api client refactor
This commit is contained in:
332
plans/2025-12-17_website-services.md
Normal file
332
plans/2025-12-17_website-services.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# 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.
|
||||
|
||||
```typescript
|
||||
// ✅ 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
|
||||
|
||||
```typescript
|
||||
// ✅ 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
|
||||
|
||||
```typescript
|
||||
// ✅ 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
|
||||
|
||||
```typescript
|
||||
// ✅ 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
|
||||
|
||||
1. **One Presenter = One Transformation** - Each presenter has exactly one `present()` method
|
||||
2. **Service = Orchestrator** - Services coordinate API calls and presenter transformations
|
||||
3. **Multiple Presenters per Service** - Services inject all presenters they need
|
||||
4. **No Presenter Reuse Across Domains** - Each domain has its own presenters
|
||||
5. **ServiceFactory = Composition Root** - Single place to wire everything up
|
||||
6. **Stateless Presenters** - No instance state, pure transformations
|
||||
7. **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
|
||||
|
||||
1. ✅ Each presenter has exactly one `present()` method
|
||||
2. ✅ Services inject all presenters they need
|
||||
3. ✅ No multi-method presenters
|
||||
4. ✅ ServiceFactory wires everything correctly
|
||||
5. ✅ Zero direct imports from `@core`
|
||||
6. ✅ All tests pass
|
||||
Reference in New Issue
Block a user