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)
- Install dependencies
- Configure TypeScript
- Create core infrastructure
- Create API module
Phase 2: Domain Modules (Week 2-3)
- Create module for each domain (2-3 per day)
- Register all services
- Test each module
Phase 3: React-Query Hooks Migration (Week 4)
- Update all hooks to use
useInject()instead ofuseServices() - 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.
- Test each hook after migration
Phase 4: Integration & Cleanup (Week 5)
- Update root layout with ContainerProvider
- Remove old ServiceProvider
- Remove ServiceFactory
- 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
apps/website/lib/di/index.tsapps/website/lib/di/container.tsapps/website/lib/di/tokens.tsapps/website/lib/di/providers/ContainerProvider.tsxapps/website/lib/di/hooks/useInject.tsapps/website/lib/di/modules/core.module.tsapps/website/lib/di/modules/api.module.tsapps/website/lib/di/modules/league.module.tsapps/website/lib/di/modules/driver.module.tsapps/website/lib/di/modules/team.module.tsapps/website/lib/di/modules/race.module.tsapps/website/lib/di/modules/media.module.tsapps/website/lib/di/modules/payment.module.tsapps/website/lib/di/modules/analytics.module.tsapps/website/lib/di/modules/auth.module.tsapps/website/lib/di/modules/dashboard.module.tsapps/website/lib/di/modules/policy.module.tsapps/website/lib/di/modules/protest.module.tsapps/website/lib/di/modules/penalty.module.tsapps/website/lib/di/modules/onboarding.module.tsapps/website/lib/di/modules/landing.module.ts
Next Steps
- Approve this plan
- Start Phase 1: Install dependencies and create core infrastructure
- Proceed module by module: No backward compatibility needed - clean migration
Total effort: 4-5 weeks for clean implementation