# 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` ```typescript import { ContainerManager } from '@/lib/di/container'; export interface FetchResult { data: T | null; errors: Record; 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( ServiceToken: string | symbol, method: TMethod, ...args: TService[TMethod] extends (...params: infer P) => Promise ? P : never ): Promise<(TService[TMethod] extends (...params: any[]) => Promise ? R : never) | null> { try { const container = ContainerManager.getInstance().getContainer(); const service = container.get(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( serviceFactory: () => Promise | TData ): Promise { 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>( queries: T ): Promise> { const results = {} as { [K in keyof T]: T[K] }; const errors = {} as Record; 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` ```typescript '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 { queryKey: string[]; queryFn: () => Promise; 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( config: PageDataConfig ) { return useQuery({ 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>( queries: { [K in keyof T]: PageDataConfig; } ) { 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( mutationFn: (variables: TVariables) => Promise, options?: Omit, 'mutationFn'> ) { return useMutation({ 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( 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` ```typescript 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 { /** 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 ; * } * ``` * * Usage in CSR: * ```typescript * export default function Page() { * const { data, isLoading, error } = usePageData(...); * return ( * * ); * } * ``` */ export function PageWrapper({ data, isLoading = false, error = null, retry, Template, loading, errorConfig, empty, children, className = '', }: PageWrapperProps) { // 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 ( ); } // Default to skeleton return (
{children}
); } // 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 (
{children}
); } // Default to full-screen return ( ); } // 3. Empty State if (!data || (Array.isArray(data) && data.length === 0)) { if (empty) { const Icon = empty.icon; const hasAction = empty.action && retry; return (
{children}
); } // If no empty config provided but data is empty, show nothing return (
{children}
); } // 4. Success State - Render Template with data return (