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

8.4 KiB

Website Services Architecture

This document defines the role and responsibilities of services in the website layer (apps/website/lib/services/).

Overview

Website services are frontend orchestration services. They bridge the gap between server-side composition (PageQueries, Server Actions) and API infrastructure.

Purpose

Website services answer: "How does the website orchestrate API calls and handle infrastructure?"

Responsibilities

Services MAY:

  • Call API clients
  • Orchestrate multiple API calls
  • Handle infrastructure concerns (logging, error reporting, retries)
  • Transform API DTOs to Page DTOs (if orchestration is needed)
  • Cache responses (in-memory, request-scoped)
  • Handle recoverable errors

Services MUST NOT:

  • Contain business rules (that's for core use cases)
  • Create ViewModels (ViewModels are client-only)
  • Import from lib/view-models/ or templates/
  • Perform UI rendering logic
  • Store state across requests

Placement

apps/website/lib/services/

Pattern

Service Definition

Services create their own dependencies:

import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import type { UserDto } from '@/lib/api/admin/AdminApiClient';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';

export class AdminService {
  private apiClient: AdminApiClient;

  constructor() {
    // Service creates its own dependencies
    const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
    this.apiClient = new AdminApiClient(
      baseUrl,
      new ConsoleErrorReporter(),
      new ConsoleLogger()
    );
  }

  async updateUserStatus(userId: string, status: string): Promise<Result<UserDto, DomainError>> {
    try {
      const result = await this.apiClient.updateUserStatus(userId, status);
      return Result.ok(result);
    } catch (error) {
      // Convert HTTP errors to domain errors
      if (error instanceof HttpForbiddenError) {
        return Result.err(new ForbiddenError('Insufficient permissions'));
      }
      return Result.err(new UnknownError('Failed to update user'));
    }
  }
}

Usage in PageQueries (Reads)

// apps/website/lib/page-queries/AdminDashboardPageQuery.ts
import { AdminService } from '@/lib/services/admin/AdminService';
import { AdminDashboardViewDataBuilder } from '@/lib/builders/view-data/AdminDashboardViewDataBuilder';

export class AdminDashboardPageQuery implements PageQuery<AdminDashboardViewData, void> {
  async execute(): Promise<Result<AdminDashboardViewData, DashboardPageError>> {
    // Manual construction: Service creates its own dependencies
    const service = new AdminService();
    
    // Use service
    const result = await service.getDashboardStats();
    
    if (result.isErr()) {
      return Result.err(mapToPresentationError(result.error));
    }
    
    // Transform to ViewData using Builder
    const viewData = AdminDashboardViewDataBuilder.build(result.value);
    return Result.ok(viewData);
  }
}

Usage in Mutations (Writes)

// apps/website/lib/mutations/UpdateUserStatusMutation.ts
import { AdminService } from '@/lib/services/admin/AdminService';

export class UpdateUserStatusMutation implements Mutation<UpdateUserStatusInput, void> {
  async execute(input: UpdateUserStatusInput): Promise<Result<void, MutationError>> {
    // Manual construction: Service creates its own dependencies
    const service = new AdminService();
    
    const result = await service.updateUserStatus(input.userId, input.status);
    
    if (result.isErr()) {
      return Result.err(mapToMutationError(result.error));
    }
    
    return Result.ok(undefined);
  }
}

Usage in Server Actions

// app/admin/actions.ts
'use server';

import { UpdateUserStatusMutation } from '@/lib/mutations/UpdateUserStatusMutation';
import { revalidatePath } from 'next/cache';

export async function updateUserStatus(input: UpdateUserStatusInput) {
  // Manual construction: Mutation creates Service
  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 };
}

Dependency Chain

RSC Page / Server Action
  ↓ (manual construction)
PageQuery / Mutation
  ↓ (manual construction)
Service
  ↓ (creates own dependencies)
API Client
  ↓ (makes HTTP calls)
API

Key Principle: Each layer manually constructs the next layer. Services create their own infrastructure (API Client, Logger, ErrorReporter).

Why Manual Construction?

See: DEPENDENCY_CONSTRUCTION.md

Summary:

  • Explicit and clear
  • No singleton issues
  • No request-scoping problems
  • Easy to test (pass mocks to constructor)
  • Works perfectly with Next.js RSC
  • No DI container needed

Naming

  • Service classes: *Service
  • Service methods: Return Result<T, DomainError>
  • Variable names: apiDto, viewData (never just dto)

Comparison with Other Layers

Layer Purpose Example
Website Service Orchestrate API calls, handle errors AdminService
API Client HTTP infrastructure AdminApiClient
Core Use Case Business rules CreateLeagueUseCase
Domain Service Cross-entity logic StrengthOfFieldCalculator

Anti-Patterns

Wrong: Service creates ViewModels

// WRONG
class AdminService {
  async getUser(userId: string): Promise<UserViewModel> {
    const dto = await this.apiClient.getUser(userId);
    return new UserViewModel(dto); // ❌ ViewModels are client-only
  }
}

Correct: Service returns DTOs

// CORRECT
class AdminService {
  async getUser(userId: string): Promise<Result<UserDto, DomainError>> {
    try {
      const dto = await this.apiClient.getUser(userId);
      return Result.ok(dto); // ✅ DTOs are fine
    } catch (error) {
      return Result.err(new NotFoundError('User not found'));
    }
  }
}

Wrong: Service contains business logic

// WRONG
class AdminService {
  async canDeleteUser(userId: string): Promise<boolean> {
    const user = await this.apiClient.getUser(userId);
    return user.role !== 'admin'; // ❌ Business rule belongs in core
  }
}

Correct: Service orchestrates

// CORRECT
class AdminService {
  async getUser(userId: string): Promise<Result<UserDto, DomainError>> {
    try {
      const dto = await this.apiClient.getUser(userId);
      return Result.ok(dto);
    } catch (error) {
      return Result.err(new NotFoundError('User not found'));
    }
  }
}
// Business logic in core use case or page query

Wrong: Server action calls API client directly

// WRONG
'use server';
export async function updateUserStatus(userId: string, status: string) {
  const apiClient = new AdminApiClient(...);
  await apiClient.updateUserStatus(userId, status); // ❌ Should use service
}

Correct: Server action uses Mutation

// CORRECT
'use server';
export async function updateUserStatus(input: UpdateUserStatusInput) {
  const mutation = new UpdateUserStatusMutation();
  const result = await mutation.execute(input);
  // ...
}

Wrong: PageQuery creates API Client

// WRONG
export class DashboardPageQuery {
  async execute() {
    const apiClient = new DashboardApiClient(...); // ❌ Should use Service
    return await apiClient.getOverview();
  }
}

Correct: PageQuery uses Service

// CORRECT
export class DashboardPageQuery {
  async execute() {
    const service = new DashboardService(); // ✅ Service creates API Client
    return await service.getDashboardOverview();
  }
}

Summary

Website services are thin orchestration wrappers that create their own dependencies and handle error conversion.

Key principles:

  1. Services create their own dependencies (API Client, Logger, ErrorReporter)
  2. Services return Result<T, DomainError>
  3. Services convert HTTP errors to Domain errors
  4. Services don't create ViewModels
  5. Services don't contain business rules
  6. PageQueries/Mutations use Services, not API Clients directly
  7. Manual construction (no DI container in RSC)