# 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). --- ## 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 # Nut.js 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 { // 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; findById(id: string): Promise; findByOwnerId(ownerId: string): Promise; delete(id: string): Promise; } ``` **[`IRacingClient`](../src/packages/application/ports/IRacingClient.ts)** ```typescript export interface IRacingClient { authenticate(authCode: string): Promise; getDriverProfile(iracingId: number): Promise; getSessionResults(sessionId: string): Promise; } ``` **[`INotificationService`](../src/packages/application/ports/INotificationService.ts)** ```typescript export interface INotificationService { sendEmail(to: string, subject: string, body: string): Promise; sendPushNotification(userId: string, message: string): Promise; } ``` ### 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 { await this.db.league.upsert({ where: { id: league.id }, create: this.toDbModel(league), update: this.toDbModel(league) }); } async findById(id: string): Promise { 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 { 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 { // 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 { 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(key: string): Promise { const value = await this.redis.get(key); return value ? JSON.parse(value) : null; } async set(key: string, value: T, ttlSeconds: number): Promise { 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 { 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 { 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
{ /* ... */ }
; } // 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 Nut.js 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 Nut.js automation await automationService.createSessionInBrowser(result.sessionDetails); } return result; }); ``` **Renderer Process (React)** - UI for session creation, result monitoring - IPC communication with main process **Nut.js Automation** ([`AutomationService`](../src/apps/companion/automation/AutomationService.ts)) ```typescript export class AutomationService { async createSessionInBrowser(details: SessionDetails): Promise { // 1. Launch browser via Nut.js await mouse.click(/* iRacing icon position */); // 2. Navigate to session creation page await keyboard.type('https://members.iracing.com/membersite/CreateSession'); // 3. Fill form fields await this.fillField('session-name', details.name); await this.fillField('track', details.track); // 4. Submit form await this.clickButton('create-session'); } } ``` ### 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**: `` 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): void { winston.log(level, message, { ...context, timestamp: new Date() }); } error(message: string, error: Error, context?: Record): 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; } // 2. Implement adapter (infrastructure layer) class DiscordWebhookAdapter implements IDiscordNotificationService { async sendMessage(webhookUrl: string, message: string): Promise { 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; confirmPayment(paymentIntentId: string): Promise; } // 2. Implement adapter (infrastructure layer) class StripeAdapter implements IPaymentProcessor { async createPaymentIntent(amount: number, currency: string): Promise { 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 { 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(); 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-21*