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/ortemplates/ - 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 justdto)
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:
- ✅ Services create their own dependencies (API Client, Logger, ErrorReporter)
- ✅ Services return
Result<T, DomainError> - ✅ Services convert HTTP errors to Domain errors
- ❌ Services don't create ViewModels
- ❌ Services don't contain business rules
- ✅ PageQueries/Mutations use Services, not API Clients directly
- ✅ Manual construction (no DI container in RSC)