7.8 KiB
7.8 KiB
Dependency Construction Architecture
The Decision: Manual Construction (Not DI Container)
Why Not Dependency Injection in RSC?
Problem: Next.js RSC pages run on every request, but DI containers are singletons.
Risks with DI:
- Data Leakage: Singleton container could share auth tokens between users
- Request Context: Can't inject request-specific data (user ID, auth token)
- Scoping Complexity: Would need request-scoped containers
- Overhead: DI adds complexity without benefit for RSC
Example of the Problem:
// ❌ DON'T DO THIS - DI in RSC
export default async function DashboardPage() {
const container = ContainerManager.getInstance().getContainer();
const service = container.get<DashboardService>(DASHBOARD_SERVICE_TOKEN);
// Problem: What if DashboardService needs the user's auth token?
// The singleton container doesn't know which user this request is for!
}
The Solution: Manual Construction
Pattern:
RSC Page
↓
PageQuery (constructs Service)
↓
Service (constructs API Client, Logger, ErrorReporter)
↓
API Client (makes HTTP calls)
Example:
// ✅ CORRECT: Manual construction in RSC
export default async function DashboardPage() {
const query = new DashboardPageQuery();
const result = await query.execute();
// ...
}
// In DashboardPageQuery
export class DashboardPageQuery {
async execute() {
const service = new DashboardService(); // Manual construction
return await service.getDashboardOverview();
}
}
// In DashboardService
export class DashboardService {
private apiClient: DashboardApiClient;
constructor() {
// Service creates its own dependencies
this.apiClient = new DashboardApiClient(
process.env.NEXT_PUBLIC_API_URL || '',
new ConsoleErrorReporter(),
new ConsoleLogger()
);
}
async getDashboardOverview() {
return await this.apiClient.getOverview();
}
}
Benefits of Manual Construction
- Explicit: Dependencies are clear and visible
- Simple: No magic, no container, no configuration
- Safe: No singleton issues, no data leakage
- Testable: Easy to pass mocks in constructor
- Flexible: Can change dependencies without affecting callers
What About the Existing lib/di/?
The project has an Inversify DI system, but it's designed for:
Client Components:
// ✅ OK in client components
export function UserDashboard() {
const service = useInject(DASHBOARD_SERVICE_TOKEN);
// ...
}
Testing:
// ✅ OK in tests
const mockApiClient = new MockDashboardApiClient();
const service = new DashboardService(mockApiClient);
RSC (PageQueries/Mutations):
// ❌ DON'T use DI container in RSC
// ✅ DO use manual construction
ESLint Enforcement
The services-no-instantiation rule is removed because it was wrong.
Correct Rules:
- ✅
clean-error-handling: PageQueries must use Services, not API Clients - ✅
services-implement-contract: Services must return Result types - ✅
lib-no-next-imports: No Next.js imports in lib/ directory
What the Rules Allow:
// In PageQuery - ALLOWED
const service = new DashboardService();
// In Service - ALLOWED
this.apiClient = new DashboardApiClient(...);
this.logger = new ConsoleLogger();
// In PageQuery - FORBIDDEN
const apiClient = new DashboardApiClient(...); // Use Service instead!
Complete Example: Read Flow
// apps/website/app/dashboard/page.tsx
export default async function DashboardPage() {
const query = new DashboardPageQuery();
const result = await query.execute();
if (result.isErr()) {
// Handle presentation errors
return <ErrorDashboard error={result.error} />;
}
return <DashboardTemplate viewData={result.value} />;
}
// apps/website/lib/page-queries/page-queries/DashboardPageQuery.ts
export class DashboardPageQuery implements PageQuery<DashboardViewData, void> {
async execute(): Promise<Result<DashboardViewData, DashboardPageError>> {
const service = new DashboardService();
const result = await service.getDashboardOverview();
if (result.isErr()) {
// Map domain error to presentation error
return Result.err(mapToPresentationError(result.error));
}
const viewData = DashboardViewDataBuilder.build(result.value);
return Result.ok(viewData);
}
}
// apps/website/lib/services/analytics/DashboardService.ts
export class DashboardService {
private apiClient: DashboardApiClient;
constructor() {
this.apiClient = new DashboardApiClient(
process.env.NEXT_PUBLIC_API_URL || '',
new ConsoleErrorReporter(),
new ConsoleLogger()
);
}
async getDashboardOverview(): Promise<Result<DashboardOverviewDTO, DomainError>> {
try {
const data = await this.apiClient.getOverview();
return Result.ok(data);
} catch (error) {
// Convert HTTP errors to domain errors
if (error instanceof HttpNotFoundError) {
return Result.err(new NotFoundError('Dashboard not found'));
}
return Result.err(new UnknownError('Failed to fetch dashboard'));
}
}
}
// apps/website/lib/api/dashboard/DashboardApiClient.ts
export class DashboardApiClient {
constructor(
private baseUrl: string,
private errorReporter: ErrorReporter,
private logger: Logger
) {}
async getOverview(): Promise<DashboardOverviewDTO> {
const response = await fetch(`${this.baseUrl}/dashboard/overview`);
if (!response.ok) {
if (response.status === 404) {
throw new HttpNotFoundError('Dashboard not found');
}
throw new HttpError(`HTTP ${response.status}`);
}
return response.json();
}
}
Complete Example: Write Flow
// apps/website/app/actions/userActions.ts
'use server';
export async function updateUserStatus(input: UpdateUserStatusInput) {
const mutation = new UpdateUserStatusMutation();
const result = await mutation.execute(input);
if (result.isErr()) {
return { success: false, error: result.error };
}
revalidatePath('/admin/users');
return { success: true };
}
// apps/website/lib/mutations/UpdateUserStatusMutation.ts
export class UpdateUserStatusMutation implements Mutation<UpdateUserStatusInput, void> {
async execute(input: UpdateUserStatusInput): Promise<Result<void, MutationError>> {
const service = new UserService();
const result = await service.updateUserStatus(input.userId, input.status);
if (result.isErr()) {
return Result.err(mapToMutationError(result.error));
}
return Result.ok(undefined);
}
}
// apps/website/lib/services/user/UserService.ts
export class UserService {
private apiClient: UserApiClient;
constructor() {
this.apiClient = new UserApiClient(
process.env.NEXT_PUBLIC_API_URL || '',
new ConsoleErrorReporter(),
new ConsoleLogger()
);
}
async updateUserStatus(userId: string, status: string): Promise<Result<void, DomainError>> {
try {
await this.apiClient.updateUserStatus(userId, status);
return Result.ok(undefined);
} catch (error) {
if (error instanceof HttpForbiddenError) {
return Result.err(new ForbiddenError('Insufficient permissions'));
}
return Result.err(new UnknownError('Failed to update user'));
}
}
}
Summary
| Aspect | RSC (PageQueries/Mutations) | Client Components |
|---|---|---|
| Construction | Manual (new Service()) |
Manual or DI hooks |
| DI Container | ❌ Never use | ✅ Can use |
| Dependencies | Service creates its own | Injected or manual |
| Testability | Pass mocks to constructor | Use DI mocks |
| Complexity | Low | Medium |
Golden Rule: In RSC, always use manual construction. It's simpler, safer, and more explicit.