Files
gridpilot.gg/plans/2025-12-17_website-services.md
2025-12-17 19:25:10 +01:00

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

  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