Files
gridpilot.gg/apps/website/CLEAN_ARCHITECTURE_PLAN.md
2026-01-07 12:40:52 +01:00

28 KiB

Clean Architecture Plan: Unified Data Fetching with SOLID OOP

Executive Summary

This plan eliminates file proliferation and establishes a unified, type-safe data fetching architecture that handles all real-world scenarios: SSR, CSR, complex state, mutations, and multi-service dependencies.

Core Principles

1. Single Responsibility

  • Data Fetching: PageDataFetcher (SSR) + usePageData (CSR)
  • State Management: PageWrapper handles loading/error/empty states
  • Business Logic: Service classes handle domain logic
  • UI Rendering: Templates handle presentation

2. Open/Closed Principle

  • Extend via composition, not modification
  • Add new service methods without changing fetchers
  • Support new patterns via strategy pattern

3. Dependency Inversion

  • High-level modules depend on abstractions
  • Use DI container for SSR
  • Use hooks for CSR

Architecture Components

1. Unified Data Fetcher (SSR) - UPDATED FOR REALITY

File: lib/page/PageDataFetcher.ts

import { ContainerManager } from '@/lib/di/container';

export interface FetchResult<T> {
  data: T | null;
  errors: Record<string, Error>;
  hasErrors: boolean;
}

export class PageDataFetcher {
  /**
   * Fetch data using DI container
   * Use for: Simple SSR pages with single service
   * WARNING: Container is singleton - avoid stateful services
   */
  static async fetch<TService, TMethod extends keyof TService>(
    ServiceToken: string | symbol,
    method: TMethod,
    ...args: TService[TMethod] extends (...params: infer P) => Promise<infer R> ? P : never
  ): Promise<(TService[TMethod] extends (...params: any[]) => Promise<infer R> ? R : never) | null> {
    try {
      const container = ContainerManager.getInstance().getContainer();
      const service = container.get<TService>(ServiceToken);
      const result = await (service[method] as Function)(...args);
      return result;
    } catch (error) {
      console.error(`Failed to fetch: ${String(ServiceToken)}.${String(method)}`, error);
      return null;
    }
  }

  /**
   * Fetch using manual service instantiation
   * Use for: Multiple dependencies, request-scoped services, or auth context
   * RECOMMENDED for SSR over fetch() with DI
   */
  static async fetchManual<TData>(
    serviceFactory: () => Promise<TData> | TData
  ): Promise<TData | null> {
    try {
      const result = await serviceFactory();
      return result;
    } catch (error) {
      console.error('Failed to fetch manual:', error);
      return null;
    }
  }

  /**
   * Fetch multiple datasets in parallel with error aggregation
   * Use for: Pages needing multiple service calls
   * UPDATED: Returns both data and errors for proper handling
   */
  static async fetchMultiple<T extends Record<string, any>>(
    queries: T
  ): Promise<FetchResult<{ [K in keyof T]: T[K] }>> {
    const results = {} as { [K in keyof T]: T[K] };
    const errors = {} as Record<string, Error>;
    
    const entries = await Promise.all(
      Object.entries(queries).map(async ([key, query]) => {
        try {
          const result = await query();
          return [key, { success: true, data: result }];
        } catch (error) {
          console.error(`Failed to fetch ${key}:`, error);
          return [key, { success: false, error: error instanceof Error ? error : new Error(String(error)) }];
        }
      })
    );

    entries.forEach(([key, result]) => {
      if (result.success) {
        results[key as keyof T] = result.data;
      } else {
        errors[key] = result.error;
      }
    });

    return {
      data: results,
      errors,
      hasErrors: Object.keys(errors).length > 0
    };
  }
}

2. Client-Side Data Hook - UPDATED FOR REALITY

File: lib/page/usePageData.ts

'use client';

import { useQuery, useQueries, UseQueryOptions, useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { ApiError } from '@/lib/api/base/ApiError';

export interface PageDataConfig<TData, TError = ApiError> {
  queryKey: string[];
  queryFn: () => Promise<TData>;
  enabled?: boolean;
  staleTime?: number;
  onError?: (error: TError) => void;
}

/**
 * Single query hook - STANDARDIZED PATTERN
 * Use for: Simple CSR pages
 *
 * @example
 * const { data, isLoading, error, refetch } = usePageData({
 *   queryKey: ['profile'],
 *   queryFn: () => driverService.getProfile(),
 * });
 */
export function usePageData<TData, TError = ApiError>(
  config: PageDataConfig<TData, TError>
) {
  return useQuery<TData, TError>({
    queryKey: config.queryKey,
    queryFn: config.queryFn,
    enabled: config.enabled ?? true,
    staleTime: config.staleTime ?? 1000 * 60 * 5,
    onError: config.onError,
  });
}

/**
 * Multiple queries hook - STANDARDIZED PATTERN
 * Use for: Complex CSR pages with multiple data sources
 *
 * @example
 * const { data, isLoading, error, refetch } = usePageDataMultiple({
 *   results: {
 *     queryKey: ['raceResults', raceId],
 *     queryFn: () => service.getResults(raceId),
 *   },
 *   sof: {
 *     queryKey: ['raceSOF', raceId],
 *     queryFn: () => service.getSOF(raceId),
 *   },
 * });
 */
export function usePageDataMultiple<T extends Record<string, any>>(
  queries: {
    [K in keyof T]: PageDataConfig<T[K]>;
  }
) {
  const queryResults = useQueries({
    queries: Object.entries(queries).map(([key, config]) => ({
      queryKey: config.queryKey,
      queryFn: config.queryFn,
      enabled: config.enabled ?? true,
      staleTime: config.staleTime ?? 1000 * 60 * 5,
      onError: config.onError,
    })),
  });

  // Combine results
  const combined = {} as { [K in keyof T]: T[K] | null };
  const keys = Object.keys(queries) as (keyof T)[];
  
  keys.forEach((key, index) => {
    combined[key] = queryResults[index].data ?? null;
  });

  const isLoading = queryResults.some(q => q.isLoading);
  const error = queryResults.find(q => q.error)?.error ?? null;

  return {
    data: combined,
    isLoading,
    error,
    refetch: () => queryResults.forEach(q => q.refetch()),
  };
}

/**
 * Mutation hook wrapper - STANDARDIZED PATTERN
 * Use for: All mutation operations
 *
 * @example
 * const mutation = usePageMutation(
 *   (variables) => service.mutateData(variables),
 *   { onSuccess: () => refetch() }
 * );
 */
export function usePageMutation<TData, TVariables, TError = ApiError>(
  mutationFn: (variables: TVariables) => Promise<TData>,
  options?: Omit<UseMutationOptions<TData, TError, TVariables>, 'mutationFn'>
) {
  return useMutation<TData, TError, TVariables>({
    mutationFn,
    ...options,
  });
}

/**
 * SSR Hydration Hook - NEW
 * Use for: Passing SSR data to CSR to avoid re-fetching
 *
 * @example
 * // In SSR page
 * const ssrData = await PageDataFetcher.fetch(...);
 *
 * // In client component
 * const { data } = useHydrateSSRData(ssrData, ['queryKey']);
 */
export function useHydrateSSRData<TData>(
  ssrData: TData | null,
  queryKey: string[]
): { data: TData | null; isHydrated: boolean } {
  const [isHydrated, setIsHydrated] = React.useState(false);
  
  React.useEffect(() => {
    if (ssrData !== null) {
      setIsHydrated(true);
    }
  }, [ssrData]);

  return {
    data: ssrData,
    isHydrated,
  };
}

3. Universal Page Wrapper - UPDATED FOR SSR/CSR COMPATIBILITY

File: components/shared/state/PageWrapper.tsx

import React from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { LoadingWrapper } from './LoadingWrapper';
import { ErrorDisplay } from './ErrorDisplay';
import { EmptyState } from './EmptyState';

export interface PageWrapperLoadingConfig {
  variant?: 'skeleton' | 'full-screen';
  message?: string;
}

export interface PageWrapperErrorConfig {
  variant?: 'full-screen' | 'card';
  card?: {
    title?: string;
    description?: string;
  };
}

export interface PageWrapperEmptyConfig {
  icon?: React.ElementType;
  title?: string;
  description?: string;
  action?: {
    label: string;
    onClick: () => void;
  };
}

export interface PageWrapperProps<TData> {
  /** Data to be rendered */
  data: TData | undefined;
  /** Loading state (default: false) */
  isLoading?: boolean;
  /** Error state (default: null) */
  error?: Error | null;
  /** Retry function for errors */
  retry?: () => void;
  /** Template component that receives the data */
  Template: React.ComponentType<{ data: TData }>;
  /** Loading configuration */
  loading?: PageWrapperLoadingConfig;
  /** Error configuration */
  errorConfig?: PageWrapperErrorConfig;
  /** Empty configuration */
  empty?: PageWrapperEmptyConfig;
  /** Children for flexible content rendering */
  children?: React.ReactNode;
  /** Additional CSS classes */
  className?: string;
}

/**
 * PageWrapper Component - SSR/CSR COMPATIBLE
 *
 * CRITICAL: This component is NOT marked 'use client' to work in SSR pages
 * For CSR pages, use the wrapper version below
 *
 * Usage in SSR:
 * ```typescript
 * export default async function Page() {
 *   const data = await PageDataFetcher.fetch(...);
 *   return <PageWrapper data={data} Template={MyTemplate} />;
 * }
 * ```
 *
 * Usage in CSR:
 * ```typescript
 * export default function Page() {
 *   const { data, isLoading, error } = usePageData(...);
 *   return (
 *     <PageWrapperCSR
 *       data={data}
 *       isLoading={isLoading}
 *       error={error}
 *       Template={MyTemplate}
 *     />
 *   );
 * }
 * ```
 */
export function PageWrapper<TData>({
  data,
  isLoading = false,
  error = null,
  retry,
  Template,
  loading,
  errorConfig,
  empty,
  children,
  className = '',
}: PageWrapperProps<TData>) {
  // Priority order: Loading > Error > Empty > Success

  // 1. Loading State
  if (isLoading) {
    const loadingVariant = loading?.variant || 'skeleton';
    const loadingMessage = loading?.message || 'Loading...';

    if (loadingVariant === 'full-screen') {
      return (
        <LoadingWrapper
          variant="full-screen"
          message={loadingMessage}
        />
      );
    }

    // Default to skeleton
    return (
      <div className={className}>
        <LoadingWrapper
          variant="skeleton"
          message={loadingMessage}
          skeletonCount={3}
        />
        {children}
      </div>
    );
  }

  // 2. Error State
  if (error) {
    const errorVariant = errorConfig?.variant || 'full-screen';

    if (errorVariant === 'card') {
      const cardTitle = errorConfig?.card?.title || 'Error';
      const cardDescription = errorConfig?.card?.description || 'Something went wrong';

      return (
        <div className={className}>
          <ErrorDisplay
            error={error as ApiError}
            onRetry={retry}
            variant="card"
            title={cardTitle}
            description={cardDescription}
          />
          {children}
        </div>
      );
    }

    // Default to full-screen
    return (
      <ErrorDisplay
        error={error as ApiError}
        onRetry={retry}
        variant="full-screen"
      />
    );
  }

  // 3. Empty State
  if (!data || (Array.isArray(data) && data.length === 0)) {
    if (empty) {
      const Icon = empty.icon;
      const hasAction = empty.action && retry;

      return (
        <div className={className}>
          <EmptyState
            icon={Icon || require('lucide-react').Inbox}
            title={empty.title || 'No data available'}
            description={empty.description}
            action={hasAction ? {
              label: empty.action!.label,
              onClick: empty.action!.onClick,
            } : undefined}
            variant="default"
          />
          {children}
        </div>
      );
    }

    // If no empty config provided but data is empty, show nothing
    return (
      <div className={className}>
        {children}
      </div>
    );
  }

  // 4. Success State - Render Template with data
  return (
    <div className={className}>
      <Template data={data} />
      {children}
    </div>
  );
}

/**
 * Stateful Page Wrapper - CLIENT SIDE ONLY
 * Adds loading/error state management for client-side fetching
 *
 * Usage:
 * ```typescript
 * 'use client';
 *
 * export default function ProfilePage() {
 *   const { data, isLoading, error, refetch } = usePageData(...);
 *
 *   return (
 *     <StatefulPageWrapper
 *       data={data}
 *       isLoading={isLoading}
 *       error={error}
 *       retry={refetch}
 *       Template={ProfileTemplate}
 *       loading={{ variant: 'skeleton', message: 'Loading profile...' }}
 *     />
 *   );
 * }
 * ```
 */
export function StatefulPageWrapper<TData>({
  data,
  isLoading = false,
  error = null,
  retry,
  Template,
  loading,
  errorConfig,
  empty,
  children,
  className = '',
}: PageWrapperProps<TData>) {
  // Same implementation but with 'use client' for CSR-specific features
  return (
    <PageWrapper
      data={data}
      isLoading={isLoading}
      error={error}
      retry={retry}
      Template={Template}
      loading={loading}
      errorConfig={errorConfig}
      empty={empty}
      children={children}
      className={className}
    />
  );
}

Page Patterns

Pattern 1: Simple SSR Page - UPDATED

Use Case: Dashboard, simple detail pages

// app/dashboard/page.tsx
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { DashboardTemplate } from '@/templates/DashboardTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { DASHBOARD_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { DashboardService } from '@/lib/services/dashboard/DashboardService';
import { notFound } from 'next/navigation';

export default async function DashboardPage() {
  // CRITICAL: PageWrapper is NOT marked 'use client' for SSR compatibility
  const data = await PageDataFetcher.fetch<DashboardService, 'getDashboardOverview'>(
    DASHBOARD_SERVICE_TOKEN,
    'getDashboardOverview'
  );
  
  if (!data) notFound();
  
  return <PageWrapper data={data} Template={DashboardTemplate} />;
}

⚠️ SSR Reality Check:

  • PageWrapper must NOT have 'use client' directive
  • All state management happens in the template (which can be client component)
  • No loading states during SSR (data is fetched before render)
  • Error handling via notFound() or try/catch

Pattern 2: SSR Page with Parameters

Use Case: Race detail, team detail, league detail

// app/races/[id]/page.tsx
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { RaceService } from '@/lib/services/races/RaceService';
import { notFound } from 'next/navigation';

interface Props {
  params: { id: string; };
}

export default async function RaceDetailPage({ params }: Props) {
  if (!params.id) notFound();

  const data = await PageDataFetcher.fetch<RaceService, 'getRaceDetail'>(
    RACE_SERVICE_TOKEN,
    'getRaceDetail',
    params.id,
    '' // currentDriverId (handled client-side)
  );
  
  if (!data) notFound();

  // Transform if needed
  const viewModel = transformRaceData(data);

  return (
    <PageWrapper
      data={viewModel}
      Template={RaceDetailTemplate}
      loading={{ variant: 'skeleton', message: 'Loading race details...' }}
      errorConfig={{ variant: 'full-screen' }}
      empty={{
        icon: require('lucide-react').Flag,
        title: 'Race not found',
        description: 'The race may have been cancelled or deleted',
        action: { label: 'Back to Races', onClick: () => {} }
      }}
    />
  );
}

Pattern 3: SSR Page with Multiple Dependencies

Use Case: Leagues page, complex dashboards

// app/leagues/page.tsx
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { LeagueService } from '@/lib/services/leagues/LeagueService';
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';

export default async function LeaguesPage() {
  const data = await PageDataFetcher.fetchManual(async () => {
    // Manual dependency creation
    const baseUrl = getWebsiteApiBaseUrl();
    const logger = new ConsoleLogger();
    const errorReporter = new EnhancedErrorReporter(logger, {
      showUserNotifications: true,
      logToConsole: true,
      reportToExternal: process.env.NODE_ENV === 'production',
    });

    const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
    const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);

    const service = new LeagueService(leaguesApiClient, driversApiClient);
    return await service.getAllLeagues();
  });

  if (!data) notFound();

  return <PageWrapper data={data} Template={LeaguesTemplate} />;
}

Pattern 4: CSR Page with Single Query

Use Case: Profile page, settings page

// app/profile/page.tsx
'use client';

import { StatefulPageWrapper } from '@/components/shared/state/PageWrapper';
import { ProfileTemplate } from '@/templates/ProfileTemplate';
import { usePageData } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { DriverService } from '@/lib/services/drivers/DriverService';

export default function ProfilePage() {
  const driverService = useInject(DRIVER_SERVICE_TOKEN);

  const { data, isLoading, error, refetch } = usePageData({
    queryKey: ['profile'],
    queryFn: () => driverService.getProfile(),
  });

  return (
    <StatefulPageWrapper
      data={data}
      isLoading={isLoading}
      error={error}
      retry={refetch}
      Template={ProfileTemplate}
      loading={{ variant: 'skeleton', message: 'Loading profile...' }}
    />
  );
}

⚠️ CSR Reality Check:

  • Uses StatefulPageWrapper (client component)
  • Gets loading/error states from usePageData hook
  • Can handle mutations and re-fetching
  • No SSR data available initially

Pattern 5: CSR Page with Multiple Queries

Use Case: Race results with SOF, memberships

// app/races/[id]/results/page.tsx
'use client';

import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
import { usePageDataMultiple } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
import { RaceResultsDataTransformer } from '@/lib/transformers/RaceResultsDataTransformer';
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useState } from 'react';
import { notFound } from 'next/navigation';

interface Props {
  params: { id: string; };
}

export default function RaceResultsPage({ params }: Props) {
  const raceId = params.id;
  if (!raceId) notFound();

  const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
  const currentDriverId = useEffectiveDriverId() || '';

  // Multiple queries
  const { data: queries, isLoading, error, refetch } = usePageDataMultiple({
    results: {
      queryKey: ['raceResultsDetail', raceId, currentDriverId],
      queryFn: () => raceResultsService.getResultsDetail(raceId, currentDriverId),
    },
    sof: {
      queryKey: ['raceWithSOF', raceId],
      queryFn: () => raceResultsService.getWithSOF(raceId),
    },
  });

  // Additional data
  const leagueName = queries?.results?.league?.name || '';
  const { data: memberships } = useLeagueMemberships(leagueName, currentDriverId);

  // Transform
  const data = queries?.results && queries?.sof
    ? RaceResultsDataTransformer.transform(
        queries.results,
        queries.sof,
        currentDriverId,
        memberships
      )
    : undefined;

  // Local state
  const [importing, setImporting] = useState(false);
  const [showImportForm, setShowImportForm] = useState(false);

  const handleImport = async (results: any[]) => {
    setImporting(true);
    try {
      // Import logic
      await refetch();
    } finally {
      setImporting(false);
    }
  };

  return (
    <PageWrapper
      data={data}
      isLoading={isLoading}
      error={error}
      retry={refetch}
      Template={({ data }) => (
        <RaceResultsTemplate
          data={data}
          importing={importing}
          showImportForm={showImportForm}
          setShowImportForm={setShowImportForm}
          onImportResults={handleImport}
          // ... other props
        />
      )}
      loading={{ variant: 'skeleton', message: 'Loading results...' }}
      empty={{
        icon: require('lucide-react').Trophy,
        title: 'No results available',
        description: 'Race results will appear here once completed',
      }}
    />
  );
}

Pattern 6: CSR Page with Mutations

Use Case: Registration, form submissions

// app/races/[id]/page.tsx
'use client';

import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
import { usePageData, usePageMutation } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { RaceService } from '@/lib/services/races/RaceService';
import { notFound } from 'next/navigation';

interface Props {
  params: { id: string; };
}

export default function RaceDetailPage({ params }: Props) {
  const raceId = params.id;
  if (!raceId) notFound();

  const raceService = useInject(RACE_SERVICE_TOKEN);

  // Query
  const { data, isLoading, error, refetch } = usePageData({
    queryKey: ['raceDetail', raceId],
    queryFn: () => raceService.getRaceDetail(raceId, ''),
  });

  // Mutations
  const registerMutation = usePageMutation(
    ({ raceId, leagueId, driverId }: { raceId: string; leagueId: string; driverId: string }) =>
      raceService.registerForRace(raceId, leagueId, driverId),
    {
      onSuccess: () => refetch(),
    }
  );

  const withdrawMutation = usePageMutation(
    ({ raceId, driverId }: { raceId: string; driverId: string }) =>
      raceService.withdrawFromRace(raceId, driverId),
    {
      onSuccess: () => refetch(),
    }
  );

  const handleRegister = () => {
    if (!data?.league?.id) return;
    registerMutation.mutate({
      raceId,
      leagueId: data.league.id,
      driverId: '', // Get from auth
    });
  };

  return (
    <PageWrapper
      data={data}
      isLoading={isLoading}
      error={error}
      retry={refetch}
      Template={({ data }) => (
        <RaceDetailTemplate
          data={data}
          onRegister={handleRegister}
          onWithdraw={() => withdrawMutation.mutate({ raceId, driverId: '' })}
          mutationLoading={{
            register: registerMutation.isPending,
            withdraw: withdrawMutation.isPending,
          }}
        />
      )}
    />
  );
}

Pattern 7: Client-Only Pages with URL State

Use Case: Wizards, multi-step flows

// app/leagues/create/page.tsx
'use client';

import { useRouter, useSearchParams } from 'next/navigation';
import { CreateLeagueWizard } from '@/components/leagues/CreateLeagueWizard';
import { Section } from '@/components/ui/Section';
import { Container } from '@/components/ui/Container';

type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';

function normalizeStepName(raw: string | null): StepName {
  const validSteps = ['basics', 'visibility', 'structure', 'schedule', 'scoring', 'stewarding', 'review'];
  return (validSteps.includes(raw || '') ? raw : 'basics') as StepName;
}

export default function CreateLeaguePage() {
  const router = useRouter();
  const searchParams = useSearchParams();

  const currentStep = normalizeStepName(searchParams?.get('step') || null);

  const handleStepChange = (stepName: StepName) => {
    const params = new URLSearchParams(searchParams?.toString() || '');
    params.set('step', stepName);
    const query = params.toString();
    router.push(query ? `/leagues/create?${query}` : '/leagues/create');
  };

  return (
    <Section>
      <Container size="md">
        <CreateLeagueWizard stepName={currentStep} onStepChange={handleStepChange} />
      </Container>
    </Section>
  );
}

Service Layer Patterns

Service Class Structure

// lib/services/races/RaceService.ts
export class RaceService {
  constructor(
    private readonly apiClient: RaceApiClient,
    private readonly logger: Logger
  ) {}

  async getRaceDetail(raceId: string, currentDriverId: string): Promise<RaceDetailDTO> {
    this.logger.info('Fetching race detail', { raceId, currentDriverId });
    return await this.apiClient.getRaceDetail(raceId, currentDriverId);
  }

  async registerForRace(raceId: string, leagueId: string, driverId: string): Promise<void> {
    await this.apiClient.register(raceId, leagueId, driverId);
  }
}

Data Transformers

// lib/transformers/RaceResultsDataTransformer.ts
export class RaceResultsDataTransformer {
  static transform(
    results: RaceResultsDTO,
    sof: RaceSOFDTO,
    currentDriverId: string,
    memberships?: LeagueMembershipDTO[]
  ): RaceResultsViewModel {
    return {
      raceTrack: results.race.track,
      raceScheduledAt: results.race.scheduledAt,
      totalDrivers: results.results.length,
      leagueName: results.league.name,
      raceSOF: sof.sof,
      results: results.results.map(r => ({
        ...r,
        isCurrentUser: r.driverId === currentDriverId,
      })),
      penalties: results.penalties,
      pointsSystem: results.pointsSystem,
      fastestLapTime: results.fastestLapTime,
      memberships,
    };
  }
}

Migration Strategy

Phase 1: Core Infrastructure (Complete)

  • PageWrapper component
  • PageDataFetcher class
  • Basic page patterns

Phase 2: Client-Side Infrastructure

  • Create usePageData hooks
  • Update PageWrapper for CSR
  • Create mutation hooks

Phase 3: Page Migration

  • Migrate simple SSR pages (Dashboard, etc.)
  • Migrate SSR pages with params (Race Detail, etc.)
  • Migrate SSR pages with multiple deps (Leagues, etc.)
  • Migrate CSR pages (Profile, etc.)
  • Migrate complex CSR pages (Race Results, etc.)
  • Migrate mutation pages (Registration, etc.)
  • Migrate client-only pages (Wizards, etc.)

Phase 4: Cleanup

  • Delete obsolete wrapper files
  • Verify all patterns work
  • Update documentation

Benefits

SOLID Compliance

  • Single Responsibility: Each component has one clear purpose
  • Open/Closed: Easy to extend without modification
  • Liskov Substitution: All patterns use same PageWrapper interface
  • Interface Segregation: Small, focused interfaces
  • Dependency Inversion: High-level modules depend on abstractions

Clean & DRY

  • No file proliferation
  • Consistent patterns across all pages
  • Reusable components and hooks

Type-Safe

  • Full TypeScript support
  • Compile-time type checking
  • IntelliSense support

Maintainable

  • Clear separation of concerns
  • Easy to debug and test
  • Simple to add new pages

Comprehensive

  • Handles ALL real-world scenarios
  • SSR, CSR, mutations, complex state
  • Single source of truth for data fetching

This plan provides complete guidance for every scenario in your codebase while maintaining clean OOP/SOLID principles.