1048 lines
35 KiB
Markdown
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* |