Files
gridpilot.gg/docs/architecture/website/DEPENDENCY_CONSTRUCTION.md
2026-01-13 02:42:58 +01:00

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:

  1. Data Leakage: Singleton container could share auth tokens between users
  2. Request Context: Can't inject request-specific data (user ID, auth token)
  3. Scoping Complexity: Would need request-scoped containers
  4. 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

  1. Explicit: Dependencies are clear and visible
  2. Simple: No magic, no container, no configuration
  3. Safe: No singleton issues, no data leakage
  4. Testable: Easy to pass mocks in constructor
  5. 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.