12 KiB
12 KiB
Website Architecture Refactoring Plan (CORRECTED)
Executive Summary
I've identified 75+ violations where apps/website directly imports from core domain, use cases, or repositories. This plan provides the CORRECT architecture with:
- One presenter = One transformation = One
present()method - Pure constructor injection
- Stateless presenters
- No singleton exports
- No redundant index.ts files
✅ Correct Presenter Architecture
The Rule: One Presenter = One Transformation
Each presenter has exactly one responsibility: transform one specific DTO into one specific ViewModel.
// ✅ CORRECT - Single present() method, one purpose
export class RaceDetailPresenter {
present(dto: RaceDetailDto): RaceDetailViewModel {
return new RaceDetailViewModel(dto);
}
}
// ✅ CORRECT - Another presenter for different transformation
export class RaceResultsDetailPresenter {
present(dto: RaceResultsDetailDto, currentUserId?: string): RaceResultsDetailViewModel {
return new RaceResultsDetailViewModel(dto, currentUserId);
}
}
// ✅ CORRECT - Yet another presenter for different transformation
export class RaceWithSOFPresenter {
present(dto: RaceWithSOFDto): RaceWithSOFViewModel {
return {
id: dto.raceId,
strengthOfField: dto.strengthOfField,
registeredCount: dto.registeredCount,
// ... pure transformation
};
}
}
🏗️ Correct Service Layer Design
Service with Multiple Focused Presenters
// ✅ apps/website/lib/services/races/RaceService.ts
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { RaceDetailPresenter } from '@/lib/presenters/RaceDetailPresenter';
import { RacesPagePresenter } from '@/lib/presenters/RacesPagePresenter';
import type { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
import type { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel';
/**
* Race Service
*
* Orchestrates race operations. Each operation uses its own focused presenter
* for a specific DTO-to-ViewModel transformation.
*/
export class RaceService {
constructor(
private readonly apiClient: RacesApiClient,
private readonly raceDetailPresenter: RaceDetailPresenter,
private readonly racesPagePresenter: RacesPagePresenter
) {}
async getRaceDetail(raceId: string, driverId: string): Promise<RaceDetailViewModel> {
const dto = await this.apiClient.getDetail(raceId, driverId);
return this.raceDetailPresenter.present(dto);
}
async getRacesPageData(): Promise<RacesPageViewModel> {
const dto = await this.apiClient.getPageData();
return this.racesPagePresenter.present(dto);
}
async completeRace(raceId: string): Promise<void> {
await this.apiClient.complete(raceId);
}
}
Race Results Service with Multiple Presenters
// ✅ apps/website/lib/services/races/RaceResultsService.ts
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { RaceResultsDetailPresenter } from '@/lib/presenters/RaceResultsDetailPresenter';
import { RaceWithSOFPresenter } from '@/lib/presenters/RaceWithSOFPresenter';
import { ImportRaceResultsSummaryPresenter } from '@/lib/presenters/ImportRaceResultsSummaryPresenter';
import type { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel';
import type { RaceWithSOFViewModel } from '@/lib/view-models/RaceWithSOFViewModel';
import type { ImportRaceResultsSummaryViewModel } from '@/lib/view-models/ImportRaceResultsSummaryViewModel';
export class RaceResultsService {
constructor(
private readonly apiClient: RacesApiClient,
private readonly resultsDetailPresenter: RaceResultsDetailPresenter,
private readonly sofPresenter: RaceWithSOFPresenter,
private readonly importSummaryPresenter: ImportRaceResultsSummaryPresenter
) {}
async getResultsDetail(raceId: string, currentUserId?: string): Promise<RaceResultsDetailViewModel> {
const dto = await this.apiClient.getResultsDetail(raceId);
return this.resultsDetailPresenter.present(dto, currentUserId);
}
async getWithSOF(raceId: string): Promise<RaceWithSOFViewModel> {
const dto = await this.apiClient.getWithSOF(raceId);
return this.sofPresenter.present(dto);
}
async importResults(raceId: string, input: any): Promise<ImportRaceResultsSummaryViewModel> {
const dto = await this.apiClient.importResults(raceId, input);
return this.importSummaryPresenter.present(dto);
}
}
📁 Correct Presenter Organization
apps/website/lib/presenters/
├── RaceDetailPresenter.ts # RaceDetailDto -> RaceDetailViewModel
├── RacesPagePresenter.ts # RacesPageDataDto -> RacesPageViewModel
├── RaceResultsDetailPresenter.ts # RaceResultsDetailDto -> RaceResultsDetailViewModel
├── RaceWithSOFPresenter.ts # RaceWithSOFDto -> RaceWithSOFViewModel
├── ImportRaceResultsSummaryPresenter.ts # ImportRaceResultsSummaryDto -> ImportRaceResultsSummaryViewModel
├── LeagueDetailPresenter.ts # LeagueDetailDto -> LeagueDetailViewModel
├── LeagueStandingsPresenter.ts # LeagueStandingsDto -> LeagueStandingsViewModel
├── LeagueStatsPresenter.ts # LeagueStatsDto -> LeagueStatsViewModel
├── DriverProfilePresenter.ts # DriverProfileDto -> DriverProfileViewModel
├── DriverLeaderboardPresenter.ts # DriverLeaderboardDto -> DriverLeaderboardViewModel
├── TeamDetailsPresenter.ts # TeamDetailsDto -> TeamDetailsViewModel
├── TeamMembersPresenter.ts # TeamMembersDto -> TeamMembersViewModel
└── ...
NO index.ts files
NO multi-method presenters
🏗️ Correct ServiceFactory
// ✅ apps/website/lib/services/ServiceFactory.ts
import { ApiClient } from '@/lib/api';
import { RaceService } from '@/lib/services/races/RaceService';
import { RaceResultsService } from '@/lib/services/races/RaceResultsService';
import { LeagueService } from '@/lib/services/leagues/LeagueService';
import { DriverService } from '@/lib/services/drivers/DriverService';
import { TeamService } from '@/lib/services/teams/TeamService';
// Race presenters
import { RaceDetailPresenter } from '@/lib/presenters/RaceDetailPresenter';
import { RacesPagePresenter } from '@/lib/presenters/RacesPagePresenter';
import { RaceResultsDetailPresenter } from '@/lib/presenters/RaceResultsDetailPresenter';
import { RaceWithSOFPresenter } from '@/lib/presenters/RaceWithSOFPresenter';
import { ImportRaceResultsSummaryPresenter } from '@/lib/presenters/ImportRaceResultsSummaryPresenter';
// League presenters
import { LeagueDetailPresenter } from '@/lib/presenters/LeagueDetailPresenter';
import { LeagueStandingsPresenter } from '@/lib/presenters/LeagueStandingsPresenter';
import { LeagueStatsPresenter } from '@/lib/presenters/LeagueStatsPresenter';
// Driver presenters
import { DriverProfilePresenter } from '@/lib/presenters/DriverProfilePresenter';
import { DriverLeaderboardPresenter } from '@/lib/presenters/DriverLeaderboardPresenter';
// Team presenters
import { TeamDetailsPresenter } from '@/lib/presenters/TeamDetailsPresenter';
import { TeamMembersPresenter } from '@/lib/presenters/TeamMembersPresenter';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
/**
* Service Factory - Composition Root
*
* Creates and wires up all services with their dependencies.
* Each service gets all the presenters it needs.
*/
export class ServiceFactory {
private static apiClient = new ApiClient(API_BASE_URL);
// Race presenters
private static raceDetailPresenter = new RaceDetailPresenter();
private static racesPagePresenter = new RacesPagePresenter();
private static raceResultsDetailPresenter = new RaceResultsDetailPresenter();
private static raceWithSOFPresenter = new RaceWithSOFPresenter();
private static importRaceResultsSummaryPresenter = new ImportRaceResultsSummaryPresenter();
// League presenters
private static leagueDetailPresenter = new LeagueDetailPresenter();
private static leagueStandingsPresenter = new LeagueStandingsPresenter();
private static leagueStatsPresenter = new LeagueStatsPresenter();
// Driver presenters
private static driverProfilePresenter = new DriverProfilePresenter();
private static driverLeaderboardPresenter = new DriverLeaderboardPresenter();
// Team presenters
private static teamDetailsPresenter = new TeamDetailsPresenter();
private static teamMembersPresenter = new TeamMembersPresenter();
static createRaceService(): RaceService {
return new RaceService(
this.apiClient.races,
this.raceDetailPresenter,
this.racesPagePresenter
);
}
static createRaceResultsService(): RaceResultsService {
return new RaceResultsService(
this.apiClient.races,
this.raceResultsDetailPresenter,
this.raceWithSOFPresenter,
this.importRaceResultsSummaryPresenter
);
}
static createLeagueService(): LeagueService {
return new LeagueService(
this.apiClient.leagues,
this.leagueDetailPresenter,
this.leagueStandingsPresenter,
this.leagueStatsPresenter
);
}
static createDriverService(): DriverService {
return new DriverService(
this.apiClient.drivers,
this.driverProfilePresenter,
this.driverLeaderboardPresenter
);
}
static createTeamService(): TeamService {
return new TeamService(
this.apiClient.teams,
this.teamDetailsPresenter,
this.teamMembersPresenter
);
}
}
✅ Complete Example: Race Domain
File Structure
apps/website/lib/
├── services/races/
│ ├── RaceService.ts
│ └── RaceResultsService.ts
├── presenters/
│ ├── RaceDetailPresenter.ts
│ ├── RacesPagePresenter.ts
│ ├── RaceResultsDetailPresenter.ts
│ ├── RaceWithSOFPresenter.ts
│ └── ImportRaceResultsSummaryPresenter.ts
├── view-models/
│ ├── RaceDetailViewModel.ts
│ ├── RacesPageViewModel.ts
│ ├── RaceResultsDetailViewModel.ts
│ ├── RaceWithSOFViewModel.ts
│ └── ImportRaceResultsSummaryViewModel.ts
└── dtos/
├── RaceDetailDto.ts
├── RacesPageDataDto.ts
├── RaceResultsDetailDto.ts
├── RaceWithSOFDto.ts
└── ImportRaceResultsSummaryDto.ts
🎯 Key Architectural Principles
- One Presenter = One Transformation - Each presenter has exactly one
present()method - Service = Orchestrator - Services coordinate API calls and presenter transformations
- Multiple Presenters per Service - Services inject all presenters they need
- No Presenter Reuse Across Domains - Each domain has its own presenters
- ServiceFactory = Composition Root - Single place to wire everything up
- Stateless Presenters - No instance state, pure transformations
- Constructor Injection - All dependencies explicit
📋 Migration Checklist
For Each Presenter:
- Verify exactly one
present()method - Verify stateless (no instance properties)
- Verify pure transformation (no side effects)
- Remove any functional wrapper exports
For Each Service:
- Identify all DTO-to-ViewModel transformations needed
- Inject all required presenters via constructor
- Each method calls appropriate presenter
- No presenter logic in service
For ServiceFactory:
- Create shared presenter instances
- Wire presenters to services via constructor
- One factory method per service
✅ Success Criteria
- ✅ Each presenter has exactly one
present()method - ✅ Services inject all presenters they need
- ✅ No multi-method presenters
- ✅ ServiceFactory wires everything correctly
- ✅ Zero direct imports from
@core - ✅ All tests pass