38 KiB
GridPilot Architecture
This document provides a technical deep-dive into GridPilot's Clean Architecture implementation. For business context, see CONCEPT.md. For technology stack details, see TECH.md.
iRacing Automation Strategy
IMPORTANT: Understanding the distinction between iRacing's interfaces is critical for our automation approach.
Two iRacing Interfaces
-
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. -
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:
- Multiple Clients: Web API, Web Client, and Companion App all access the same business logic without duplication
- External Service Integration: iRacing OAuth, iRacing data API, potential future integrations (Discord, Stripe) remain isolated in infrastructure layer
- Testing: Core league management logic (scoring, standings, validation) can be tested independently of databases or external APIs
- Evolution: As monetization features (payment processing) and new racing platforms are added, changes remain localized to appropriate layers
2. Layer Diagram
Conceptual Flow
User Request (HTTP/UI Event)
↓
┌──────────────────────┐
│ PRESENTATION LAYER │ Controllers, React Components, Electron IPC Handlers
└──────────────────────┘
↓ (calls use case)
┌──────────────────────┐
│ APPLICATION LAYER │ Use Cases, Port Interfaces (IRepository, IClient)
└──────────────────────┘
↓ (enforces)
┌──────────────────────┐
│ DOMAIN LAYER │ Entities, Value Objects, Business Rules
└──────────────────────┘
↑ (implements ports)
┌──────────────────────┐
│ INFRASTRUCTURE LAYER │ PostgresLeagueRepository, IRacingOAuthClient, S3AssetStorage
└──────────────────────┘
Dependency Inversion in Practice
Bad (Direct Coupling):
// Use case directly depends on concrete implementation
class CreateLeagueUseCase {
constructor(private db: PostgresLeagueRepository) {} // ❌ Coupled to Postgres
}
Good (Dependency Inversion):
// Use case depends on abstraction (port interface)
class CreateLeagueUseCase {
constructor(private leagueRepo: ILeagueRepository) {} // ✅ Depends on abstraction
}
// Infrastructure layer provides concrete implementation
class PostgresLeagueRepository implements ILeagueRepository {
// Implementation details hidden from use case
}
3. Package Boundaries
GridPilot's monorepo structure enforces layer boundaries through directory organization:
/src
├── /packages # Shared domain and application logic
│ ├── /domain # Core business entities and rules
│ │ ├── /entities # League, Team, Driver, Event, Result, ScoringRule
│ │ ├── /value-objects # Email, IRacingId, PositionPoints, TeamName
│ │ └── /errors # Domain-specific exceptions
│ │
│ ├── /application # Use cases and port interfaces
│ │ ├── /use-cases # CreateLeagueUseCase, ImportRaceResultsUseCase
│ │ ├── /ports # ILeagueRepository, IRacingClient, INotificationService
│ │ └── /dtos # Data Transfer Objects for layer communication
│ │
│ ├── /contracts # TypeScript interfaces for cross-layer contracts
│ │ ├── /api # HTTP request/response shapes
│ │ └── /events # Domain event schemas
│ │
│ └── /shared # Cross-cutting concerns (no domain logic)
│ ├── /logger # Winston logger adapter
│ ├── /validator # Zod schema utilities
│ └── /crypto # Encryption/hashing utilities
│
├── /infrastructure # External service adapters
│ ├── /repositories # PostgresLeagueRepository, PostgresTeamRepository
│ ├── /clients # IRacingOAuthClient, IRacingDataClient
│ ├── /storage # S3AssetStorage, RedisCache
│ └── /queue # BullJobQueue for background tasks
│
└── /apps # Presentation layer (framework-specific)
├── /web-api # REST/GraphQL API (Express/Fastify/Hono)
│ ├── /controllers # HTTP request handlers
│ ├── /middleware # Auth, validation, error handling
│ └── /di-container.ts # Dependency injection wiring
│
├── /web-client # React SPA (Vite/Next.js)
│ ├── /components # UI components
│ ├── /hooks # React hooks for API calls
│ └── /state # TanStack Query, Zustand stores
│
└── /companion # Electron desktop app
├── /main # Electron main process
├── /renderer # Electron renderer (React)
└── /automation # Playwright browser automation scripts
Import Rules (Enforced via ESLint)
// ✅ ALLOWED: Inner layer imports
// domain → nothing (zero external dependencies)
// application → domain only
import { League } from '@gridpilot/domain/entities/League';
// ✅ ALLOWED: Outer layer imports inner layers
// infrastructure → application + domain
import { ILeagueRepository } from '@gridpilot/application/ports/ILeagueRepository';
import { League } from '@gridpilot/domain/entities/League';
// ❌ FORBIDDEN: Inner layer importing outer layer
// domain → application (violates dependency rule)
import { CreateLeagueDTO } from '@gridpilot/application/dtos'; // ❌ Domain can't import application
// ❌ FORBIDDEN: Application importing infrastructure
// application → infrastructure (violates dependency inversion)
import { PostgresLeagueRepository } from '@gridpilot/infrastructure'; // ❌ Application can't know about Postgres
4. Domain Layer
The domain layer contains the core business logic with zero framework dependencies. It answers: "What are the fundamental rules of league racing?"
Core Entities
- 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:
seasonsarray cannot have overlapping date ranges
- 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:
membersincludescaptainId
Event (Represents a single race)
- Properties:
id,seasonId,trackId,scheduledAt,sessionId - Business Rules:
- Events cannot overlap with other events in the same season
- Scheduled time must be in the future (at creation)
- Track must be from iRacing's current season lineup
- Properties:
id,iracingId,name,email,licenseClass - Business Rules:
- iRacing ID uniqueness globally enforced
- Email verified via iRacing OAuth
- Validation:
iracingIdmatches pattern^\d{5,7}$
- Properties:
id,eventId,driverId,position,points,fastestLap - Business Rules:
- Position must match finishing order (no duplicates)
- Points calculated via
ScoringRuleentity - Cannot modify results after season finalized
- 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).
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; }
}
class IRacingId {
constructor(private readonly value: number) {
if (value < 10000 || value > 9999999) {
throw new InvalidIRacingIdError(value);
}
}
toNumber(): number { return this.value; }
}
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)
- No Overlapping Seasons: A league cannot have two seasons with overlapping start/end dates
- Team Roster Limits: Teams cannot exceed configured maximum members (default: 10)
- Driver Uniqueness: A driver cannot be registered with multiple teams in the same league season
- Result Immutability: Results cannot be modified after season status changes to
FINALIZED - Scoring Consistency: All events in a season must use the same scoring rule
5. Application Layer
The application layer orchestrates use cases (user intentions) by coordinating domain entities and external services via port interfaces.
Use Case Pattern
All use cases follow a consistent structure:
// Example: CreateLeagueUseCase
export class CreateLeagueUseCase {
constructor(
private readonly leagueRepo: ILeagueRepository, // Port interface
private readonly eventPublisher: IEventPublisher // Port interface
) {}
async execute(input: CreateLeagueDTO): Promise<LeagueDTO> {
// 1. Validation (DTO-level, not domain-level)
this.validateInput(input);
// 2. Create domain entity
const league = League.create({
name: input.name,
ownerId: input.ownerId,
settings: input.settings
});
// 3. Persist via repository port
await this.leagueRepo.save(league);
// 4. Publish domain event (optional)
await this.eventPublisher.publish(new LeagueCreatedEvent(league.id));
// 5. Return DTO (not domain entity)
return LeagueDTO.fromEntity(league);
}
private validateInput(input: CreateLeagueDTO): void {
// DTO validation (Zod schemas)
if (!input.name || input.name.length < 3) {
throw new ValidationError('League name must be at least 3 characters');
}
}
}
Port Interfaces (Dependency Inversion)
Ports define contracts for external dependencies without specifying implementation.
export interface ILeagueRepository {
save(league: League): Promise<void>;
findById(id: string): Promise<League | null>;
findByOwnerId(ownerId: string): Promise<League[]>;
delete(id: string): Promise<void>;
}
export interface IRacingClient {
authenticate(authCode: string): Promise<AuthTokens>;
getDriverProfile(iracingId: number): Promise<DriverProfile>;
getSessionResults(sessionId: string): Promise<SessionResult[]>;
}
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: Create new league with validationUpdateLeagueSettingsUseCase: Modify league configurationDeleteLeagueUseCase: Archive league (soft delete)
Season Management
CreateSeasonUseCase: Define new season with scheduleRegisterDriverForSeasonUseCase: Handle driver sign-upsRegisterTeamForSeasonUseCase: Handle team sign-ups
Race Management
ImportRaceResultsUseCase: Fetch results from iRacing, calculate pointsGenerateStandingsUseCase: Compute driver/team standingsCreateRaceSessionUseCase: Initiate session creation workflow
Dependency Injection Approach
Use cases receive dependencies via constructor injection:
// DI Container (apps/web-api/di-container.ts)
const container = {
// Infrastructure implementations
leagueRepo: new PostgresLeagueRepository(dbConnection),
iracingClient: new IRacingOAuthClient(config.iracing),
notificationService: new SendGridNotificationService(config.sendgrid),
// Use cases (dependencies injected)
createLeague: new CreateLeagueUseCase(
container.leagueRepo,
container.eventPublisher
),
importRaceResults: new ImportRaceResultsUseCase(
container.iracingClient,
container.leagueRepo,
container.eventRepo
)
};
6. Infrastructure Layer
The infrastructure layer provides concrete implementations of port interfaces, handling external service communication.
Repository Implementations
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
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());
}
}
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}`;
}
}
export class RedisCache implements ICacheService {
constructor(private readonly redis: Redis) {}
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key);
return value ? JSON.parse(value) : null;
}
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
await this.redis.setex(key, ttlSeconds, JSON.stringify(value));
}
}
Database Schema Considerations
PostgreSQL Tables (Simplified)
-- Leagues table
CREATE TABLE leagues (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
owner_id UUID NOT NULL REFERENCES drivers(id),
settings JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Seasons table (belongs to league)
CREATE TABLE seasons (
id UUID PRIMARY KEY,
league_id UUID NOT NULL REFERENCES leagues(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status VARCHAR(50) DEFAULT 'UPCOMING',
CONSTRAINT no_overlapping_seasons EXCLUDE USING gist (
league_id WITH =,
daterange(start_date, end_date, '[]') WITH &&
)
);
-- Teams table
CREATE TABLE teams (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL,
captain_id UUID NOT NULL REFERENCES drivers(id),
league_id UUID NOT NULL REFERENCES leagues(id),
UNIQUE(league_id, name)
);
JSONB Usage
leagues.settings: Flexible league configuration (scoring rules, roster limits)events.session_metadata: iRacing session details (weather, track conditions)
7. Presentation Layer
The presentation layer exposes the system to users via Web API, Web Client, and Companion App.
Web API (REST/GraphQL)
HTTP Controllers translate HTTP requests into use case calls.
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
- Authentication: JWT validation, iRacing OAuth token refresh
- Validation: Zod schema validation for request bodies
- Rate Limiting: Redis-backed throttling (100 req/min per user)
- Error Handling: Centralized error formatting, logging
Web Client (React SPA)
Component Architecture
// React component calls use case via API hook
export function CreateLeagueForm() {
const createLeague = useCreateLeague(); // TanStack Query mutation
const handleSubmit = async (data: CreateLeagueFormData) => {
await createLeague.mutateAsync(data); // Triggers API call
// TanStack Query handles cache invalidation
};
return <form onSubmit={handleSubmit}>{ /* ... */ }</form>;
}
// API hook (wraps HTTP call)
function useCreateLeague() {
return useMutation({
mutationFn: async (data: CreateLeagueFormData) => {
const response = await fetch('/api/leagues', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries(['leagues']); // Refresh league list
}
});
}
State Management
- TanStack Query: Server state (league data, race results) with caching
- Zustand: Client state (UI preferences, selected league filter)
Companion App (Electron)
Main Process (Node.js)
- Handles IPC from renderer process
- Invokes use cases (directly calls application layer, not via HTTP)
- Manages Playwright browser automation workflows
// Electron IPC handler
ipcMain.handle('create-iracing-session', async (event, sessionData) => {
const useCase = container.createRaceSessionUseCase;
const result = await useCase.execute(sessionData);
if (result.requiresAutomation) {
// Trigger Playwright browser automation
await automationService.createSessionInBrowser(result.sessionDetails);
}
return result;
});
Renderer Process (React)
- UI for session creation, result monitoring
- IPC communication with main process
Playwright Browser Automation (PlaywrightAutomationAdapter)
GridPilot uses Playwright for all automation tasks. This is the only automation approach—there is no OS-level automation or fallback.
import { chromium, Browser, Page } from 'playwright';
export class PlaywrightAutomationAdapter {
private browser: Browser | null = null;
private page: Page | null = null;
async createSessionInBrowser(details: SessionDetails): Promise<void> {
// 1. Launch browser via Playwright
this.browser = await chromium.launch({ headless: false });
this.page = await this.browser.newPage();
// 2. Navigate to iRacing session creation page
await this.page.goto('https://members-ng.iracing.com/web/racing/hosted/create');
// 3. Fill form fields using DOM selectors
await this.page.fill('[data-testid="session-name"]', details.name);
await this.page.selectOption('[data-testid="track-select"]', details.track);
// 4. Submit form
await this.page.click('[data-testid="create-session-button"]');
// 5. Wait for confirmation
await this.page.waitForSelector('[data-testid="session-created-confirmation"]');
}
async cleanup(): Promise<void> {
await this.browser?.close();
}
}
Dependency Injection Wiring (Per App)
Each app wires dependencies in its di-container.ts:
// apps/web-api/di-container.ts
export function createDIContainer(config: Config) {
const db = new PrismaClient();
const redis = new Redis(config.redis);
// Infrastructure layer
const leagueRepo = new PostgresLeagueRepository(db);
const iracingClient = new IRacingOAuthClient(config.iracing);
// Application layer
const createLeague = new CreateLeagueUseCase(leagueRepo);
const importResults = new ImportRaceResultsUseCase(iracingClient, leagueRepo);
return { createLeague, importResults };
}
8. Data Flow Example
Complete Flow: User Requests League Standings
Step-by-Step:
- User Action: Driver navigates to
/leagues/123/standingsin Web Client - React Component:
<StandingsTable>component mounts, triggers TanStack Query - HTTP Request:
GET /api/leagues/123/standings - Web API Controller:
StandingsController.getStandings() - Use Case Invocation:
GenerateStandingsUseCase.execute(leagueId: '123') - Repository Call:
ILeagueRepository.findById('123')(port interface) - PostgreSQL Query:
PostgresLeagueRepositoryexecutes SQL via Prisma - Domain Entity: Repository returns
Leaguedomain entity - Standings Calculation: Use case computes standings from
League.eventsandScoringRule - DTO Conversion: Use case returns
StandingsDTO(serializable) - HTTP Response: Controller sends JSON to client
- React Update: TanStack Query caches data, component re-renders with standings
Sequence Diagram:
User → Web Client → Web API → Use Case → Repository → PostgreSQL
↓
Domain Entity (League)
↓
Compute Standings
↓
Return DTO
↓
User ← Web Client ← Web API ← Use Case
9. Cross-Cutting Concerns
Logging Strategy
Logger (Winston wrapper)
export class Logger {
log(level: LogLevel, message: string, context?: Record<string, any>): void {
winston.log(level, message, { ...context, timestamp: new Date() });
}
error(message: string, error: Error, context?: Record<string, any>): void {
this.log('error', message, { ...context, stack: error.stack });
}
}
Usage Across Layers:
- Domain: Logs business rule violations (e.g., "League season overlap detected")
- Application: Logs use case execution (e.g., "CreateLeagueUseCase executed for user 123")
- Infrastructure: Logs external service calls (e.g., "iRacing API request failed, retrying...")
Error Handling Patterns
Domain Errors (Business Rule Violations)
export class LeagueSeasonOverlapError extends DomainError {
constructor(leagueId: string) {
super(`League ${leagueId} has overlapping seasons`);
}
}
Application Errors (Use Case Failures)
export class LeagueNotFoundError extends ApplicationError {
constructor(leagueId: string) {
super(`League ${leagueId} not found`, 404);
}
}
Infrastructure Errors (External Service Failures)
export class IRacingAPIError extends InfrastructureError {
constructor(message: string, statusCode: number) {
super(`iRacing API error: ${message}`, statusCode);
}
}
Error Propagation:
- Errors bubble up from domain → application → infrastructure/presentation
- Presentation layer translates errors into HTTP status codes or UI messages
Validation
Two-Level Validation:
- Domain Validation (Entity Invariants)
class League {
constructor(name: string) {
if (name.length < 3) {
throw new InvalidLeagueNameError(name); // Domain error
}
}
}
- DTO Validation (Input Sanitization)
const CreateLeagueDTOSchema = z.object({
name: z.string().min(3).max(100),
ownerId: z.string().uuid()
});
export class CreateLeagueDTO {
static fromRequest(body: unknown): CreateLeagueDTO {
return CreateLeagueDTOSchema.parse(body); // Throws if invalid
}
}
10. Monorepo Inter-Package Dependencies
Dependency Rules (DAG Enforcement)
Directed Acyclic Graph:
apps/web-api ────┐
apps/web-client ─┤
apps/companion ──┘
↓
infrastructure ──┐
↓ │
application ─────┘
↓
domain (no dependencies)
↑
shared (utilities only, no domain logic)
Import Restrictions (ESLint Plugin):
// .eslintrc.js
module.exports = {
rules: {
'import/no-restricted-paths': [
'error',
{
zones: [
{ target: './src/packages/domain', from: './src/packages/application' },
{ target: './src/packages/domain', from: './src/infrastructure' },
{ target: './src/packages/application', from: './src/infrastructure' },
{ target: './src/packages/application', from: './src/apps' }
]
}
]
}
};
Build Order Implications
Compilation Order:
domain(no dependencies)application(depends ondomain)infrastructure(depends onapplication,domain)apps/*(depends on all above)
Monorepo Tool Integration:
- npm workspaces: Automatically hoists shared dependencies
- Turborepo/Nx: Caches builds, parallelizes independent packages
11. Extension Points
Adding New Scoring Rules (Plugin Architecture)
Current Approach:
class ScoringRule {
calculatePoints(position: number): number {
return this.positionPoints[position] || 0;
}
}
Future Plugin System:
interface IScoringPlugin {
name: string;
calculatePoints(position: number, context: RaceContext): number;
}
// Plugin example: Dynamic scoring based on field size
class DynamicScoringPlugin implements IScoringPlugin {
calculatePoints(position: number, context: RaceContext): number {
const fieldSize = context.numberOfDrivers;
return Math.max(0, fieldSize - position + 1);
}
}
// Register plugin
scoringRegistry.register(new DynamicScoringPlugin());
Integrating Third-Party APIs (New Infrastructure Adapters)
Example: Discord Notifications
// 1. Define port interface (application layer)
interface IDiscordNotificationService {
sendMessage(webhookUrl: string, message: string): Promise<void>;
}
// 2. Implement adapter (infrastructure layer)
class DiscordWebhookAdapter implements IDiscordNotificationService {
async sendMessage(webhookUrl: string, message: string): Promise<void> {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: message })
});
}
}
// 3. Wire in DI container
const discordService = new DiscordWebhookAdapter();
const notifyOnRaceComplete = new NotifyOnRaceCompleteUseCase(discordService);
Extending Monetization Features
Example: Stripe Payment Integration
// 1. Define port (application layer)
interface IPaymentProcessor {
createPaymentIntent(amount: number, currency: string): Promise<string>;
confirmPayment(paymentIntentId: string): Promise<boolean>;
}
// 2. Implement adapter (infrastructure layer)
class StripeAdapter implements IPaymentProcessor {
async createPaymentIntent(amount: number, currency: string): Promise<string> {
const intent = await this.stripe.paymentIntents.create({ amount, currency });
return intent.id;
}
}
// 3. Use in use case (application layer)
class ChargeSeasonEntryFeeUseCase {
constructor(private readonly paymentProcessor: IPaymentProcessor) {}
async execute(seasonId: string, driverId: string): Promise<void> {
const season = await this.seasonRepo.findById(seasonId);
const paymentIntentId = await this.paymentProcessor.createPaymentIntent(
season.entryFee,
'usd'
);
// Store payment intent ID, await webhook confirmation
}
}
How Clean Architecture Supports Evolution
- New Features: Add use cases without modifying existing code
- Technology Changes: Swap infrastructure adapters (e.g., Postgres → MongoDB) without touching domain/application layers
- Multi-Tenancy: Introduce tenant isolation at infrastructure layer (row-level security)
- API Versioning: Create new controllers/DTOs while preserving old endpoints
12. Testing Strategy Integration
For detailed testing strategy, see TESTS.md.
How Architecture Enables Testing
Unit Tests (Domain & Application)
// No mocks needed for pure domain logic
test('League prevents overlapping seasons', () => {
const league = new League({ name: 'Test League' });
league.addSeason({ startDate: '2025-01-01', endDate: '2025-06-01' });
expect(() => {
league.addSeason({ startDate: '2025-05-01', endDate: '2025-12-01' });
}).toThrow(LeagueSeasonOverlapError);
});
// Mock ports for use case tests
test('CreateLeagueUseCase saves league', async () => {
const mockRepo = mock<ILeagueRepository>();
const useCase = new CreateLeagueUseCase(mockRepo);
await useCase.execute({ name: 'Test League', ownerId: '123' });
expect(mockRepo.save).toHaveBeenCalledWith(expect.any(League));
});
Integration Tests (Infrastructure)
// Test with real PostgreSQL (Docker Testcontainers)
test('PostgresLeagueRepository persists league', async () => {
const db = await setupTestDatabase();
const repo = new PostgresLeagueRepository(db);
const league = new League({ name: 'Integration Test League' });
await repo.save(league);
const retrieved = await repo.findById(league.id);
expect(retrieved?.name).toBe('Integration Test League');
});
E2E Tests (Full Stack)
// Test with Playwright (browser automation)
test('User creates league via Web Client', async ({ page }) => {
await page.goto('/leagues/new');
await page.fill('input[name="name"]', 'E2E Test League');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/leagues\/[a-z0-9-]+/);
await expect(page.locator('h1')).toContainText('E2E Test League');
});
Cross-References
- Business Context: See CONCEPT.md for problem statement and user journeys
- Technology Stack: See TECH.md for framework choices and tooling
- Testing Details: See TESTS.md for comprehensive testing strategy (coming soon)
This architecture documentation will evolve as GridPilot matures. All changes must maintain Clean Architecture principles and the dependency rule.
Last Updated: 2025-11-23