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:
DriverStatsStoreacts 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:
InMemoryRankingServiceis 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.tsadapters/racing/services/TeamStatsStore.tsadapters/racing/services/InMemoryRankingService.tsadapters/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:
- Seed real data (results, standings, races)
- Compute stats from real data
- Store in repositories (not singletons)
- 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)
- ✅ Delete
DriverStatsStore.ts - ✅ Delete
TeamStatsStore.ts - ✅ Delete
InMemoryRankingService.ts - ✅ Delete
InMemoryDriverStatsService.ts - ✅ Remove imports from
SeedRacingData.ts
Phase 2: Create Proper Infrastructure
- ✅ Create
core/racing/domain/services/RankingService.ts - ✅ Create
core/racing/domain/services/DriverStatsService.ts - ✅ Create
adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository.ts - ✅ Create
adapters/racing/persistence/media/InMemoryMediaRepository.ts
Phase 3: Update Seed Logic
- ✅ Modify
SeedRacingData.tsto compute stats from results - ✅ Remove singleton store population
- ✅ Add stats repository injection
- ✅ Add media data seeding
Phase 4: Update Application Layer
- ✅ Update factories to inject proper services
- ✅ Update controllers to use domain services
- ✅ Update presenters to query repositories
Phase 5: Frontend Integration
- ✅ Create query use cases for frontend data
- ✅ Implement media repository for assets
- ✅ 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:
- Using singletons for state management
- Placing services in adapters layer
- Hardcoding data sources instead of using repositories
- Mixing persistence logic with business logic
The solution requires:
- Removing all singleton stores and in-memory services
- Creating proper domain services that compute from real data
- Implementing repository pattern for all persistence
- Updating seed logic to compute stats from results/standings
- Adding media repository for frontend assets
- 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