Files
gridpilot.gg/plans/DI_PLAN.md
2026-01-06 19:36:03 +01:00

14 KiB

Dependency Injection Plan for GridPilot Website

Overview

Implement proper dependency injection in your website using InversifyJS, following the same patterns as your NestJS API. This replaces the current manual ServiceFactory approach with a professional DI container system.

Why InversifyJS?

  • NestJS-like: Same decorators and patterns you already know
  • TypeScript-first: Excellent type safety
  • React-friendly: Works seamlessly with React Context
  • Production-ready: Battle-tested, well-maintained

Current Problem

// Current: Manual dependency management
// apps/website/lib/services/ServiceProvider.tsx
const services = useMemo(() => {
  const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
  return {
    leagueService: serviceFactory.createLeagueService(),
    driverService: serviceFactory.createDriverService(),
    // ... 25+ services manually created
  };
}, []);

Issues:

  • No formal DI container
  • Manual dependency wiring
  • Hard to test/mock
  • No lifecycle management
  • Inconsistent with API

Solution Architecture

1. Install Dependencies

npm install inversify reflect-metadata
npm install --save-dev @types/inversify

2. Configure TypeScript

// apps/website/tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

3. Core Structure

apps/website/lib/di/
├── index.ts              # Main exports
├── container.ts          # Container factory
├── tokens.ts             # Symbol tokens
├── providers/
│   └── ContainerProvider.tsx
├── hooks/
│   └── useInject.ts
└── modules/
    ├── core.module.ts
    ├── api.module.ts
    ├── league.module.ts
    └── ... (one per domain)

Implementation

Step 1: Token Registry

File: apps/website/lib/di/tokens.ts

// Centralized token registry
export const LOGGER_TOKEN = Symbol.for('Core.Logger');
export const ERROR_REPORTER_TOKEN = Symbol.for('Core.ErrorReporter');

export const LEAGUE_SERVICE_TOKEN = Symbol.for('Service.League');
export const DRIVER_SERVICE_TOKEN = Symbol.for('Service.Driver');
export const TEAM_SERVICE_TOKEN = Symbol.for('Service.Team');
export const RACE_SERVICE_TOKEN = Symbol.for('Service.Race');
// ... all service tokens

Step 2: Container Factory

File: apps/website/lib/di/container.ts

import { Container } from 'inversify';
import { CoreModule } from './modules/core.module';
import { ApiModule } from './modules/api.module';
import { LeagueModule } from './modules/league.module';
// ... other modules

export function createContainer(): Container {
  const container = new Container({ defaultScope: 'Singleton' });
  
  container.load(
    CoreModule,
    ApiModule,
    LeagueModule,
    // ... all modules
  );
  
  return container;
}

Step 3: React Integration

File: apps/website/lib/di/providers/ContainerProvider.tsx

'use client';

import { createContext, ReactNode, useContext, useMemo } from 'react';
import { Container } from 'inversify';
import { createContainer } from '../container';

const ContainerContext = createContext<Container | null>(null);

export function ContainerProvider({ children }: { children: ReactNode }) {
  const container = useMemo(() => createContainer(), []);
  
  return (
    <ContainerContext.Provider value={container}>
      {children}
    </ContainerContext.Provider>
  );
}

export function useContainer(): Container {
  const container = useContext(ContainerContext);
  if (!container) throw new Error('Missing ContainerProvider');
  return container;
}

Step 4: Injection Hook

File: apps/website/lib/di/hooks/useInject.ts

'use client';

import { useContext, useMemo } from 'react';
import { ContainerContext } from '../providers/ContainerProvider';

export function useInject<T>(token: symbol): T {
  const container = useContext(ContainerContext);
  if (!container) throw new Error('Missing ContainerProvider');
  
  return useMemo(() => container.get<T>(token), [container, token]);
}

Step 5: Module Examples

File: apps/website/lib/di/modules/league.module.ts

import { ContainerModule } from 'inversify';
import { LeagueService } from '../../services/leagues/LeagueService';
import {
  LEAGUE_SERVICE_TOKEN,
  LEAGUE_API_CLIENT_TOKEN,
  DRIVER_API_CLIENT_TOKEN,
  SPONSOR_API_CLIENT_TOKEN,
  RACE_API_CLIENT_TOKEN
} from '../tokens';

export const LeagueModule = new ContainerModule((bind) => {
  bind<LeagueService>(LEAGUE_SERVICE_TOKEN)
    .toDynamicValue((context) => {
      const leagueApiClient = context.container.get(LEAGUE_API_CLIENT_TOKEN);
      const driverApiClient = context.container.get(DRIVER_API_CLIENT_TOKEN);
      const sponsorApiClient = context.container.get(SPONSOR_API_CLIENT_TOKEN);
      const raceApiClient = context.container.get(RACE_API_CLIENT_TOKEN);
      
      return new LeagueService(
        leagueApiClient,
        driverApiClient,
        sponsorApiClient,
        raceApiClient
      );
    })
    .inSingletonScope();
});

File: apps/website/lib/di/modules/api.module.ts

import { ContainerModule } from 'inversify';
import { LeaguesApiClient } from '../../api/leagues/LeaguesApiClient';
import { DriversApiClient } from '../../api/drivers/DriversApiClient';
// ... other API clients
import {
  LEAGUE_API_CLIENT_TOKEN,
  DRIVER_API_CLIENT_TOKEN,
  LOGGER_TOKEN,
  ERROR_REPORTER_TOKEN,
  CONFIG_TOKEN
} from '../tokens';

export const ApiModule = new ContainerModule((bind) => {
  const createApiClient = (ClientClass: any, context: any) => {
    const baseUrl = context.container.get(CONFIG_TOKEN);
    const errorReporter = context.container.get(ERROR_REPORTER_TOKEN);
    const logger = context.container.get(LOGGER_TOKEN);
    return new ClientClass(baseUrl, errorReporter, logger);
  };

  bind(LEAGUE_API_CLIENT_TOKEN)
    .toDynamicValue(ctx => createApiClient(LeaguesApiClient, ctx))
    .inSingletonScope();

  bind(DRIVER_API_CLIENT_TOKEN)
    .toDynamicValue(ctx => createApiClient(DriversApiClient, ctx))
    .inSingletonScope();
    
  // ... other API clients
});

Usage Examples

In React-Query Hooks (Your Current Pattern)

Before:

// apps/website/hooks/useLeagueService.ts
export function useAllLeagues() {
  const { leagueService } = useServices(); // ❌ Manual service retrieval

  return useQuery({
    queryKey: ['allLeagues'],
    queryFn: () => leagueService.getAllLeagues(),
  });
}

After:

// apps/website/hooks/useLeagueService.ts
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';

export function useAllLeagues() {
  const leagueService = useInject(LEAGUE_SERVICE_TOKEN); // ✅ Clean DI

  return useQuery({
    queryKey: ['allLeagues'],
    queryFn: () => leagueService.getAllLeagues(),
  });
}

In React-Query Mutations

export function useCreateLeague() {
  const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (input: CreateLeagueInputDTO) => leagueService.createLeague(input),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
    },
  });
}

In Components

'use client';

import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';

export function CreateLeagueForm() {
  const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
  
  // Use leagueService directly
}

In Server Components

import { createContainer } from '@/lib/di/container';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';

export default async function LeaguePage({ params }) {
  const container = createContainer();
  const leagueService = container.get(LEAGUE_SERVICE_TOKEN);
  
  const league = await leagueService.getLeague(params.id);
  return <ClientComponent league={league} />;
}

In Tests

import { createTestContainer } from '@/lib/di/container';
import { ContainerProvider } from '@/lib/di/providers/ContainerProvider';

test('useAllLeagues works with DI', () => {
  const mockLeagueService = {
    getAllLeagues: jest.fn().mockResolvedValue([{ id: '1', name: 'Test League' }])
  };
  
  const overrides = new Map([
    [LEAGUE_SERVICE_TOKEN, mockLeagueService]
  ]);
  
  const container = createTestContainer(overrides);
  
  const { result } = renderHook(() => useAllLeagues(), {
    wrapper: ({ children }) => (
      <ContainerProvider container={container}>
        <QueryClientProvider client={new QueryClient()}>
          {children}
        </QueryClientProvider>
      </ContainerProvider>
    )
  });
  
  // Test works exactly the same
});

Complete React-Query Hook Migration Example

// apps/website/hooks/useLeagueService.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';

export function useAllLeagues() {
  const leagueService = useInject(LEAGUE_SERVICE_TOKEN);

  return useQuery({
    queryKey: ['allLeagues'],
    queryFn: () => leagueService.getAllLeagues(),
  });
}

export function useLeagueStandings(leagueId: string, currentUserId: string) {
  const leagueService = useInject(LEAGUE_SERVICE_TOKEN);

  return useQuery({
    queryKey: ['leagueStandings', leagueId, currentUserId],
    queryFn: () => leagueService.getLeagueStandings(leagueId, currentUserId),
    enabled: !!leagueId && !!currentUserId,
  });
}

export function useCreateLeague() {
  const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (input: CreateLeagueInputDTO) => leagueService.createLeague(input),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
    },
  });
}

export function useRemoveLeagueMember() {
  const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ leagueId, performerDriverId, targetDriverId }: {
      leagueId: string;
      performerDriverId: string;
      targetDriverId: string;
    }) => leagueService.removeMember(leagueId, performerDriverId, targetDriverId),
    onSuccess: (data, variables) => {
      queryClient.invalidateQueries({ queryKey: ['leagueMemberships', variables.leagueId] });
      queryClient.invalidateQueries({ queryKey: ['leagueStandings', variables.leagueId] });
    },
  });
}

Multiple Services in One Hook

import { useInjectMany } from '@/lib/di/hooks/useInjectMany';
import { LEAGUE_SERVICE_TOKEN, RACE_SERVICE_TOKEN } from '@/lib/di/tokens';

export function useLeagueAndRaceData(leagueId: string) {
  const [leagueService, raceService] = useInjectMany([
    LEAGUE_SERVICE_TOKEN,
    RACE_SERVICE_TOKEN
  ]);

  const leagueQuery = useQuery({
    queryKey: ['league', leagueId],
    queryFn: () => leagueService.getLeague(leagueId),
  });

  const racesQuery = useQuery({
    queryKey: ['races', leagueId],
    queryFn: () => raceService.getRacesByLeague(leagueId),
  });

  return { leagueQuery, racesQuery };
}

Migration Strategy

Phase 1: Setup (Week 1)

  1. Install dependencies
  2. Configure TypeScript
  3. Create core infrastructure
  4. Create API module

Phase 2: Domain Modules (Week 2-3)

  1. Create module for each domain (2-3 per day)
  2. Register all services
  3. Test each module

Phase 3: React-Query Hooks Migration (Week 4)

  1. Update all hooks to use useInject() instead of useServices()
  2. Migrate one domain at a time:
    • useLeagueService.tsuseInject(LEAGUE_SERVICE_TOKEN)
    • useRaceService.tsuseInject(RACE_SERVICE_TOKEN)
    • useDriverService.tsuseInject(DRIVER_SERVICE_TOKEN)
    • etc.
  3. Test each hook after migration

Phase 4: Integration & Cleanup (Week 5)

  1. Update root layout with ContainerProvider
  2. Remove old ServiceProvider
  3. Remove ServiceFactory
  4. Final verification

React-Query Hook Migration Pattern

Each hook file gets a simple 2-line change:

// Before
import { useServices } from '@/lib/services/ServiceProvider';
const { leagueService } = useServices();

// After
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);

The rest of the hook (queries, mutations, etc.) stays exactly the same.

Benefits

Testability: Easy mocking via container overrides
Maintainability: Clear dependency graphs
Type Safety: Compile-time validation
Consistency: Same patterns as API
Performance: Singleton scope by default

Files to Create

  1. apps/website/lib/di/index.ts
  2. apps/website/lib/di/container.ts
  3. apps/website/lib/di/tokens.ts
  4. apps/website/lib/di/providers/ContainerProvider.tsx
  5. apps/website/lib/di/hooks/useInject.ts
  6. apps/website/lib/di/modules/core.module.ts
  7. apps/website/lib/di/modules/api.module.ts
  8. apps/website/lib/di/modules/league.module.ts
  9. apps/website/lib/di/modules/driver.module.ts
  10. apps/website/lib/di/modules/team.module.ts
  11. apps/website/lib/di/modules/race.module.ts
  12. apps/website/lib/di/modules/media.module.ts
  13. apps/website/lib/di/modules/payment.module.ts
  14. apps/website/lib/di/modules/analytics.module.ts
  15. apps/website/lib/di/modules/auth.module.ts
  16. apps/website/lib/di/modules/dashboard.module.ts
  17. apps/website/lib/di/modules/policy.module.ts
  18. apps/website/lib/di/modules/protest.module.ts
  19. apps/website/lib/di/modules/penalty.module.ts
  20. apps/website/lib/di/modules/onboarding.module.ts
  21. apps/website/lib/di/modules/landing.module.ts

Next Steps

  1. Approve this plan
  2. Start Phase 1: Install dependencies and create core infrastructure
  3. Proceed module by module: No backward compatibility needed - clean migration

Total effort: 4-5 weeks for clean implementation