website refactor

This commit is contained in:
2026-01-12 19:24:59 +01:00
parent 1f0c4f7fa6
commit 5ea95eaf51
54 changed files with 2894 additions and 2342 deletions

View File

@@ -0,0 +1,207 @@
# Builders (Strict)
This document defines the **Builder** pattern for `apps/website`.
Builders exist to transform data between presentation model types.
## 1) Definition
A **Builder** is a deterministic, side-effect free transformation between website presentation models.
There are two types of builders:
### 1.1 ViewModel Builders
Transform API Transport DTOs into ViewModels.
**Purpose**: Prepare raw API data for client-side state management.
**Location**: `apps/website/lib/builders/view-models/**`
**Pattern**:
```typescript
export class AdminViewModelBuilder {
static build(dto: UserDto): AdminUserViewModel {
return new AdminUserViewModel(dto);
}
}
```
### 1.2 ViewData Builders
Transform API DTOs directly into ViewData for templates.
**Purpose**: Prepare API data for server-side rendering without ViewModels.
**Location**: `apps/website/lib/builders/view-data/**`
**Pattern**:
```typescript
export class LeagueViewDataBuilder {
static build(apiDto: LeagueApiDto): LeagueDetailViewData {
return {
leagueId: apiDto.id,
name: apiDto.name,
// ... more fields
};
}
}
```
## 2) Non-negotiable rules
### ViewModel Builders
1. MUST be deterministic
2. MUST be side-effect free
3. MUST NOT perform HTTP
4. MUST NOT call API clients
5. MUST NOT access cookies/headers
6. Input: API Transport DTO
7. Output: ViewModel
8. MUST live in `lib/builders/view-models/**`
### ViewData Builders
1. MUST be deterministic
2. MUST be side-effect free
3. MUST NOT perform HTTP
4. MUST NOT call API clients
5. MUST NOT access cookies/headers
6. Input: API DTO
7. Output: ViewData
8. MUST live in `lib/builders/view-data/**`
## 3) Why two builder types?
**ViewModel Builders** (API → Client State):
- Bridge the API boundary
- Convert transport types to client classes
- Add client-only fields if needed
- Run in client code
**ViewData Builders** (API → Render Data):
- Bridge the presentation boundary
- Transform API data directly for templates
- Format values for display
- Run in server code (RSC)
## 4) Relationship to other patterns
```
API Transport DTO
ViewModel Builder (lib/builders/view-models/)
ViewModel (lib/view-models/)
(for client components)
API Transport DTO
ViewData Builder (lib/builders/view-data/)
ViewData (lib/templates/)
Template (lib/templates/)
```
## 5) Naming convention
**ViewModel Builders**: `*ViewModelBuilder`
- `AdminViewModelBuilder`
- `RaceViewModelBuilder`
**ViewData Builders**: `*ViewDataBuilder`
- `LeagueViewDataBuilder`
- `RaceViewDataBuilder`
## 6) File structure
```
lib/
builders/
view-models/
AdminViewModelBuilder.ts
RaceViewModelBuilder.ts
index.ts
view-data/
LeagueViewDataBuilder.ts
RaceViewDataBuilder.ts
index.ts
```
## 7) Usage examples
### ViewModel Builder (Client Component)
```typescript
'use client';
import { AdminViewModelBuilder } from '@/lib/builders/view-models/AdminViewModelBuilder';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
export function AdminPage() {
const [users, setUsers] = useState<AdminUserViewModel[]>([]);
useEffect(() => {
const apiClient = new AdminApiClient();
const dto = await apiClient.getUsers();
const viewModels = dto.map(d => AdminViewModelBuilder.build(d));
setUsers(viewModels);
}, []);
// ... render with viewModels
}
```
### ViewData Builder (Server Component)
```typescript
import { LeagueViewDataBuilder } from '@/lib/builders/view-data/LeagueViewDataBuilder';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
export default async function LeagueDetailPage({ params }) {
const apiDto = await LeagueDetailPageQuery.execute(params.id);
const viewData = LeagueViewDataBuilder.build(apiDto);
return <LeagueDetailTemplate viewData={viewData} />;
}
```
## 8) Common mistakes
**Wrong**: Using "Presenter" for DTO → ViewModel
```typescript
// DON'T
export class AdminPresenter {
static createViewModel(dto: UserDto): AdminUserViewModel { ... }
}
```
**Correct**: Use ViewModelBuilder
```typescript
export class AdminViewModelBuilder {
static build(dto: UserDto): AdminUserViewModel { ... }
}
```
**Wrong**: Using "Transformer" for ViewModel → ViewData
```typescript
// DON'T
export class RaceResultsDataTransformer {
static transform(...): TransformedData { ... }
}
```
**Correct**: Use ViewDataBuilder
```typescript
export class RaceResultsViewDataBuilder {
static build(...): RaceResultsViewData { ... }
}
```
## 9) Enforcement
These rules are enforced by ESLint:
- `gridpilot-rules/view-model-builder-contract`
- `gridpilot-rules/view-data-builder-contract`
- `gridpilot-rules/filename-view-model-builder-match`
- `gridpilot-rules/filename-view-data-builder-match`
See [`docs/architecture/website/WEBSITE_GUARDRAILS.md`](WEBSITE_GUARDRAILS.md) for details.

View File

@@ -0,0 +1,151 @@
# Mutations (Strict)
This document defines the **Mutation** pattern for `apps/website`.
Mutations exist to provide framework-agnostic write operations.
## 1) Definition
A **Mutation** is a framework-agnostic operation that orchestrates writes.
Mutations are the write equivalent of PageQueries.
## 2) Relationship to Next.js Server Actions
**Server Actions are the entry point**, but they should be thin wrappers:
```typescript
// app/admin/actions.ts (Next.js framework code)
'use server';
import { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation';
import { revalidatePath } from 'next/cache';
export async function updateUserStatus(userId: string, status: string): Promise<void> {
try {
const mutation = new AdminUserMutation();
await mutation.updateUserStatus(userId, status);
revalidatePath('/admin/users');
} catch (error) {
console.error('updateUserStatus failed:', error);
throw new Error('Failed to update user status');
}
}
```
## 3) Mutation Structure
```typescript
// lib/mutations/admin/AdminUserMutation.ts
export class AdminUserMutation {
private service: AdminService;
constructor() {
// Manual DI for serverless
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
this.service = new AdminService(apiClient);
}
async updateUserStatus(userId: string, status: string): Promise<void> {
await this.service.updateUserStatus(userId, status);
}
async deleteUser(userId: string): Promise<void> {
await this.service.deleteUser(userId);
}
}
```
## 4) Why This Pattern?
**Benefits:**
1. **Framework independence** - Mutations can be tested without Next.js
2. **Consistent pattern** - Mirrors PageQueries for reads/writes
3. **Easy migration** - Can switch frameworks without rewriting business logic
4. **Testable** - Can unit test mutations in isolation
5. **Reusable** - Can be called from other contexts (cron jobs, etc.)
## 5) Naming Convention
- Mutations: `*Mutation.ts`
- Server Actions: `actions.ts` (thin wrappers)
## 6) File Structure
```
lib/
mutations/
admin/
AdminUserMutation.ts
AdminLeagueMutation.ts
league/
LeagueJoinMutation.ts
team/
TeamUpdateMutation.ts
```
## 7) Non-negotiable Rules
1. **Server Actions are thin wrappers** - They only handle framework concerns (revalidation, redirects)
2. **Mutations handle infrastructure** - They create services, handle errors
3. **Services handle business logic** - They orchestrate API calls
4. **Mutations are framework-agnostic** - No Next.js imports except in tests
5. **Mutations must be deterministic** - Same inputs = same outputs
## 8) Comparison with PageQueries
| Aspect | PageQuery | Mutation |
|--------|-----------|----------|
| Purpose | Read data | Write data |
| Location | `lib/page-queries/` | `lib/mutations/` |
| Framework | Called from RSC | Called from Server Actions |
| Infrastructure | Manual DI | Manual DI |
| Returns | Page DTO | void or result |
| Revalidation | N/A | Server Action handles it |
## 9) Example Flow
**Read:**
```
RSC page.tsx
PageQuery.execute()
Service
API Client
Page DTO
```
**Write:**
```
Client Component
Server Action
Mutation.execute()
Service
API Client
Revalidation
```
## 10) Enforcement
ESLint rules should enforce:
- Server Actions must call Mutations (not Services directly)
- Mutations must not import Next.js (except in tests)
- Mutations must use services
See `docs/architecture/website/WEBSITE_GUARDRAILS.md` for details.

View File

@@ -1,56 +1,40 @@
# Presenters (Strict)
# Builders (Deprecated)
This document defines the **Presenter** boundary for `apps/website`.
**This document is deprecated.** See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md) for the current pattern.
Presenters exist to prevent responsibility drift into:
## Summary of changes
- server routes
- Page Queries
- Templates
The architecture has been updated to use **Builders** instead of **Presenters**:
## 1) Definition
### Old pattern (deprecated)
- `lib/presenters/` - All transformations
- `lib/view-models/` - ViewModels + some presenters
A **Presenter** is a deterministic, side-effect free transformation between website presentation models.
### New pattern (current)
- `lib/builders/view-models/` - DTO → ViewModel
- `lib/builders/view-data/` - ViewModel → ViewData
- `lib/view-models/` - ViewModels only
Allowed transformations:
### Why the change?
- Page DTO → ViewData
- Page DTO → ViewModel
- ViewModel → ViewData
The old pattern had **three anti-patterns**:
## 2) Non-negotiable rules
1. **Inconsistent naming** - Same concept had 3 names (Presenter, Transformer, ViewModelPresenter)
2. **Inconsistent location** - Presenters lived in both `lib/presenters/` and `lib/view-models/`
3. **Confusing semantics** - "Presenter" implies presenting to client, but some presenters prepared data for server templates
1. Presenters MUST be deterministic.
2. Presenters MUST be side-effect free.
3. Presenters MUST NOT perform HTTP.
4. Presenters MUST NOT call API clients.
5. Presenters MUST NOT access cookies/headers.
6. Presenters MAY use Display Objects.
7. Presenters MUST NOT import Templates.
### What changed?
## 3) Where Presenters run
**ViewModel Builders** (DTO → ViewModel):
- Location: `lib/builders/view-models/`
- Naming: `*ViewModelBuilder`
- Example: `AdminViewModelBuilder.build(dto)`
Presenters run in **client code only**.
**ViewData Builders** (ViewModel → ViewData):
- Location: `lib/builders/view-data/`
- Naming: `*ViewDataBuilder`
- Example: `LeagueViewDataBuilder.build(viewModel, id)`
Presenters MUST be defined in `'use client'` modules.
This makes the architecture **self-documenting** and **clean**.
If a computation affects routing decisions (redirect, notFound), it belongs in a Page Query or server route composition, not in a Presenter.
## 4) Relationship to Display Objects
Display Objects implement reusable formatting/mapping.
Rules:
- Presenters may orchestrate Display Objects.
- Display Object instances MUST NOT appear in ViewData.
See [`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:1) and [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:1).
## 5) Canonical placement in this repo (strict)
Presenters MUST live colocated with ViewModels under:
- `apps/website/lib/view-models/**`
Reason: this repo already treats `apps/website/lib/view-models/**` as the client-only presentation module boundary.
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md) for full details.

View File

@@ -0,0 +1,231 @@
# 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
```typescript
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import type { UserDto } from '@/lib/api/admin/AdminApiClient';
export class AdminService {
constructor(private readonly apiClient: AdminApiClient) {}
async updateUserStatus(userId: string, status: string): Promise<UserDto> {
return this.apiClient.updateUserStatus(userId, status);
}
}
```
### Usage in PageQueries (Reads)
```typescript
// apps/website/lib/page-queries/AdminDashboardPageQuery.ts
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { AdminService } from '@/lib/services/admin/AdminService';
export class AdminDashboardPageQuery {
async execute(): Promise<PageQueryResult<AdminDashboardPageDto>> {
// Create infrastructure
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {...});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
const service = new AdminService(apiClient);
// Use service
const stats = await service.getDashboardStats();
// Transform to Page DTO
return { status: 'ok', dto: transformToPageDto(stats) };
}
}
```
### Usage in Server Actions (Writes)
```typescript
// apps/website/app/admin/actions.ts
'use server';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { AdminService } from '@/lib/services/admin/AdminService';
import { revalidatePath } from 'next/cache';
export async function updateUserStatus(userId: string, status: string): Promise<void> {
try {
// Create infrastructure
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
const service = new AdminService(apiClient);
// Use service (NOT API client directly)
await service.updateUserStatus(userId, status);
// Revalidate
revalidatePath('/admin/users');
} catch (error) {
console.error('updateUserStatus failed:', error);
throw new Error('Failed to update user status');
}
}
```
## Infrastructure Concerns
**Where should logging/error reporting live?**
In the current architecture, **server actions and PageQueries create infrastructure**. This is acceptable because:
1. Next.js serverless functions are stateless
2. Each request needs fresh infrastructure
3. Manual DI is clearer than magic containers
**Key principle**: Services orchestrate, they don't create infrastructure.
## Dependency Chain
```
Server Action / PageQuery
↓ (creates infrastructure)
Service
↓ (orchestrates)
API Client
↓ (makes HTTP calls)
API
```
## Naming
- Service classes: `*Service`
- Service methods: Return DTOs (not ViewModels)
- Variable names: `apiDto`, `pageDto` (never just `dto`)
## Comparison with Other Layers
| Layer | Purpose | Example |
|-------|---------|---------|
| **Website Service** | Orchestrate API calls | `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<UserDto> {
return this.apiClient.getUser(userId); // ✅ DTOs are fine
}
}
```
**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<UserDto> {
return this.apiClient.getUser(userId);
}
}
// 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 service
```typescript
// CORRECT
'use server';
export async function updateUserStatus(userId: string, status: string) {
const apiClient = new AdminApiClient(...);
const service = new AdminService(apiClient);
await service.updateUserStatus(userId, status); // ✅ Uses service
}
```
## Summary
Website services are **thin orchestration wrappers** around API clients. They handle infrastructure concerns so that PageQueries and Server Actions can focus on composition and validation.
**Key principles**:
1. Services orchestrate API calls
2. Server actions/PageQueries create infrastructure
3. Services don't create ViewModels
4. Services don't contain business rules
5. **Server actions MUST use services, not API clients directly**

View File

@@ -22,17 +22,34 @@ ViewData is not:
## 3) Construction rules
ViewData MUST be created in client code:
ViewData is created by **ViewData Builders**:
1) Initial SSR-safe render: `ViewData = fromDTO(PageDTO)`
2) Post-hydration render: `ViewData = fromViewModel(ViewModel)`
### Server Components (RSC)
```typescript
const apiDto = await PageQuery.execute();
const viewData = ViewDataBuilder.build(apiDto);
return <Template viewData={viewData} />;
```
These transformations are Presenters.
See [`PRESENTERS.md`](docs/architecture/website/PRESENTERS.md:1).
### Client Components
```typescript
'use client';
const [viewModel, setViewModel] = useState<ViewModel | null>(null);
useEffect(() => {
const apiDto = await apiClient.get();
const vm = ViewModelBuilder.build(apiDto);
setViewModel(vm);
}, []);
// Template receives ViewModel, not ViewData
return viewModel ? <Template viewModel={viewModel} /> : null;
```
Templates MUST NOT compute derived values.
Presenters MUST NOT call the API.
ViewData Builders MUST NOT call the API.
## 4) Determinism rules
@@ -46,7 +63,7 @@ Forbidden anywhere in formatting code paths:
Reason: SSR and browser outputs can differ.
Localization MUST NOT depend on runtime locale APIs.
If localized strings are required, they MUST be provided as deterministic inputs (for example via API-provided labels or a deterministic code-to-label map) and passed through Presenters into ViewData.
If localized strings are required, they MUST be provided as deterministic inputs (for example via API-provided labels or a deterministic code-to-label map) and passed through ViewData Builders into ViewData.
## 5) Relationship to Display Objects

View File

@@ -51,39 +51,20 @@ Canonical placement in this repo:
- `apps/website/lib/types/**` (transport DTOs consumed by services and page queries)
### 3.2 Page DTO
### 3.2 API Transport DTO
Definition: the website-owned, server-to-client payload for a route.
Definition: the shape returned by the backend API over HTTP.
Rules:
- JSON-serializable only.
- Contains **raw** values only (IDs, ISO strings, numbers, codes).
- MUST NOT contain class instances.
- Created by Page Queries.
- Passed from server routes into client code.
- API Transport DTOs MUST be contained inside infrastructure.
- API Transport DTOs MUST NOT be imported by Templates.
Canonical placement in this repo:
- `apps/website/lib/page-queries/**` (composition and Page DTO construction)
- `apps/website/lib/types/**` (transport DTOs consumed by services and page queries)
### 3.3 ViewModel
Definition: the client-only, UI-owned class representing fully prepared UI state.
Rules:
- Instantiated only in `'use client'` modules.
- Never serialized.
- MUST NOT be passed into Templates.
See [`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:1).
Canonical placement in this repo:
- `apps/website/lib/view-models/**`
### 3.4 ViewData
### 3.3 ViewData
Definition: the only allowed input type for Templates.
@@ -99,17 +80,29 @@ Canonical placement in this repo:
- `apps/website/templates/**` (Templates that accept ViewData only)
## 4) Presentation helpers (strict)
### 3.3 ViewModel
### 4.1 Presenter
Definition: the client-only, UI-owned class representing fully prepared UI state.
Definition: a deterministic, side-effect free transformation.
Rules:
Presenters map between website presentation models:
- Instantiated only in `'use client'` modules.
- Never serialized.
- Used for client components that need state management.
- Page DTO → ViewData
- Page DTO → ViewModel
- ViewModel → ViewData
See [`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:1).
Canonical placement in this repo:
- `apps/website/lib/view-models/**`
## 4) Data transformation helpers (strict)
### 4.1 ViewModel Builder
Definition: transforms API Transport DTOs into ViewModels.
Purpose: prepare raw API data for client-side state management.
Rules:
@@ -117,15 +110,37 @@ Rules:
- MUST be side-effect free.
- MUST NOT call HTTP.
- MUST NOT call the API.
- MAY use Display Objects.
- Input: API Transport DTO
- Output: ViewModel
See [`PRESENTERS.md`](docs/architecture/website/PRESENTERS.md:1).
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
Canonical placement in this repo:
- `apps/website/lib/presenters/**`
- `apps/website/lib/builders/view-models/**`
### 4.2 Display Object
### 4.2 ViewData Builder
Definition: transforms API DTOs directly into ViewData for templates.
Purpose: prepare API data for server-side rendering.
Rules:
- MUST be deterministic.
- MUST be side-effect free.
- MUST NOT call HTTP.
- MUST NOT call the API.
- Input: API Transport DTO
- Output: ViewData
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
Canonical placement in this repo:
- `apps/website/lib/builders/view-data/**`
### 4.3 Display Object
Definition: deterministic, reusable, UI-only formatting/mapping logic.
@@ -144,28 +159,40 @@ Canonical placement in this repo:
## 5) Read flow (strict)
### Server Components (RSC)
```text
RSC page.tsx
PageQuery (server)
PageQuery
API service / API client (infra)
API client (infra)
API Transport DTO
Page DTO
Presenter (client)
ViewModel (optional, client)
Presenter (client)
ViewData Builder (lib/builders/view-data/)
ViewData
Template
```
### Client Components
```text
Client Component
API client (useEffect)
API Transport DTO
ViewModel Builder (lib/builders/view-models/)
ViewModel (lib/view-models/)
Client State (useState)
Template
```
## 6) Write flow (strict)
All writes MUST enter through **Next.js Server Actions**.
@@ -179,9 +206,68 @@ Allowed:
- client submits intent (FormData, button action)
- server action performs UX validation
- server action calls the API
- **server action calls a service** (not API clients directly)
- service orchestrates API calls and business logic
See [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:1).
**Server Actions must use Services:**
```typescript
// ❌ WRONG - Direct API client usage
'use server';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
export async function updateUserStatus(userId: string, status: string) {
const apiClient = new AdminApiClient(...);
await apiClient.updateUserStatus(userId, status); // ❌ Should use service
}
// ✅ CORRECT - Service usage
'use server';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { AdminService } from '@/lib/services/admin/AdminService';
import { revalidatePath } from 'next/cache';
export async function updateUserStatus(userId: string, status: string) {
try {
// Create infrastructure
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
const service = new AdminService(apiClient);
// Use service
await service.updateUserStatus(userId, status);
// Revalidate
revalidatePath('/admin/users');
} catch (error) {
console.error('updateUserStatus failed:', error);
throw new Error('Failed to update user status');
}
}
```
**Pattern**:
1. Server action creates infrastructure (logger, errorReporter, apiClient)
2. Server action creates service with infrastructure
3. Server action calls service method
4. Server action handles revalidation and returns
**Rationale**:
- Services orchestrate API calls (can grow to multiple calls)
- Keeps server actions consistent with PageQueries
- Makes infrastructure explicit and testable
- Services can add caching, retries, transformations
See [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:1) and [`SERVICES.md`](docs/architecture/website/SERVICES.md:1).
## 7) Authorization (strict)
@@ -245,9 +331,12 @@ See [`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:1).
1. The API is the brain.
2. The website is a terminal.
3. API Transport DTOs never reach Templates.
4. ViewModels never go to the API.
5. Templates accept ViewData only.
6. Page Queries do not format; they only compose.
7. Presenters are pure and deterministic.
8. Server Actions are the only write entry point.
9. Authorization always belongs to the API.
4. Templates accept ViewData only.
5. Page Queries do not format; they only compose.
6. ViewData Builders transform API DTO → ViewData (RSC).
7. ViewModel Builders transform API DTO → ViewModel (Client).
8. Builders are pure and deterministic.
9. Server Actions are the only write entry point.
10. Server Actions must use Mutations (not Services directly).
11. Mutations orchestrate Services for writes.
12. Authorization always belongs to the API.

View File

@@ -0,0 +1,109 @@
# Write Flow Update (Mutation Pattern)
This document updates the write flow section of WEBSITE_CONTRACT.md to use the Mutation pattern.
## Write Flow (Strict)
All writes MUST enter through **Next.js Server Actions**.
### Forbidden
- client components performing write HTTP requests
- client components calling API clients for mutations
### Allowed
- client submits intent (FormData, button action)
- server action performs UX validation
- **server action calls a Mutation** (not Services directly)
- Mutation creates infrastructure and calls Service
- Service orchestrates API calls and business logic
### Server Actions must use Mutations
```typescript
// ❌ WRONG - Direct service usage
'use server';
import { AdminService } from '@/lib/services/admin/AdminService';
export async function updateUserStatus(userId: string, status: string) {
const service = new AdminService(...);
await service.updateUserStatus(userId, status); // ❌ Should use mutation
}
```
```typescript
// ✅ CORRECT - Mutation usage
'use server';
import { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation';
import { revalidatePath } from 'next/cache';
export async function updateUserStatus(userId: string, status: string) {
try {
const mutation = new AdminUserMutation();
await mutation.updateUserStatus(userId, status);
revalidatePath('/admin/users');
} catch (error) {
console.error('updateUserStatus failed:', error);
throw new Error('Failed to update user status');
}
}
```
### Mutation Pattern
```typescript
// lib/mutations/admin/AdminUserMutation.ts
export class AdminUserMutation {
private service: AdminService;
constructor() {
// Manual DI for serverless
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
this.service = new AdminService(apiClient);
}
async updateUserStatus(userId: string, status: string): Promise<void> {
await this.service.updateUserStatus(userId, status);
}
}
```
### Flow
1. **Server Action** (thin wrapper) - handles framework concerns (revalidation, redirects)
2. **Mutation** (framework-agnostic) - creates infrastructure, calls service
3. **Service** (business logic) - orchestrates API calls
4. **API Client** (infrastructure) - makes HTTP requests
### Rationale
- **Framework independence**: Mutations can be tested without Next.js
- **Consistency**: Mirrors PageQuery pattern for reads/writes
- **Migration ease**: Can switch frameworks without rewriting business logic
- **Testability**: Can unit test mutations in isolation
- **Reusability**: Can be called from other contexts (cron jobs, etc.)
### Comparison with PageQueries
| Aspect | PageQuery | Mutation |
|--------|-----------|----------|
| Purpose | Read data | Write data |
| Location | `lib/page-queries/` | `lib/mutations/` |
| Framework | Called from RSC | Called from Server Actions |
| Infrastructure | Manual DI | Manual DI |
| Returns | Page DTO | void or result |
| Revalidation | N/A | Server Action handles it |
### See Also
- [`MUTATIONS.md`](MUTATIONS.md) - Full mutation pattern documentation
- [`SERVICES.md`](SERVICES.md) - Service layer documentation
- [`WEBSITE_CONTRACT.md`](WEBSITE_CONTRACT.md) - Main contract

View File

@@ -16,6 +16,7 @@ It renders truth from the API and forwards user intent to the API.
## 2) Read flow
### Server Components (RSC)
```text
RSC page.tsx
@@ -25,19 +26,30 @@ API client (infra)
API Transport DTO
Page DTO
Presenter (client)
ViewModel (optional)
Presenter (client)
ViewData Builder (lib/builders/view-data/)
ViewData
Template
```
### Client Components
```text
Client Component
API client (useEffect)
API Transport DTO
ViewModel Builder (lib/builders/view-models/)
ViewModel (lib/view-models/)
Client State (useState)
Template
```
## 3) Write flow
All writes enter through **Server Actions**.
@@ -60,6 +72,8 @@ RSC reload
1. Templates accept ViewData only.
2. Page Queries do not format.
3. Presenters do not call the API.
4. Client state is UI-only.
3. ViewData Builders transform API DTO → ViewData (RSC).
4. ViewModel Builders transform API DTO → ViewModel (Client).
5. Builders do not call the API.
6. Client state is UI-only.

View File

@@ -35,16 +35,19 @@ Canonical folders (existing in this repo):
```text
apps/website/lib/
api/  API clients
infrastructure/  technical concerns
services/  UI orchestration (read-only and write orchestration)
page-queries/  server composition
types/  API transport DTOs
view-models/  client-only classes
display-objects/  deterministic formatting helpers
command-models/  transient form models
blockers/  UX-only prevention
hooks/  React-only helpers
di/  client-first DI integration
api/  API clients
infrastructure/  technical concerns
services/  UI orchestration (read-only and write orchestration)
page-queries/  server composition
types/  API transport DTOs
builders/  data transformation (DTO → ViewModel → ViewData)
view-models/
view-data/
view-models/  client-only classes
display-objects/  deterministic formatting helpers
command-models/  transient form models
blockers/  UX-only prevention
hooks/  React-only helpers
di/  client-first DI integration
```

View File

@@ -1,248 +0,0 @@
# Website Guardrails (Mandatory)
This document defines architecture guardrails that must be enforced via tests + ESLint.
Authoritative contract: [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:1).
Purpose:
- Encode the architecture as *enforceable* rules.
- Remove ambiguity and prevent drift.
- Make it impossible for `page.tsx` and Templates to accumulate business logic.
## 1) RSC boundary guardrails
Fail CI if any `apps/website/app/**/page.tsx`:
- imports from `apps/website/lib/view-models/*`
- imports from Presenter code (presenters live colocated with ViewModels)
- calls `Intl.*` or `toLocale*`
- performs sorting/filtering (`sort`, `filter`, `reduce`) beyond trivial null checks
Also fail CI if any `apps/website/app/**/page.tsx`:
- imports from `apps/website/lib/display-objects/**`
- imports from `apps/website/lib/services/**` **that are not explicitly server-safe**
- imports from `apps/website/lib/di/**` (server DI ban)
- defines local helper functions other than trivial `assert*`/`invariant*` guards
- contains `new SomeClass()` (object graph construction belongs in PageQueries)
- contains any of these calls (directly or indirectly):
- `ContainerManager.getInstance()`
- `ContainerManager.getContainer()`
Filename rules (route module clarity):
- Only `page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx`, `not-found.tsx`, `actions.ts` are allowed under `apps/website/app/**`.
- Fail CI if any file under `apps/website/app/**` matches:
- `*Template.tsx`
- `*ViewModel.ts`
- `*Presenter.ts`
Allowed exception:
- `apps/website/app/<route>/actions.ts` may call services and API clients (server-side), but it must not import ViewModels or Presenters.
## 2) Template purity guardrails
Fail CI if any `apps/website/templates/**`:
- imports from `apps/website/lib/view-models/*`
- imports from presenter code (presenters live colocated with ViewModels)
- imports from `apps/website/lib/display-objects/*`
- calls `Intl.*` or `toLocale*`
Also fail CI if any Template:
- contains `useMemo`, `useEffect`, `useState`, `useReducer` (state belongs in `*PageClient.tsx` and components)
- calls `.filter`, `.sort`, `.reduce` (derived computations must happen before ViewData reaches Templates)
- imports from:
- `apps/website/lib/page-queries/**`
- `apps/website/lib/services/**`
- `apps/website/lib/api/**`
- `apps/website/lib/di/**`
- `apps/website/lib/contracts/**`
Templates accept ViewData only.
Filename + signature rules:
- Template filenames must end with `Template.tsx`.
- The first parameter type of a Template component must be `*ViewData` (or an object containing only `*ViewData` shapes).
- Templates must not export helper functions.
## 3) Display Object guardrails
Fail CI if any `apps/website/lib/display-objects/**`:
- calls `Intl.*` or `toLocale*`
Also fail CI if any Display Object:
- imports from `apps/website/lib/api/**`, `apps/website/lib/services/**`, or `apps/website/lib/page-queries/**` (no IO)
- imports from `apps/website/lib/view-models/**` (direction must be Presenter/ViewModel -> DisplayObject, not vice versa)
- exports non-class members (Display Objects must be class-based)
Display Objects must be deterministic.
## 4) Page Query guardrails (server composition only)
Fail CI if any `apps/website/lib/page-queries/**`:
- imports from `apps/website/lib/view-models/**`
- imports from `apps/website/lib/display-objects/**`
- imports from `apps/website/lib/di/**` or references `ContainerManager`
- calls `Intl.*` or `toLocale*`
- calls `.sort`, `.filter`, `.reduce` (sorting/filtering belongs in API if canonical; otherwise client ViewModel)
- returns `null` (must return `PageQueryResult` union)
Filename rules:
- PageQueries must be named `*PageQuery.ts`.
- Page DTO types must be named `*PageDto` and live next to their PageQuery.
## 5) Services guardrails (DTO-only, server-safe)
Fail CI if any `apps/website/lib/services/**`:
- imports from `apps/website/lib/view-models/**` or `apps/website/templates/**`
- imports from `apps/website/lib/display-objects/**`
- stores state on `this` other than injected dependencies (services must be stateless)
- uses blockers (blockers are client-only UX helpers)
Naming rules:
- Service methods returning API responses should use variable name `apiDto`.
- Service methods returning Page DTO should use variable name `pageDto`.
## 6) Client-only guardrails (ViewModels, Presenters)
Fail CI if any file under `apps/website/lib/view-models/**`:
- lacks `'use client'` at top-level when it exports a ViewModel class intended for instantiation
- imports from `apps/website/lib/page-queries/**` or `apps/website/app/**` (dependency direction violation)
Fail CI if any Presenter/ViewModel uses:
- HTTP calls (`fetch`, axios, API clients)
## 7) Write boundary guardrails (Server Actions only)
Fail CI if any client module (`'use client'` file or `apps/website/components/**`) performs HTTP writes:
- `fetch` with method `POST|PUT|PATCH|DELETE`
Fail CI if any server action (`apps/website/app/**/actions.ts`):
- imports from `apps/website/lib/view-models/**` or `apps/website/templates/**`
- returns ViewModels (must return primitives / redirect / revalidate)
## 8) Model taxonomy guardrails (naming + type suffixes)
Problem being prevented:
- Calling everything “dto” collapses API Transport DTO, Page DTO, and ViewData.
- This causes wrong-layer dependencies and makes reviews error-prone.
Fail CI if any file under `apps/website/**` contains a variable named exactly:
- `dto`
Allowed variable names (pick the right one):
- `apiDto` (API Transport DTO from OpenAPI / backend HTTP)
- `pageDto` (Page DTO assembled by PageQueries)
- `viewData` (Template input)
- `commandDto` (write intent)
Type naming rules (CI should fail if violated):
1. Any PageQuery output type MUST end with `PageDto`.
- Applies to types defined in `apps/website/lib/page-queries/**`.
2. Any Template prop type MUST end with `ViewData`.
- Applies to types used by `apps/website/templates/**`.
3. API Transport DTO types may end with `DTO` (existing generated convention) or `ApiDto` (preferred for hand-written).
Module boundary reinforcement:
- `apps/website/templates/**` MUST NOT import API Transport DTO types directly.
- Prefer: PageQuery emits `pageDto` → Presenter emits `viewData`.
## 9) Contracts enforcement (mandatory interfaces)
Purpose:
- Guardrails that rely on regex alone will always have loopholes.
- Contracts make the compiler enforce architecture: code must implement the right shapes.
These contracts live under:
- `apps/website/lib/contracts/**`
### 9.1 Required contracts
Fail CI if any of these are missing:
1. PageQuery contract: `apps/website/lib/contracts/page-queries/PageQuery.ts`
- Requires `execute(...) -> PageQueryResult<PageDto>`.
2. Service contract(s): `apps/website/lib/contracts/services/*`
- Services return `ApiDto`/`PageDto` only.
- No ViewModels.
3. Presenter contract: `apps/website/lib/contracts/presenters/Presenter.ts`
- `present(input) -> output` (pure, deterministic).
4. ViewModel base: `apps/website/lib/contracts/view-models/ViewModel.ts`
- ViewModels are client-only.
- Must not expose a method that returns Page DTO or API DTO.
### 9.2 Enforcement rules
Fail CI if:
- Any file under `apps/website/lib/page-queries/**` defines a `class *PageQuery` that does NOT implement `PageQuery`.
- Any file under `apps/website/lib/services/**` defines a `class *Service` that does NOT implement a Service contract.
- Any file under `apps/website/lib/view-models/**` defines a `*Presenter` that does NOT implement `Presenter`.
Additionally:
- Fail if a PageQuery returns a shape that is not `PageQueryResult`.
- Fail if a service method returns a `*ViewModel` type.
Note:
- Enforcement can be implemented as a boundary test that parses TypeScript files (or a regex-based approximation as a first step), but the source of truth is: contracts must exist and be implemented.
## 10) Generated DTO isolation (OpenAPI transport types do not reach UI)
Purpose:
- Generated OpenAPI DTOs are transport contracts.
- UI must not depend on transport contracts directly.
- Prevents “DTO soup” and forces the PageDto/ViewData boundary.
Fail CI if any of these import from `apps/website/lib/types/generated/**`:
- `apps/website/templates/**`
- `apps/website/components/**`
- `apps/website/hooks/**` and `apps/website/lib/hooks/**`
Fail CI if any Template imports from `apps/website/lib/types/**`.
Allowed locations for generated DTO imports:
- `apps/website/lib/api/**` (API clients)
- `apps/website/lib/services/**` (transport orchestration)
- `apps/website/lib/page-queries/**` (Page DTO assembly)
Enforced flow:
- Generated `*DTO` -> `apiDto` (API client/service)
- `apiDto` -> `pageDto` (PageQuery)
- `pageDto` -> `viewData` (Presenter)
Rationale:
- If the API contract changes, the blast radius stays in infrastructure + server composition, not in Templates.

View File

@@ -42,18 +42,52 @@ Authoritative contract: [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSIT
- JSON-serializable only.
- Contains only values ready to render (mostly strings/numbers).
- Built from Page DTO (initial render) and from ViewModel (post-hydration).
- Built from API DTO directly in RSC.
The mapping between Page DTO, ViewModel, and ViewData is performed by Presenters.
See [`PRESENTERS.md`](docs/architecture/website/PRESENTERS.md:1).
The mapping between API DTO and ViewData is performed by ViewData Builders.
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
## 3) Required per-route structure
Every route MUST follow:
### Server Components (RSC)
Every RSC route MUST follow:
1) `page.tsx` (server): calls a PageQuery and passes Page DTO
2) `*PageClient.tsx` (client): builds ViewData and renders Template
3) `*Template.tsx` (pure UI): renders ViewData only
1) `page.tsx`: calls a PageQuery
2) `page.tsx`: builds ViewData using ViewDataBuilder
3) `page.tsx`: renders Template with ViewData
Example:
```typescript
export default async function AdminDashboardPage() {
const apiDto = await AdminDashboardPageQuery.execute();
const viewData = AdminDashboardViewDataBuilder.build(apiDto);
return <AdminDashboardTemplate viewData={viewData} />;
}
```
### Client Components
Client components that need API data MUST follow:
1) `*Client.tsx`: fetches API DTO
2) `*Client.tsx`: builds ViewModel using ViewModelBuilder
3) `*Client.tsx`: renders Template with ViewModel
Example:
```typescript
'use client';
export function AdminDashboardClient() {
const [viewModel, setViewModel] = useState<AdminDashboardViewModel | null>(null);
useEffect(() => {
const apiDto = await adminApiClient.getDashboard();
const vm = AdminDashboardViewModelBuilder.build(apiDto);
setViewModel(vm);
}, []);
return viewModel ? <AdminDashboardTemplate viewModel={viewModel} /> : null;
}
```
All writes enter through Server Actions.
See [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:1).