Files
gridpilot.gg/docs/ARCHITECTURE.md

1048 lines
35 KiB
Markdown

# 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<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 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<void> {
// 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**: `<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-21*