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

512 lines
14 KiB
Markdown

# 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
```typescript
// 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
```bash
npm install inversify reflect-metadata
npm install --save-dev @types/inversify
```
### 2. Configure TypeScript
```json
// 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`
```typescript
// 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`
```typescript
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`
```typescript
'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`
```typescript
'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`
```typescript
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`
```typescript
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**:
```typescript
// apps/website/hooks/useLeagueService.ts
export function useAllLeagues() {
const { leagueService } = useServices(); // ❌ Manual service retrieval
return useQuery({
queryKey: ['allLeagues'],
queryFn: () => leagueService.getAllLeagues(),
});
}
```
**After**:
```typescript
// 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
```typescript
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
```typescript
'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
```typescript
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
```typescript
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
```typescript
// 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
```typescript
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.ts``useInject(LEAGUE_SERVICE_TOKEN)`
- `useRaceService.ts``useInject(RACE_SERVICE_TOKEN)`
- `useDriverService.ts``useInject(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:
```typescript
// 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