512 lines
14 KiB
Markdown
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 |