Files
gridpilot.gg/docs/ARCHITECTURE.md

38 KiB

GridPilot Architecture

This document provides a technical deep-dive into GridPilot's Clean Architecture implementation. For business context, see CONCEPT.md. For technology stack details, see TECH.md.


iRacing Automation Strategy

IMPORTANT: Understanding the distinction between iRacing's interfaces is critical for our automation approach.

Two iRacing Interfaces

  1. iRacing Website (members.iracing.com): Standard HTML/DOM web application accessible at https://members-ng.iracing.com/. This is where hosted session management lives. Being a standard web application, it can be automated with browser automation tools like Playwright or Puppeteer. This is 100% legal and our preferred approach.

  2. iRacing Desktop App (Electron): The iRacing desktop application is a sandboxed Electron app. Its DOM is inaccessible, and any modification violates iRacing's Terms of Service. This is why tools like iRefined were shut down.

Automation Rules

Allowed Approaches:

  • Browser automation of the iRacing website using Playwright/Puppeteer
  • Standard DOM manipulation and interaction via browser automation APIs

Forbidden Approaches:

  • DOM automation inside the iRacing Electron desktop app
  • Script injection into the desktop client
  • Any client modification (similar to what got iRefined shut down)

Technology Stack

  • Primary: Playwright for browser automation of members.iracing.com
  • Alternative: Puppeteer (if Playwright isn't suitable for specific use cases)

Development vs Production Mode

  • Development Mode: Launches a Playwright-controlled browser to automate the real iRacing website
  • Production Mode: Same as development - browser automation targeting members.iracing.com
  • Test Mode: Uses mocked browser automation (no real browser interaction)

HTML Fixtures (resources/iracing-hosted-sessions/)

The HTML files in resources/iracing-hosted-sessions/ are static snapshots for reference and testing. They help developers understand the iRacing UI structure and serve as fixtures for E2E tests. Production automation always targets the REAL iRacing website at members-ng.iracing.com.


1. Overview

Clean Architecture Principles

GridPilot follows Clean Architecture (also known as Hexagonal Architecture or Ports and Adapters) to ensure:

  • Independence: Business logic remains independent of frameworks, UI, databases, and external services
  • Testability: Core domain logic can be tested without UI, database, or external dependencies
  • Flexibility: Infrastructure components (database, API clients) can be replaced without affecting business rules
  • Maintainability: Clear boundaries prevent coupling and make the codebase easier to understand

The Dependency Rule

Dependencies point inward. Outer layers depend on inner layers, never the reverse.

┌─────────────────────────────────────────┐
│   Presentation (Web API, Web Client,    │
│   Companion App)                         │
│   ┌─────────────────────────────────┐   │
│   │  Infrastructure (PostgreSQL,    │   │
│   │  Redis, S3, iRacing Client)     │   │
│   │  ┌──────────────────────────┐   │   │
│   │  │  Application             │   │   │
│   │  │  (Use Cases, Ports/DTOs) │   │   │
│   │  │  ┌───────────────────┐   │   │   │
│   │  │  │  Domain           │   │   │   │
│   │  │  │  (Entities, VOs,  │   │   │   │
│   │  │  │  Business Rules)  │   │   │   │
│   │  │  └───────────────────┘   │   │   │
│   │  └──────────────────────────┘   │   │
│   └─────────────────────────────────┘   │
└─────────────────────────────────────────┘

Key Rules:

  • Domain layer has zero external dependencies (no imports from application, infrastructure, or presentation)
  • Application layer depends only on domain layer
  • Infrastructure and presentation layers depend on application and domain layers
  • Infrastructure adapters implement interfaces (ports) defined in the application layer

Why Clean Architecture for GridPilot

GridPilot's complexity demands architectural discipline:

  1. Multiple Clients: Web API, Web Client, and Companion App all access the same business logic without duplication
  2. External Service Integration: iRacing OAuth, iRacing data API, potential future integrations (Discord, Stripe) remain isolated in infrastructure layer
  3. Testing: Core league management logic (scoring, standings, validation) can be tested independently of databases or external APIs
  4. Evolution: As monetization features (payment processing) and new racing platforms are added, changes remain localized to appropriate layers

2. Layer Diagram

Conceptual Flow

User Request (HTTP/UI Event)
        ↓
┌──────────────────────┐
│  PRESENTATION LAYER  │  Controllers, React Components, Electron IPC Handlers
└──────────────────────┘
        ↓ (calls use case)
┌──────────────────────┐
│  APPLICATION LAYER   │  Use Cases, Port Interfaces (IRepository, IClient)
└──────────────────────┘
        ↓ (enforces)
┌──────────────────────┐
│   DOMAIN LAYER       │  Entities, Value Objects, Business Rules
└──────────────────────┘
        ↑ (implements ports)
┌──────────────────────┐
│ INFRASTRUCTURE LAYER │  PostgresLeagueRepository, IRacingOAuthClient, S3AssetStorage
└──────────────────────┘

Dependency Inversion in Practice

Bad (Direct Coupling):

// Use case directly depends on concrete implementation
class CreateLeagueUseCase {
  constructor(private db: PostgresLeagueRepository) {} // ❌ Coupled to Postgres
}

Good (Dependency Inversion):

// Use case depends on abstraction (port interface)
class CreateLeagueUseCase {
  constructor(private leagueRepo: ILeagueRepository) {} // ✅ Depends on abstraction
}

// Infrastructure layer provides concrete implementation
class PostgresLeagueRepository implements ILeagueRepository {
  // Implementation details hidden from use case
}

3. Package Boundaries

GridPilot's monorepo structure enforces layer boundaries through directory organization:

/src
├── /packages                    # Shared domain and application logic
│   ├── /domain                  # Core business entities and rules
│   │   ├── /entities           # League, Team, Driver, Event, Result, ScoringRule
│   │   ├── /value-objects      # Email, IRacingId, PositionPoints, TeamName
│   │   └── /errors             # Domain-specific exceptions
│   │
│   ├── /application            # Use cases and port interfaces
│   │   ├── /use-cases          # CreateLeagueUseCase, ImportRaceResultsUseCase
│   │   ├── /ports              # ILeagueRepository, IRacingClient, INotificationService
│   │   └── /dtos               # Data Transfer Objects for layer communication
│   │
│   ├── /contracts              # TypeScript interfaces for cross-layer contracts
│   │   ├── /api                # HTTP request/response shapes
│   │   └── /events             # Domain event schemas
│   │
│   └── /shared                 # Cross-cutting concerns (no domain logic)
│       ├── /logger             # Winston logger adapter
│       ├── /validator          # Zod schema utilities
│       └── /crypto             # Encryption/hashing utilities
│
├── /infrastructure             # External service adapters
│   ├── /repositories           # PostgresLeagueRepository, PostgresTeamRepository
│   ├── /clients                # IRacingOAuthClient, IRacingDataClient
│   ├── /storage                # S3AssetStorage, RedisCache
│   └── /queue                  # BullJobQueue for background tasks
│
└── /apps                       # Presentation layer (framework-specific)
    ├── /web-api                # REST/GraphQL API (Express/Fastify/Hono)
    │   ├── /controllers        # HTTP request handlers
    │   ├── /middleware         # Auth, validation, error handling
    │   └── /di-container.ts    # Dependency injection wiring
    │
    ├── /web-client             # React SPA (Vite/Next.js)
    │   ├── /components         # UI components
    │   ├── /hooks              # React hooks for API calls
    │   └── /state              # TanStack Query, Zustand stores
    │
    └── /companion              # Electron desktop app
        ├── /main               # Electron main process
        ├── /renderer           # Electron renderer (React)
        └── /automation         # Playwright browser automation scripts

Import Rules (Enforced via ESLint)

// ✅ ALLOWED: Inner layer imports
// domain → nothing (zero external dependencies)
// application → domain only
import { League } from '@gridpilot/domain/entities/League';

// ✅ ALLOWED: Outer layer imports inner layers
// infrastructure → application + domain
import { ILeagueRepository } from '@gridpilot/application/ports/ILeagueRepository';
import { League } from '@gridpilot/domain/entities/League';

// ❌ FORBIDDEN: Inner layer importing outer layer
// domain → application (violates dependency rule)
import { CreateLeagueDTO } from '@gridpilot/application/dtos'; // ❌ Domain can't import application

// ❌ FORBIDDEN: Application importing infrastructure
// application → infrastructure (violates dependency inversion)
import { PostgresLeagueRepository } from '@gridpilot/infrastructure'; // ❌ Application can't know about Postgres

4. Domain Layer

The domain layer contains the core business logic with zero framework dependencies. It answers: "What are the fundamental rules of league racing?"

Core Entities

League

  • Properties: id, name, ownerId, seasons, settings
  • Business Rules:
    • League names must be unique within an organization
    • Cannot delete a league with active seasons
    • Owner must be a verified iRacing user
  • Invariants: seasons array cannot have overlapping date ranges

Team

  • Properties: id, name, captainId, members, leagueId
  • Business Rules:
    • Team names unique per league
    • Captain must be a team member
    • Maximum roster size enforced (default: 10 members)
    • Cannot add member already registered with another team in same league
  • Invariants: members includes captainId

Event (Represents a single race)

  • Properties: id, seasonId, trackId, scheduledAt, sessionId
  • Business Rules:
    • Events cannot overlap with other events in the same season
    • Scheduled time must be in the future (at creation)
    • Track must be from iRacing's current season lineup

Driver

  • Properties: id, iracingId, name, email, licenseClass
  • Business Rules:
    • iRacing ID uniqueness globally enforced
    • Email verified via iRacing OAuth
  • Validation: iracingId matches pattern ^\d{5,7}$

Result

  • Properties: id, eventId, driverId, position, points, fastestLap
  • Business Rules:
    • Position must match finishing order (no duplicates)
    • Points calculated via ScoringRule entity
    • Cannot modify results after season finalized

ScoringRule

  • Properties: id, name, positionPoints, bonusPoints
  • Business Rules:
    • Position points must be descending (1st > 2nd > 3rd...)
    • Bonus points (fastest lap, pole position) are optional
  • Example: F1-style scoring (25, 18, 15, 12, 10, 8, 6, 4, 2, 1) with fastest lap bonus

Value Objects

Value objects are immutable and defined by their attributes (no identity).

Email

class Email {
  constructor(private readonly value: string) {
    if (!this.isValid(value)) throw new InvalidEmailError(value);
  }

  private isValid(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  toString(): string { return this.value; }
}

IRacingId

class IRacingId {
  constructor(private readonly value: number) {
    if (value < 10000 || value > 9999999) {
      throw new InvalidIRacingIdError(value);
    }
  }

  toNumber(): number { return this.value; }
}

TeamName

class TeamName {
  constructor(private readonly value: string) {
    if (value.trim().length < 3 || value.length > 50) {
      throw new InvalidTeamNameError(value);
    }
  }

  toString(): string { return this.value; }
}

Business Invariants (Enforced in Domain)

  1. No Overlapping Seasons: A league cannot have two seasons with overlapping start/end dates
  2. Team Roster Limits: Teams cannot exceed configured maximum members (default: 10)
  3. Driver Uniqueness: A driver cannot be registered with multiple teams in the same league season
  4. Result Immutability: Results cannot be modified after season status changes to FINALIZED
  5. Scoring Consistency: All events in a season must use the same scoring rule

5. Application Layer

The application layer orchestrates use cases (user intentions) by coordinating domain entities and external services via port interfaces.

Use Case Pattern

All use cases follow a consistent structure:

// Example: CreateLeagueUseCase
export class CreateLeagueUseCase {
  constructor(
    private readonly leagueRepo: ILeagueRepository,        // Port interface
    private readonly eventPublisher: IEventPublisher       // Port interface
  ) {}

  async execute(input: CreateLeagueDTO): Promise<LeagueDTO> {
    // 1. Validation (DTO-level, not domain-level)
    this.validateInput(input);

    // 2. Create domain entity
    const league = League.create({
      name: input.name,
      ownerId: input.ownerId,
      settings: input.settings
    });

    // 3. Persist via repository port
    await this.leagueRepo.save(league);

    // 4. Publish domain event (optional)
    await this.eventPublisher.publish(new LeagueCreatedEvent(league.id));

    // 5. Return DTO (not domain entity)
    return LeagueDTO.fromEntity(league);
  }

  private validateInput(input: CreateLeagueDTO): void {
    // DTO validation (Zod schemas)
    if (!input.name || input.name.length < 3) {
      throw new ValidationError('League name must be at least 3 characters');
    }
  }
}

Port Interfaces (Dependency Inversion)

Ports define contracts for external dependencies without specifying implementation.

ILeagueRepository

export interface ILeagueRepository {
  save(league: League): Promise<void>;
  findById(id: string): Promise<League | null>;
  findByOwnerId(ownerId: string): Promise<League[]>;
  delete(id: string): Promise<void>;
}

IRacingClient

export interface IRacingClient {
  authenticate(authCode: string): Promise<AuthTokens>;
  getDriverProfile(iracingId: number): Promise<DriverProfile>;
  getSessionResults(sessionId: string): Promise<SessionResult[]>;
}

INotificationService

export interface INotificationService {
  sendEmail(to: string, subject: string, body: string): Promise<void>;
  sendPushNotification(userId: string, message: string): Promise<void>;
}

Key Use Cases

League Management

Season Management

Race Management

Dependency Injection Approach

Use cases receive dependencies via constructor injection:

// DI Container (apps/web-api/di-container.ts)
const container = {
  // Infrastructure implementations
  leagueRepo: new PostgresLeagueRepository(dbConnection),
  iracingClient: new IRacingOAuthClient(config.iracing),
  notificationService: new SendGridNotificationService(config.sendgrid),

  // Use cases (dependencies injected)
  createLeague: new CreateLeagueUseCase(
    container.leagueRepo,
    container.eventPublisher
  ),
  importRaceResults: new ImportRaceResultsUseCase(
    container.iracingClient,
    container.leagueRepo,
    container.eventRepo
  )
};

6. Infrastructure Layer

The infrastructure layer provides concrete implementations of port interfaces, handling external service communication.

Repository Implementations

PostgresLeagueRepository

export class PostgresLeagueRepository implements ILeagueRepository {
  constructor(private readonly db: Prisma.Client) {}

  async save(league: League): Promise<void> {
    await this.db.league.upsert({
      where: { id: league.id },
      create: this.toDbModel(league),
      update: this.toDbModel(league)
    });
  }

  async findById(id: string): Promise<League | null> {
    const dbLeague = await this.db.league.findUnique({ where: { id } });
    return dbLeague ? this.toDomainEntity(dbLeague) : null;
  }

  // Mapping methods (DB model ↔ Domain entity)
  private toDbModel(league: League): Prisma.LeagueCreateInput { /* ... */ }
  private toDomainEntity(dbLeague: DbLeague): League { /* ... */ }
}

External Service Adapters

IRacingOAuthClient

export class IRacingOAuthClient implements IRacingClient {
  constructor(private readonly config: IRacingConfig) {}

  async authenticate(authCode: string): Promise<AuthTokens> {
    const response = await fetch(this.config.tokenUrl, {
      method: 'POST',
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: authCode,
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret
      })
    });

    const data = await response.json();
    return {
      accessToken: data.access_token,
      refreshToken: data.refresh_token,
      expiresIn: data.expires_in
    };
  }

  async getSessionResults(sessionId: string): Promise<SessionResult[]> {
    // Fetch from iRacing Data API
    const response = await this.authenticatedFetch(
      `${this.config.dataApiUrl}/results/${sessionId}`
    );
    return this.mapToSessionResults(await response.json());
  }
}

S3AssetStorage

export class S3AssetStorage implements IAssetStorage {
  constructor(private readonly s3Client: S3Client) {}

  async uploadLeagueLogo(leagueId: string, file: Buffer): Promise<string> {
    const key = `leagues/${leagueId}/logo.png`;
    await this.s3Client.send(new PutObjectCommand({
      Bucket: this.config.bucketName,
      Key: key,
      Body: file,
      ContentType: 'image/png'
    }));

    return `https://${this.config.cdnUrl}/${key}`;
  }
}

RedisCache

export class RedisCache implements ICacheService {
  constructor(private readonly redis: Redis) {}

  async get<T>(key: string): Promise<T | null> {
    const value = await this.redis.get(key);
    return value ? JSON.parse(value) : null;
  }

  async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
    await this.redis.setex(key, ttlSeconds, JSON.stringify(value));
  }
}

Database Schema Considerations

PostgreSQL Tables (Simplified)

-- Leagues table
CREATE TABLE leagues (
  id UUID PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  owner_id UUID NOT NULL REFERENCES drivers(id),
  settings JSONB,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Seasons table (belongs to league)
CREATE TABLE seasons (
  id UUID PRIMARY KEY,
  league_id UUID NOT NULL REFERENCES leagues(id) ON DELETE CASCADE,
  name VARCHAR(255) NOT NULL,
  start_date DATE NOT NULL,
  end_date DATE NOT NULL,
  status VARCHAR(50) DEFAULT 'UPCOMING',
  CONSTRAINT no_overlapping_seasons EXCLUDE USING gist (
    league_id WITH =,
    daterange(start_date, end_date, '[]') WITH &&
  )
);

-- Teams table
CREATE TABLE teams (
  id UUID PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  captain_id UUID NOT NULL REFERENCES drivers(id),
  league_id UUID NOT NULL REFERENCES leagues(id),
  UNIQUE(league_id, name)
);

JSONB Usage

  • leagues.settings: Flexible league configuration (scoring rules, roster limits)
  • events.session_metadata: iRacing session details (weather, track conditions)

7. Presentation Layer

The presentation layer exposes the system to users via Web API, Web Client, and Companion App.

Web API (REST/GraphQL)

HTTP Controllers translate HTTP requests into use case calls.

LeagueController

export class LeagueController {
  constructor(
    private readonly createLeagueUseCase: CreateLeagueUseCase,
    private readonly getLeagueUseCase: GetLeagueUseCase
  ) {}

  @Post('/leagues')
  @Authenticated()
  async createLeague(req: Request, res: Response): Promise<void> {
    try {
      const dto = CreateLeagueDTO.fromRequest(req.body);
      const league = await this.createLeagueUseCase.execute(dto);
      res.status(201).json(league);
    } catch (error) {
      this.handleError(error, res);
    }
  }

  @Get('/leagues/:id')
  async getLeague(req: Request, res: Response): Promise<void> {
    const league = await this.getLeagueUseCase.execute(req.params.id);
    if (!league) {
      res.status(404).json({ error: 'League not found' });
      return;
    }
    res.json(league);
  }
}

Middleware Stack

  1. Authentication: JWT validation, iRacing OAuth token refresh
  2. Validation: Zod schema validation for request bodies
  3. Rate Limiting: Redis-backed throttling (100 req/min per user)
  4. Error Handling: Centralized error formatting, logging

Web Client (React SPA)

Component Architecture

// React component calls use case via API hook
export function CreateLeagueForm() {
  const createLeague = useCreateLeague(); // TanStack Query mutation

  const handleSubmit = async (data: CreateLeagueFormData) => {
    await createLeague.mutateAsync(data); // Triggers API call
    // TanStack Query handles cache invalidation
  };

  return <form onSubmit={handleSubmit}>{ /* ... */ }</form>;
}

// API hook (wraps HTTP call)
function useCreateLeague() {
  return useMutation({
    mutationFn: async (data: CreateLeagueFormData) => {
      const response = await fetch('/api/leagues', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
      });
      return response.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries(['leagues']); // Refresh league list
    }
  });
}

State Management

  • TanStack Query: Server state (league data, race results) with caching
  • Zustand: Client state (UI preferences, selected league filter)

Companion App (Electron)

Main Process (Node.js)

  • Handles IPC from renderer process
  • Invokes use cases (directly calls application layer, not via HTTP)
  • Manages Playwright browser automation workflows
// Electron IPC handler
ipcMain.handle('create-iracing-session', async (event, sessionData) => {
  const useCase = container.createRaceSessionUseCase;
  const result = await useCase.execute(sessionData);

  if (result.requiresAutomation) {
    // Trigger Playwright browser automation
    await automationService.createSessionInBrowser(result.sessionDetails);
  }

  return result;
});

Renderer Process (React)

  • UI for session creation, result monitoring
  • IPC communication with main process

Playwright Browser Automation (PlaywrightAutomationAdapter)

GridPilot uses Playwright for all automation tasks. This is the only automation approach—there is no OS-level automation or fallback.

import { chromium, Browser, Page } from 'playwright';

export class PlaywrightAutomationAdapter {
  private browser: Browser | null = null;
  private page: Page | null = null;

  async createSessionInBrowser(details: SessionDetails): Promise<void> {
    // 1. Launch browser via Playwright
    this.browser = await chromium.launch({ headless: false });
    this.page = await this.browser.newPage();

    // 2. Navigate to iRacing session creation page
    await this.page.goto('https://members-ng.iracing.com/web/racing/hosted/create');

    // 3. Fill form fields using DOM selectors
    await this.page.fill('[data-testid="session-name"]', details.name);
    await this.page.selectOption('[data-testid="track-select"]', details.track);

    // 4. Submit form
    await this.page.click('[data-testid="create-session-button"]');

    // 5. Wait for confirmation
    await this.page.waitForSelector('[data-testid="session-created-confirmation"]');
  }

  async cleanup(): Promise<void> {
    await this.browser?.close();
  }
}

Dependency Injection Wiring (Per App)

Each app wires dependencies in its di-container.ts:

// apps/web-api/di-container.ts
export function createDIContainer(config: Config) {
  const db = new PrismaClient();
  const redis = new Redis(config.redis);

  // Infrastructure layer
  const leagueRepo = new PostgresLeagueRepository(db);
  const iracingClient = new IRacingOAuthClient(config.iracing);

  // Application layer
  const createLeague = new CreateLeagueUseCase(leagueRepo);
  const importResults = new ImportRaceResultsUseCase(iracingClient, leagueRepo);

  return { createLeague, importResults };
}

8. Data Flow Example

Complete Flow: User Requests League Standings

Step-by-Step:

  1. User Action: Driver navigates to /leagues/123/standings in Web Client
  2. React Component: <StandingsTable> component mounts, triggers TanStack Query
  3. HTTP Request: GET /api/leagues/123/standings
  4. Web API Controller: StandingsController.getStandings()
  5. Use Case Invocation: GenerateStandingsUseCase.execute(leagueId: '123')
  6. Repository Call: ILeagueRepository.findById('123') (port interface)
  7. PostgreSQL Query: PostgresLeagueRepository executes SQL via Prisma
  8. Domain Entity: Repository returns League domain entity
  9. Standings Calculation: Use case computes standings from League.events and ScoringRule
  10. DTO Conversion: Use case returns StandingsDTO (serializable)
  11. HTTP Response: Controller sends JSON to client
  12. React Update: TanStack Query caches data, component re-renders with standings

Sequence Diagram:

User → Web Client → Web API → Use Case → Repository → PostgreSQL
                                  ↓
                          Domain Entity (League)
                                  ↓
                         Compute Standings
                                  ↓
                            Return DTO
                                  ↓
User ← Web Client ← Web API ← Use Case

9. Cross-Cutting Concerns

Logging Strategy

Logger (Winston wrapper)

export class Logger {
  log(level: LogLevel, message: string, context?: Record<string, any>): void {
    winston.log(level, message, { ...context, timestamp: new Date() });
  }

  error(message: string, error: Error, context?: Record<string, any>): void {
    this.log('error', message, { ...context, stack: error.stack });
  }
}

Usage Across Layers:

  • Domain: Logs business rule violations (e.g., "League season overlap detected")
  • Application: Logs use case execution (e.g., "CreateLeagueUseCase executed for user 123")
  • Infrastructure: Logs external service calls (e.g., "iRacing API request failed, retrying...")

Error Handling Patterns

Domain Errors (Business Rule Violations)

export class LeagueSeasonOverlapError extends DomainError {
  constructor(leagueId: string) {
    super(`League ${leagueId} has overlapping seasons`);
  }
}

Application Errors (Use Case Failures)

export class LeagueNotFoundError extends ApplicationError {
  constructor(leagueId: string) {
    super(`League ${leagueId} not found`, 404);
  }
}

Infrastructure Errors (External Service Failures)

export class IRacingAPIError extends InfrastructureError {
  constructor(message: string, statusCode: number) {
    super(`iRacing API error: ${message}`, statusCode);
  }
}

Error Propagation:

  • Errors bubble up from domain → application → infrastructure/presentation
  • Presentation layer translates errors into HTTP status codes or UI messages

Validation

Two-Level Validation:

  1. Domain Validation (Entity Invariants)
class League {
  constructor(name: string) {
    if (name.length < 3) {
      throw new InvalidLeagueNameError(name); // Domain error
    }
  }
}
  1. DTO Validation (Input Sanitization)
const CreateLeagueDTOSchema = z.object({
  name: z.string().min(3).max(100),
  ownerId: z.string().uuid()
});

export class CreateLeagueDTO {
  static fromRequest(body: unknown): CreateLeagueDTO {
    return CreateLeagueDTOSchema.parse(body); // Throws if invalid
  }
}

10. Monorepo Inter-Package Dependencies

Dependency Rules (DAG Enforcement)

Directed Acyclic Graph:

apps/web-api ────┐
apps/web-client ─┤
apps/companion ──┘
    ↓
infrastructure ──┐
    ↓            │
application ─────┘
    ↓
domain (no dependencies)
    ↑
shared (utilities only, no domain logic)

Import Restrictions (ESLint Plugin):

// .eslintrc.js
module.exports = {
  rules: {
    'import/no-restricted-paths': [
      'error',
      {
        zones: [
          { target: './src/packages/domain', from: './src/packages/application' },
          { target: './src/packages/domain', from: './src/infrastructure' },
          { target: './src/packages/application', from: './src/infrastructure' },
          { target: './src/packages/application', from: './src/apps' }
        ]
      }
    ]
  }
};

Build Order Implications

Compilation Order:

  1. domain (no dependencies)
  2. application (depends on domain)
  3. infrastructure (depends on application, domain)
  4. apps/* (depends on all above)

Monorepo Tool Integration:

  • npm workspaces: Automatically hoists shared dependencies
  • Turborepo/Nx: Caches builds, parallelizes independent packages

11. Extension Points

Adding New Scoring Rules (Plugin Architecture)

Current Approach:

class ScoringRule {
  calculatePoints(position: number): number {
    return this.positionPoints[position] || 0;
  }
}

Future Plugin System:

interface IScoringPlugin {
  name: string;
  calculatePoints(position: number, context: RaceContext): number;
}

// Plugin example: Dynamic scoring based on field size
class DynamicScoringPlugin implements IScoringPlugin {
  calculatePoints(position: number, context: RaceContext): number {
    const fieldSize = context.numberOfDrivers;
    return Math.max(0, fieldSize - position + 1);
  }
}

// Register plugin
scoringRegistry.register(new DynamicScoringPlugin());

Integrating Third-Party APIs (New Infrastructure Adapters)

Example: Discord Notifications

// 1. Define port interface (application layer)
interface IDiscordNotificationService {
  sendMessage(webhookUrl: string, message: string): Promise<void>;
}

// 2. Implement adapter (infrastructure layer)
class DiscordWebhookAdapter implements IDiscordNotificationService {
  async sendMessage(webhookUrl: string, message: string): Promise<void> {
    await fetch(webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: message })
    });
  }
}

// 3. Wire in DI container
const discordService = new DiscordWebhookAdapter();
const notifyOnRaceComplete = new NotifyOnRaceCompleteUseCase(discordService);

Extending Monetization Features

Example: Stripe Payment Integration

// 1. Define port (application layer)
interface IPaymentProcessor {
  createPaymentIntent(amount: number, currency: string): Promise<string>;
  confirmPayment(paymentIntentId: string): Promise<boolean>;
}

// 2. Implement adapter (infrastructure layer)
class StripeAdapter implements IPaymentProcessor {
  async createPaymentIntent(amount: number, currency: string): Promise<string> {
    const intent = await this.stripe.paymentIntents.create({ amount, currency });
    return intent.id;
  }
}

// 3. Use in use case (application layer)
class ChargeSeasonEntryFeeUseCase {
  constructor(private readonly paymentProcessor: IPaymentProcessor) {}

  async execute(seasonId: string, driverId: string): Promise<void> {
    const season = await this.seasonRepo.findById(seasonId);
    const paymentIntentId = await this.paymentProcessor.createPaymentIntent(
      season.entryFee,
      'usd'
    );
    // Store payment intent ID, await webhook confirmation
  }
}

How Clean Architecture Supports Evolution

  1. New Features: Add use cases without modifying existing code
  2. Technology Changes: Swap infrastructure adapters (e.g., Postgres → MongoDB) without touching domain/application layers
  3. Multi-Tenancy: Introduce tenant isolation at infrastructure layer (row-level security)
  4. API Versioning: Create new controllers/DTOs while preserving old endpoints

12. Testing Strategy Integration

For detailed testing strategy, see TESTS.md.

How Architecture Enables Testing

Unit Tests (Domain & Application)

// No mocks needed for pure domain logic
test('League prevents overlapping seasons', () => {
  const league = new League({ name: 'Test League' });
  league.addSeason({ startDate: '2025-01-01', endDate: '2025-06-01' });

  expect(() => {
    league.addSeason({ startDate: '2025-05-01', endDate: '2025-12-01' });
  }).toThrow(LeagueSeasonOverlapError);
});

// Mock ports for use case tests
test('CreateLeagueUseCase saves league', async () => {
  const mockRepo = mock<ILeagueRepository>();
  const useCase = new CreateLeagueUseCase(mockRepo);

  await useCase.execute({ name: 'Test League', ownerId: '123' });

  expect(mockRepo.save).toHaveBeenCalledWith(expect.any(League));
});

Integration Tests (Infrastructure)

// Test with real PostgreSQL (Docker Testcontainers)
test('PostgresLeagueRepository persists league', async () => {
  const db = await setupTestDatabase();
  const repo = new PostgresLeagueRepository(db);

  const league = new League({ name: 'Integration Test League' });
  await repo.save(league);

  const retrieved = await repo.findById(league.id);
  expect(retrieved?.name).toBe('Integration Test League');
});

E2E Tests (Full Stack)

// Test with Playwright (browser automation)
test('User creates league via Web Client', async ({ page }) => {
  await page.goto('/leagues/new');
  await page.fill('input[name="name"]', 'E2E Test League');
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL(/\/leagues\/[a-z0-9-]+/);
  await expect(page.locator('h1')).toContainText('E2E Test League');
});

Cross-References

  • Business Context: See CONCEPT.md for problem statement and user journeys
  • Technology Stack: See TECH.md for framework choices and tooling
  • Testing Details: See TESTS.md for comprehensive testing strategy (coming soon)

This architecture documentation will evolve as GridPilot matures. All changes must maintain Clean Architecture principles and the dependency rule.

Last Updated: 2025-11-23