website refactor
This commit is contained in:
207
docs/architecture/website/BUILDERS.md
Normal file
207
docs/architecture/website/BUILDERS.md
Normal 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.
|
||||
151
docs/architecture/website/MUTATIONS.md
Normal file
151
docs/architecture/website/MUTATIONS.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
231
docs/architecture/website/SERVICES.md
Normal file
231
docs/architecture/website/SERVICES.md
Normal 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**
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
109
docs/architecture/website/WEBSITE_CONTRACT_WRITE_FLOW.md
Normal file
109
docs/architecture/website/WEBSITE_CONTRACT_WRITE_FLOW.md
Normal 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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user