# GridPilot Architecture

This document provides a technical deep-dive into GridPilot's Clean Architecture implementation. For business context, see [CONCEPT.md](./CONCEPT.md). For technology stack details, see [TECH.md](./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):**
```typescript
// Use case directly depends on concrete implementation
class CreateLeagueUseCase {
  constructor(private db: PostgresLeagueRepository) {} // ❌ Coupled to Postgres
}
```

**Good (Dependency Inversion):**
```typescript
// 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)

```typescript
// ✅ 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`](../src/packages/domain/entities/League.ts)**
- 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`](../src/packages/domain/entities/Team.ts)**
- 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`](../src/packages/domain/entities/Event.ts)** (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`](../src/packages/domain/entities/Driver.ts)**
- 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`](../src/packages/domain/entities/Result.ts)**
- 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`](../src/packages/domain/entities/ScoringRule.ts)**
- 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`](../src/packages/domain/value-objects/Email.ts)**
```typescript
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`](../src/packages/domain/value-objects/IRacingId.ts)**
```typescript
class IRacingId {
  constructor(private readonly value: number) {
    if (value < 10000 || value > 9999999) {
      throw new InvalidIRacingIdError(value);
    }
  }

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

**[`TeamName`](../src/packages/domain/value-objects/TeamName.ts)**
```typescript
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:

```typescript
// 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`](../src/packages/application/ports/ILeagueRepository.ts)**
```typescript
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`](../src/packages/application/ports/IRacingClient.ts)**
```typescript
export interface IRacingClient {
  authenticate(authCode: string): Promise<AuthTokens>;
  getDriverProfile(iracingId: number): Promise<DriverProfile>;
  getSessionResults(sessionId: string): Promise<SessionResult[]>;
}
```

**[`INotificationService`](../src/packages/application/ports/INotificationService.ts)**
```typescript
export interface INotificationService {
  sendEmail(to: string, subject: string, body: string): Promise<void>;
  sendPushNotification(userId: string, message: string): Promise<void>;
}
```

### Key Use Cases

**League Management**
- [`CreateLeagueUseCase`](../src/packages/application/use-cases/CreateLeagueUseCase.ts): Create new league with validation
- [`UpdateLeagueSettingsUseCase`](../src/packages/application/use-cases/UpdateLeagueSettingsUseCase.ts): Modify league configuration
- [`DeleteLeagueUseCase`](../src/packages/application/use-cases/DeleteLeagueUseCase.ts): Archive league (soft delete)

**Season Management**
- [`CreateSeasonUseCase`](../src/packages/application/use-cases/CreateSeasonUseCase.ts): Define new season with schedule
- [`RegisterDriverForSeasonUseCase`](../src/packages/application/use-cases/RegisterDriverForSeasonUseCase.ts): Handle driver sign-ups
- [`RegisterTeamForSeasonUseCase`](../src/packages/application/use-cases/RegisterTeamForSeasonUseCase.ts): Handle team sign-ups

**Race Management**
- [`ImportRaceResultsUseCase`](../src/packages/application/use-cases/ImportRaceResultsUseCase.ts): Fetch results from iRacing, calculate points
- [`GenerateStandingsUseCase`](../src/packages/application/use-cases/GenerateStandingsUseCase.ts): Compute driver/team standings
- [`CreateRaceSessionUseCase`](../src/packages/application/use-cases/CreateRaceSessionUseCase.ts): Initiate session creation workflow

### Dependency Injection Approach

Use cases receive dependencies via constructor injection:

```typescript
// 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`](../src/infrastructure/repositories/PostgresLeagueRepository.ts)**
```typescript
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`](../src/infrastructure/clients/IRacingOAuthClient.ts)**
```typescript
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`](../src/infrastructure/storage/S3AssetStorage.ts)**
```typescript
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`](../src/infrastructure/storage/RedisCache.ts)**
```typescript
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)**
```sql
-- 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`](../src/apps/web-api/controllers/LeagueController.ts)**
```typescript
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**
```typescript
// 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

```typescript
// 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`](../packages/infrastructure/adapters/automation/PlaywrightAutomationAdapter.ts))

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

```typescript
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`:

```typescript
// 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`](../src/packages/shared/logger/Logger.ts)** (Winston wrapper)
```typescript
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)
```typescript
export class LeagueSeasonOverlapError extends DomainError {
  constructor(leagueId: string) {
    super(`League ${leagueId} has overlapping seasons`);
  }
}
```

**Application Errors** (Use Case Failures)
```typescript
export class LeagueNotFoundError extends ApplicationError {
  constructor(leagueId: string) {
    super(`League ${leagueId} not found`, 404);
  }
}
```

**Infrastructure Errors** (External Service Failures)
```typescript
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)
```typescript
class League {
  constructor(name: string) {
    if (name.length < 3) {
      throw new InvalidLeagueNameError(name); // Domain error
    }
  }
}
```

2. **DTO Validation** (Input Sanitization)
```typescript
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):**
```javascript
// .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:**
```typescript
class ScoringRule {
  calculatePoints(position: number): number {
    return this.positionPoints[position] || 0;
  }
}
```

**Future Plugin System:**
```typescript
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**
```typescript
// 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**
```typescript
// 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`](./TESTS.md).

### How Architecture Enables Testing

**Unit Tests (Domain & Application)**
```typescript
// 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)**
```typescript
// 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)**
```typescript
// 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](./CONCEPT.md) for problem statement and user journeys
- **Technology Stack**: See [TECH.md](./TECH.md) for framework choices and tooling
- **Testing Details**: See [TESTS.md](./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*