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:
PageWrapperhandles 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:
PageWrappermust 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
usePageDatahook - 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)
- ✅
PageWrappercomponent - ✅
PageDataFetcherclass - ✅ Basic page patterns
Phase 2: Client-Side Infrastructure
- Create
usePageDatahooks - Update
PageWrapperfor 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
PageWrapperinterface - 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.