Files
gridpilot.gg/plans/seeds-clean-arch.md
2025-12-30 12:25:45 +01:00

19 KiB

Clean Architecture Violations & Refactoring Plan

Executive Summary

Recent changes introduced severe violations of Clean Architecture principles by creating singleton stores and in-memory services that bypass proper dependency injection and repository patterns. This document outlines all violations and provides a comprehensive refactoring strategy.


1. Violations Identified

1.1 Critical Violations

Singleton Pattern in Adapters Layer

  • File: adapters/racing/services/DriverStatsStore.ts
  • Violation: Global singleton with getInstance() static method
  • Impact: Bypasses dependency injection, creates hidden global state
  • Lines: 7-18

In-Memory Services Using Singletons

  • File: adapters/racing/services/InMemoryRankingService.ts
  • Violation: Direct singleton access via DriverStatsStore.getInstance() (line 14)
  • Impact: Service depends on global state, not injectable dependencies
  • Lines: 14

In-Memory Driver Stats Service

  • File: adapters/racing/services/InMemoryDriverStatsService.ts
  • Violation: Uses singleton store instead of repository pattern
  • Impact: Business logic depends on infrastructure implementation
  • Lines: 10

Team Stats Store

  • File: adapters/racing/services/TeamStatsStore.ts
  • Violation: Same singleton pattern as DriverStatsStore
  • Impact: Global state management in adapters layer

1.2 Architecture Violations

Domain Services in Adapters

  • Location: adapters/racing/services/
  • Violation: Services should be in domain layer, not adapters
  • Impact: Mixes application logic with infrastructure

Hardcoded Data Sources

  • Issue: RankingService computes from singleton store, not real data
  • Impact: Rankings don't reflect actual race results/standings

2. Clean Architecture Principles Violated

2.1 Dependency Rule

Principle: Dependencies must point inward (domain → application → adapters → frameworks)

Violation:

  • InMemoryRankingService (adapters) → DriverStatsStore (singleton global)
  • This creates a dependency on global state, not domain abstractions

2.2 Dependency Injection

Principle: All dependencies must be injected, never fetched

Violation:

// ❌ WRONG
const statsStore = DriverStatsStore.getInstance();

// ✅ CORRECT
constructor(private readonly statsRepository: IDriverStatsRepository) {}

2.3 Repository Pattern

Principle: Persistence concerns belong in repositories, not services

Violation:

  • DriverStatsStore acts as a repository but is a singleton
  • Services directly access store instead of using repository interfaces

2.4 Domain Service Purity

Principle: Domain services contain business logic, no persistence

Violation:

  • InMemoryRankingService is in adapters, not domain
  • It contains persistence logic (reading from store)

3. Proper Architecture Specification

3.1 Correct Layer Structure

core/racing/
├── domain/
│   ├── services/
│   │   ├── IRankingService.ts          # Domain interface
│   │   └── IDriverStatsService.ts      # Domain interface
│   └── repositories/
│       ├── IResultRepository.ts        # Persistence port
│       ├── IStandingRepository.ts      # Persistence port
│       └── IDriverRepository.ts        # Persistence port

adapters/racing/
├── persistence/
│   ├── inmemory/
│   │   ├── InMemoryResultRepository.ts
│   │   ├── InMemoryStandingRepository.ts
│   │   └── InMemoryDriverRepository.ts
│   └── typeorm/
│       ├── TypeOrmResultRepository.ts
│       ├── TypeOrmStandingRepository.ts
│       └── TypeOrmDriverRepository.ts

apps/api/racing/
├── controllers/
├── services/
└── presenters/

3.2 Domain Services (Pure Business Logic)

Location: core/racing/domain/services/

Characteristics:

  • No persistence
  • No singletons
  • Pure business logic
  • Injected dependencies via interfaces

Example:

// core/racing/domain/services/RankingService.ts
export class RankingService implements IRankingService {
  constructor(
    private readonly resultRepository: IResultRepository,
    private readonly standingRepository: IStandingRepository,
    private readonly driverRepository: IDriverRepository,
    private readonly logger: Logger
  ) {}

  async getAllDriverRankings(): Promise<DriverRanking[]> {
    // Query real data from repositories
    const standings = await this.standingRepository.findAll();
    const drivers = await this.driverRepository.findAll();
    
    // Compute rankings from actual data
    return this.computeRankings(standings, drivers);
  }
}

3.3 Repository Pattern

Location: adapters/racing/persistence/

Characteristics:

  • Implement domain repository interfaces
  • Handle persistence details
  • No singletons
  • Injected via constructor

Example:

// adapters/racing/persistence/inmemory/InMemoryStandingRepository.ts
export class InMemoryStandingRepository implements IStandingRepository {
  private standings = new Map<string, Standing>();

  async findByLeagueId(leagueId: string): Promise<Standing[]> {
    return Array.from(this.standings.values())
      .filter(s => s.leagueId === leagueId)
      .sort((a, b) => a.position - b.position);
  }

  async save(standing: Standing): Promise<Standing> {
    const key = `${standing.leagueId}-${standing.driverId}`;
    this.standings.set(key, standing);
    return standing;
  }
}

4. Refactoring Strategy

4.1 Remove Violating Files

DELETE:

  • adapters/racing/services/DriverStatsStore.ts
  • adapters/racing/services/TeamStatsStore.ts
  • adapters/racing/services/InMemoryRankingService.ts
  • adapters/racing/services/InMemoryDriverStatsService.ts

4.2 Create Proper Domain Services

CREATE: core/racing/domain/services/RankingService.ts

export class RankingService implements IRankingService {
  constructor(
    private readonly resultRepository: IResultRepository,
    private readonly standingRepository: IStandingRepository,
    private readonly driverRepository: IDriverRepository,
    private readonly logger: Logger
  ) {}

  async getAllDriverRankings(): Promise<DriverRanking[]> {
    this.logger.debug('[RankingService] Computing rankings from standings');
    
    const standings = await this.standingRepository.findAll();
    const drivers = await this.driverRepository.findAll();
    
    // Group standings by driver and compute stats
    const driverStats = new Map<string, { rating: number; wins: number; races: number }>();
    
    for (const standing of standings) {
      const existing = driverStats.get(standing.driverId) || { rating: 0, wins: 0, races: 0 };
      existing.races++;
      if (standing.position === 1) existing.wins++;
      existing.rating += this.calculateRating(standing.position);
      driverStats.set(standing.driverId, existing);
    }
    
    // Convert to rankings
    const rankings: DriverRanking[] = Array.from(driverStats.entries()).map(([driverId, stats]) => ({
      driverId,
      rating: Math.round(stats.rating / stats.races),
      wins: stats.wins,
      totalRaces: stats.races,
      overallRank: null
    }));
    
    // Sort by rating and assign ranks
    rankings.sort((a, b) => b.rating - a.rating);
    rankings.forEach((r, idx) => r.overallRank = idx + 1);
    
    return rankings;
  }

  private calculateRating(position: number): number {
    // iRacing-style rating calculation
    const base = 1000;
    const points = Math.max(0, 25 - position);
    return base + (points * 50);
  }
}

CREATE: core/racing/domain/services/DriverStatsService.ts

export class DriverStatsService implements IDriverStatsService {
  constructor(
    private readonly resultRepository: IResultRepository,
    private readonly standingRepository: IStandingRepository,
    private readonly logger: Logger
  ) {}

  async getDriverStats(driverId: string): Promise<DriverStats | null> {
    const results = await this.resultRepository.findByDriverId(driverId);
    const standings = await this.standingRepository.findAll();
    
    if (results.length === 0) return null;
    
    const wins = results.filter(r => r.position === 1).length;
    const podiums = results.filter(r => r.position <= 3).length;
    const totalRaces = results.length;
    
    // Calculate rating from standings
    const driverStanding = standings.find(s => s.driverId === driverId);
    const rating = driverStanding ? this.calculateRatingFromStanding(driverStanding) : 1000;
    
    // Find overall rank
    const sortedStandings = standings.sort((a, b) => b.points - a.points);
    const rankIndex = sortedStandings.findIndex(s => s.driverId === driverId);
    const overallRank = rankIndex >= 0 ? rankIndex + 1 : null;
    
    return {
      rating,
      wins,
      podiums,
      totalRaces,
      overallRank
    };
  }

  private calculateRatingFromStanding(standing: Standing): number {
    // Calculate based on position and points
    return Math.round(1000 + (standing.points * 10));
  }
}

4.3 Create Proper Repositories

CREATE: adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository.ts

export class InMemoryDriverStatsRepository implements IDriverStatsRepository {
  private stats = new Map<string, DriverStats>();

  async getDriverStats(driverId: string): Promise<DriverStats | null> {
    return this.stats.get(driverId) || null;
  }

  async saveDriverStats(driverId: string, stats: DriverStats): Promise<void> {
    this.stats.set(driverId, stats);
  }

  async getAllStats(): Promise<Map<string, DriverStats>> {
    return new Map(this.stats);
  }
}

4.4 Update Seed Data Strategy

Current Problem: Seeds populate singleton stores directly

New Strategy: Seed proper repositories, compute stats from results

UPDATE: adapters/bootstrap/SeedRacingData.ts

export class SeedRacingData {
  constructor(
    private readonly logger: Logger,
    private readonly seedDeps: RacingSeedDependencies,
    private readonly statsRepository: IDriverStatsRepository // NEW
  ) {}

  async execute(): Promise<void> {
    // ... existing seeding logic ...
    
    // After seeding results and standings, compute and store stats
    await this.computeAndStoreDriverStats();
    await this.computeAndStoreTeamStats();
  }

  private async computeAndStoreDriverStats(): Promise<void> {
    const drivers = await this.seedDeps.driverRepository.findAll();
    const standings = await this.seedDeps.standingRepository.findAll();
    
    for (const driver of drivers) {
      const driverStandings = standings.filter(s => s.driverId === driver.id);
      if (driverStandings.length === 0) continue;
      
      const stats = this.calculateDriverStats(driver, driverStandings);
      await this.statsRepository.saveDriverStats(driver.id, stats);
    }
    
    this.logger.info(`[Bootstrap] Computed stats for ${drivers.length} drivers`);
  }

  private calculateDriverStats(driver: Driver, standings: Standing[]): DriverStats {
    const wins = standings.filter(s => s.position === 1).length;
    const podiums = standings.filter(s => s.position <= 3).length;
    const totalRaces = standings.length;
    const avgPosition = standings.reduce((sum, s) => sum + s.position, 0) / totalRaces;
    
    // Calculate rating based on performance
    const baseRating = 1000;
    const performanceBonus = (wins * 100) + (podiums * 50) + Math.max(0, 200 - (avgPosition * 10));
    const rating = Math.round(baseRating + performanceBonus);
    
    // Find overall rank
    const allStandings = await this.seedDeps.standingRepository.findAll();
    const sorted = allStandings.sort((a, b) => b.points - a.points);
    const rankIndex = sorted.findIndex(s => s.driverId === driver.id);
    const overallRank = rankIndex >= 0 ? rankIndex + 1 : null;
    
    return {
      rating,
      safetyRating: 85, // Could be computed from penalties/incidents
      sportsmanshipRating: 4.5, // Could be computed from protests
      totalRaces,
      wins,
      podiums,
      dnfs: totalRaces - wins - podiums, // Approximate
      avgFinish: avgPosition,
      bestFinish: Math.min(...standings.map(s => s.position)),
      worstFinish: Math.max(...standings.map(s => s.position)),
      consistency: Math.round(100 - (avgPosition * 2)), // Simplified
      experienceLevel: this.determineExperienceLevel(totalRaces),
      overallRank
    };
  }
}

5. Frontend Data Strategy

5.1 Media Repository Pattern

Location: adapters/racing/persistence/media/

Purpose: Handle static assets (logos, images, categories)

Structure:

adapters/racing/persistence/media/
├── IMediaRepository.ts
├── InMemoryMediaRepository.ts
├── FileSystemMediaRepository.ts
└── S3MediaRepository.ts

Interface:

export interface IMediaRepository {
  getDriverAvatar(driverId: string): Promise<string | null>;
  getTeamLogo(teamId: string): Promise<string | null>;
  getTrackImage(trackId: string): Promise<string | null>;
  getCategoryIcon(categoryId: string): Promise<string | null>;
  getSponsorLogo(sponsorId: string): Promise<string | null>;
}

5.2 Data Enrichment Strategy

Problem: Frontend needs ratings, wins, categories, logos

Solution:

  1. Seed real data (results, standings, races)
  2. Compute stats from real data
  3. Store in repositories (not singletons)
  4. Serve via queries (CQRS pattern)

Flow:

Seed Results → Compute Standings → Calculate Stats → Store in Repository → Query for Frontend

5.3 Query Layer for Frontend

Location: core/racing/application/queries/

Examples:

// GetDriverProfileQuery.ts
export class GetDriverProfileQuery {
  constructor(
    private readonly driverStatsRepository: IDriverStatsRepository,
    private readonly mediaRepository: IMediaRepository,
    private readonly driverRepository: IDriverRepository
  ) {}

  async execute(driverId: string): Promise<DriverProfileViewModel> {
    const driver = await this.driverRepository.findById(driverId);
    const stats = await this.driverStatsRepository.getDriverStats(driverId);
    const avatar = await this.mediaRepository.getDriverAvatar(driverId);
    
    return {
      id: driverId,
      name: driver.name,
      avatar,
      rating: stats.rating,
      wins: stats.wins,
      podiums: stats.podiums,
      totalRaces: stats.totalRaces,
      rank: stats.overallRank,
      experienceLevel: stats.experienceLevel
    };
  }
}

6. Implementation Steps

Phase 1: Remove Violations (Immediate)

  1. Delete DriverStatsStore.ts
  2. Delete TeamStatsStore.ts
  3. Delete InMemoryRankingService.ts
  4. Delete InMemoryDriverStatsService.ts
  5. Remove imports from SeedRacingData.ts

Phase 2: Create Proper Infrastructure

  1. Create core/racing/domain/services/RankingService.ts
  2. Create core/racing/domain/services/DriverStatsService.ts
  3. Create adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository.ts
  4. Create adapters/racing/persistence/media/InMemoryMediaRepository.ts

Phase 3: Update Seed Logic

  1. Modify SeedRacingData.ts to compute stats from results
  2. Remove singleton store population
  3. Add stats repository injection
  4. Add media data seeding

Phase 4: Update Application Layer

  1. Update factories to inject proper services
  2. Update controllers to use domain services
  3. Update presenters to query repositories

Phase 5: Frontend Integration

  1. Create query use cases for frontend data
  2. Implement media repository for assets
  3. Update API endpoints to serve computed data

7. Testing Strategy

7.1 Unit Tests

// RankingService.test.ts
describe('RankingService', () => {
  it('computes rankings from real standings', async () => {
    const mockStandings = [/* real standings */];
    const mockResults = [/* real results */];
    
    const service = new RankingService(
      mockResultRepo,
      mockStandingRepo,
      mockDriverRepo,
      mockLogger
    );
    
    const rankings = await service.getAllDriverRankings();
    
    expect(rankings).toHaveLength(150);
    expect(rankings[0].overallRank).toBe(1);
    expect(rankings[0].rating).toBeGreaterThan(1000);
  });
});

7.2 Integration Tests

// SeedRacingData.integration.test.ts
describe('SeedRacingData', () => {
  it('seeds data and computes stats correctly', async () => {
    const seed = new SeedRacingData(logger, deps, statsRepo);
    await seed.execute();
    
    const stats = await statsRepo.getDriverStats(driverId);
    expect(stats.rating).toBeDefined();
    expect(stats.wins).toBeGreaterThan(0);
  });
});

8. Benefits of This Approach

8.1 Architecture Benefits

  • Clean Architecture Compliance: Proper layer separation
  • Dependency Injection: All dependencies injected
  • Testability: Easy to mock repositories
  • Maintainability: Clear separation of concerns

8.2 Functional Benefits

  • Real Data: Rankings computed from actual race results
  • Scalability: Works with any persistence (memory, Postgres, etc.)
  • Flexibility: Easy to add new data sources
  • Consistency: Single source of truth for stats

8.3 Development Benefits

  • No Hidden State: No singletons
  • Explicit Dependencies: Clear what each service needs
  • Framework Agnostic: Core doesn't depend on infrastructure
  • Future Proof: Easy to migrate to different storage

9. Migration Checklist

  • Remove all singleton stores
  • Remove all in-memory services from adapters/racing/services/
  • Create proper domain services in core/racing/domain/services/
  • Create proper repositories in adapters/racing/persistence/
  • Update SeedRacingData to compute stats from real data
  • Update all factories to use dependency injection
  • Update controllers to use domain services
  • Update presenters to use query patterns
  • Add media repository for frontend assets
  • Create query use cases for frontend data
  • Update tests to use proper patterns
  • Verify no singleton usage anywhere
  • Verify all services are pure domain services
  • Verify all persistence is in repositories

10. Summary

The current implementation violates Clean Architecture by:

  1. Using singletons for state management
  2. Placing services in adapters layer
  3. Hardcoding data sources instead of using repositories
  4. Mixing persistence logic with business logic

The solution requires:

  1. Removing all singleton stores and in-memory services
  2. Creating proper domain services that compute from real data
  3. Implementing repository pattern for all persistence
  4. Updating seed logic to compute stats from results/standings
  5. Adding media repository for frontend assets
  6. Using CQRS pattern for queries

This will result in a clean, maintainable, and scalable architecture that properly follows Clean Architecture principles.


Document Version: 1.0
Last Updated: 2025-12-29
Status: Planning Phase
Next Steps: Implementation