300 lines
8.4 KiB
Markdown
300 lines
8.4 KiB
Markdown
# 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:
|
|
|
|
```typescript
|
|
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)
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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](./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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
// CORRECT
|
|
'use server';
|
|
export async function updateUserStatus(input: UpdateUserStatusInput) {
|
|
const mutation = new UpdateUserStatusMutation();
|
|
const result = await mutation.execute(input);
|
|
// ...
|
|
}
|
|
```
|
|
|
|
❌ **Wrong**: PageQuery creates API Client
|
|
```typescript
|
|
// WRONG
|
|
export class DashboardPageQuery {
|
|
async execute() {
|
|
const apiClient = new DashboardApiClient(...); // ❌ Should use Service
|
|
return await apiClient.getOverview();
|
|
}
|
|
}
|
|
```
|
|
|
|
✅ **Correct**: PageQuery uses Service
|
|
```typescript
|
|
// 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) |