# 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**: ```typescript // ❌ 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**: ```typescript // 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 { // 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**: ```typescript // adapters/racing/persistence/inmemory/InMemoryStandingRepository.ts export class InMemoryStandingRepository implements IStandingRepository { private standings = new Map(); async findByLeagueId(leagueId: string): Promise { return Array.from(this.standings.values()) .filter(s => s.leagueId === leagueId) .sort((a, b) => a.position - b.position); } async save(standing: Standing): Promise { 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` ```typescript 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 { 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(); 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` ```typescript export class DriverStatsService implements IDriverStatsService { constructor( private readonly resultRepository: IResultRepository, private readonly standingRepository: IStandingRepository, private readonly logger: Logger ) {} async getDriverStats(driverId: string): Promise { 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` ```typescript export class InMemoryDriverStatsRepository implements IDriverStatsRepository { private stats = new Map(); async getDriverStats(driverId: string): Promise { return this.stats.get(driverId) || null; } async saveDriverStats(driverId: string, stats: DriverStats): Promise { this.stats.set(driverId, stats); } async getAllStats(): Promise> { 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` ```typescript export class SeedRacingData { constructor( private readonly logger: Logger, private readonly seedDeps: RacingSeedDependencies, private readonly statsRepository: IDriverStatsRepository // NEW ) {} async execute(): Promise { // ... existing seeding logic ... // After seeding results and standings, compute and store stats await this.computeAndStoreDriverStats(); await this.computeAndStoreTeamStats(); } private async computeAndStoreDriverStats(): Promise { 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**: ```typescript export interface IMediaRepository { getDriverAvatar(driverId: string): Promise; getTeamLogo(teamId: string): Promise; getTrackImage(trackId: string): Promise; getCategoryIcon(categoryId: string): Promise; getSponsorLogo(sponsorId: string): Promise; } ``` ### 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**: ```typescript // GetDriverProfileQuery.ts export class GetDriverProfileQuery { constructor( private readonly driverStatsRepository: IDriverStatsRepository, private readonly mediaRepository: IMediaRepository, private readonly driverRepository: IDriverRepository ) {} async execute(driverId: string): Promise { 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 ```typescript // 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 ```typescript // 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