website refactor

This commit is contained in:
2026-01-13 01:36:27 +01:00
parent d18e2979ba
commit e981ebd9e9
5 changed files with 623 additions and 96 deletions

View File

@@ -1,10 +1,10 @@
# Display Objects
# Displays
## Definition
A **Display Object** encapsulates **reusable, UI-only display logic**.
A **Display** encapsulates **reusable, UI-only display logic**.
In this codebase, a Display Object is a **Frontend Value Object**:
In this codebase, a Display is a **Frontend Value Object**:
- class-based
- immutable
@@ -15,14 +15,20 @@ It answers the question:
> “How should this specific piece of information be shown?”
Display Objects are **not screen-specific**.
Displays are **not screen-specific**.
They exist to avoid duplicating presentation logic across View Models.
**Naming Convention:**
- Displays MUST end with `Display` suffix
- Displays MUST be reusable across multiple screens
- Valid examples: `PriceDisplay`, `EmailDisplay`, `RatingDisplay`
- Invalid examples: `DashboardRatingDisplay`, `UserProfileDisplay`
---
## Responsibilities
A Display Object MAY:
A Display MAY:
- format values (money, dates, durations)
- handle localization only when localization inputs are deterministic (for example: mapping stable codes to stable labels)
@@ -30,18 +36,18 @@ A Display Object MAY:
- encapsulate UI display conventions
- be reused across multiple View Models
In addition, a Display Object MAY:
In addition, a Display MAY:
- normalize presentation inputs (for example trimming/casing)
- expose multiple explicit display variants (for example `shortLabel`, `longLabel`)
A Display Object MUST:
A Display MUST:
- be deterministic
- be side-effect free
- operate only on presentation data
A Display Object MUST:
A Display MUST:
- be implemented as a **class** with a small, explicit API
- accept only primitives/plain data in its constructor (or static factory)
@@ -51,7 +57,7 @@ A Display Object MUST:
## Restrictions
A Display Object MUST NOT:
A Display MUST NOT:
- contain business logic
- enforce domain invariants
@@ -60,7 +66,7 @@ A Display Object MUST NOT:
- be sent back to the server
- depend on backend or infrastructure concerns
In this repository, a Display Object MUST NOT:
In this repository, a Display MUST NOT:
- call `Intl.*`
- call `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()`
@@ -82,41 +88,42 @@ Forbidden approaches:
- any usage of `toLocale*`
If a rule affects system correctness or persistence,
it does not belong in a Display Object.
it does not belong in a Display.
---
## Ownership & Placement
- Display Objects belong to the **presentation layer**
- Displays belong to the **presentation layer**
- They are frontend-only
- They are not shared with the backend or core
Placement rule (strict):
- Display Objects live under `apps/website/lib/display-objects/*`.
- Displays live under `apps/website/lib/display-objects/*`.
- Filenames MUST match the class name with `.tsx` extension (e.g., `RatingDisplay.tsx` contains `class RatingDisplay`)
---
## Relationship to View Models
- View Models MAY use Display Objects
- Display Objects MUST NOT depend on View Models
- Display Objects represent **parts**
- View Models MAY use Displays
- Displays MUST NOT depend on View Models
- Displays represent **parts**
- View Models represent **screens**
Additional strict rules:
- View Models SHOULD compose Display Objects.
- Display Objects MUST NOT be serialized or passed across boundaries.
- View Models SHOULD compose Displays.
- Displays MUST NOT be serialized or passed across boundaries.
- They must not appear in server-to-client DTOs.
- Templates should receive primitive display outputs, not Display Object instances.
- Templates should receive primitive display outputs, not Display instances.
---
## Testing
Display Objects SHOULD be tested because they often contain:
Displays SHOULD be tested because they often contain:
- locale-specific behavior
- formatting rules
@@ -131,9 +138,9 @@ Additionally:
## Summary
- Display Objects encapsulate **how something looks**
- Displays encapsulate **how something looks**
- View Models encapsulate **what a screen needs**
- Both are presentation concerns
- Neither contains business truth
In one sentence: Display Objects are **Value Objects for UI display**, not utility functions.
In one sentence: Displays are **Value Objects for UI display**, not utility functions.

View File

@@ -21,15 +21,16 @@ Mutations are the write equivalent of PageQueries.
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);
export async function updateUserStatus(userId: string, status: string) {
const mutation = new AdminUserMutation();
const result = await mutation.updateUserStatus(userId, status);
if (result.isErr()) {
console.error('updateUserStatus failed:', result.getError());
throw new Error('Failed to update user status');
}
revalidatePath('/admin/users');
}
```
@@ -37,6 +38,8 @@ export async function updateUserStatus(userId: string, status: string): Promise<
```typescript
// lib/mutations/admin/AdminUserMutation.ts
import { Result, ResultFactory } from '@/lib/contracts/Result';
export class AdminUserMutation {
private service: AdminService;
@@ -53,12 +56,22 @@ export class AdminUserMutation {
this.service = new AdminService(apiClient);
}
async updateUserStatus(userId: string, status: string): Promise<void> {
await this.service.updateUserStatus(userId, status);
async updateUserStatus(userId: string, status: string): Promise<Result<void, string>> {
try {
await this.service.updateUserStatus(userId, status);
return ResultFactory.ok(undefined);
} catch (error) {
return ResultFactory.error('UPDATE_USER_STATUS_FAILED');
}
}
async deleteUser(userId: string): Promise<void> {
await this.service.deleteUser(userId);
async deleteUser(userId: string): Promise<Result<void, string>> {
try {
await this.service.deleteUser(userId);
return ResultFactory.ok(undefined);
} catch (error) {
return ResultFactory.error('DELETE_USER_FAILED');
}
}
}
```
@@ -108,12 +121,12 @@ lib/
| 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 |
| Returns | `Result<ApiDto, string>` | `Result<void, string>` |
| Revalidation | N/A | Server Action handles it |
## 9) Example Flow
**Read:**
**Read (RSC):**
```
RSC page.tsx
@@ -123,10 +136,14 @@ Service
API Client
Page DTO
Result<ApiDto, string>
ViewDataBuilder
Template
```
**Write:**
**Write (Server Action):**
```
Client Component
@@ -138,6 +155,8 @@ Service
API Client
Result<void, string>
Revalidation
```

View File

@@ -0,0 +1,481 @@
# PageQuery Error Handling Design (Option 1: Clean Architecture)
## The Problem
The current `DashboardPageQuery` mixes HTTP concerns with domain logic:
```typescript
// ❌ WRONG - Mixes HTTP status codes with PageQuery logic
interface ErrorWithStatusCode extends Error {
statusCode?: number;
}
if (errorWithStatus.statusCode === 404) {
return Result.err('notFound');
}
```
This violates Clean Architecture because:
- PageQueries should not know about HTTP status codes
- PageQueries should use Services, not API Clients directly
- Error classification should happen at the Service layer
- PageQueries should only deal with domain error IDs
## The Solution: Layered Error Handling
### Layer 1: API Client (HTTP → Technical Errors)
The API client handles HTTP communication and catches HTTP errors:
```typescript
// apps/website/lib/api/dashboard/DashboardApiClient.ts
export class DashboardApiClient {
async getDashboardOverview(): Promise<DashboardStats> {
const response = await fetch(`${this.baseUrl}/dashboard/overview`, {
headers: this.getHeaders(),
});
if (!response.ok) {
// Throw HTTP-specific errors (will be caught by Service)
if (response.status === 404) {
throw new HttpNotFoundError('Dashboard not found');
}
if (response.status === 401 || response.status === 403) {
throw new HttpUnauthorizedError('Access denied');
}
if (response.status >= 500) {
throw new HttpServerError('Server error');
}
throw new HttpError(`HTTP ${response.status}`);
}
return response.json();
}
}
// HTTP error classes
class HttpError extends Error {}
class HttpNotFoundError extends HttpError {}
class HttpUnauthorizedError extends HttpError {}
class HttpServerError extends HttpError {}
```
### Layer 2: Service (Technical → Domain Errors)
The Service catches HTTP errors and converts them to domain errors:
```typescript
// apps/website/lib/services/dashboard/DashboardService.ts
import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient';
export type DashboardServiceError =
| { type: 'notFound'; message: string }
| { type: 'unauthorized'; message: string }
| { type: 'serverError'; message: string }
| { type: 'networkError'; message: string }
| { type: 'unknown'; message: string };
export class DashboardService {
constructor(private apiClient: DashboardApiClient) {}
async getDashboardOverview(): Promise<Result<DashboardStats, DashboardServiceError>> {
try {
const data = await this.apiClient.getDashboardOverview();
return Result.ok(data);
} catch (error) {
// Convert HTTP errors to domain errors
if (error instanceof HttpNotFoundError) {
return Result.err({ type: 'notFound', message: error.message });
}
if (error instanceof HttpUnauthorizedError) {
return Result.err({ type: 'unauthorized', message: error.message });
}
if (error instanceof HttpServerError) {
return Result.err({ type: 'serverError', message: error.message });
}
if (error instanceof HttpError) {
return Result.err({ type: 'unknown', message: error.message });
}
if (error instanceof Error) {
return Result.err({ type: 'networkError', message: error.message });
}
return Result.err({ type: 'unknown', message: 'Unknown error' });
}
}
}
```
### Layer 3: PageQuery (Domain → Presentation Errors)
PageQueries use Services and map domain errors to presentation errors:
```typescript
// apps/website/lib/page-queries/page-queries/DashboardPageQuery.ts
import { DashboardService, type DashboardServiceError } from '@/lib/services/dashboard/DashboardService';
// Presentation error IDs (what the template/redirect logic understands)
type DashboardPageError = 'notFound' | 'redirect' | 'DASHBOARD_FETCH_FAILED' | 'UNKNOWN_ERROR';
export class DashboardPageQuery implements PageQuery<DashboardViewData, void> {
async execute(): Promise<Result<DashboardViewData, DashboardPageError>> {
// Manual wiring
const errorReporter = new ConsoleErrorReporter();
const logger = new ConsoleLogger();
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const apiClient = new DashboardApiClient(baseUrl, errorReporter, logger);
const dashboardService = new DashboardService(apiClient);
// Call service
const serviceResult = await dashboardService.getDashboardOverview();
if (serviceResult.isErr()) {
const serviceError = serviceResult.error;
// Map service errors to presentation errors
switch (serviceError.type) {
case 'notFound':
return Result.err('notFound');
case 'unauthorized':
return Result.err('redirect'); // Redirect to login
case 'serverError':
case 'networkError':
case 'unknown':
return Result.err('DASHBOARD_FETCH_FAILED');
default:
return Result.err('UNKNOWN_ERROR');
}
}
// Success - transform to ViewData
const apiDto = serviceResult.value;
const viewData = DashboardViewDataBuilder.build(apiDto);
return Result.ok(viewData);
}
}
```
### Layer 4: RSC Page (Presentation → User)
The RSC page handles presentation errors:
```typescript
// apps/website/app/dashboard/page.tsx
import { DashboardPageQuery } from '@/lib/page-queries/page-queries/DashboardPageQuery';
import { DashboardTemplate } from '@/templates/DashboardTemplate';
export default async function DashboardPage() {
const query = new DashboardPageQuery();
const result = await query.execute();
if (result.isErr()) {
switch (result.error) {
case 'notFound':
notFound();
case 'redirect':
redirect('/login');
case 'DASHBOARD_FETCH_FAILED':
case 'UNKNOWN_ERROR':
return <ErrorAnalyticsDashboard error={result.error} />;
}
}
return <DashboardTemplate viewData={result.value} />;
}
```
## Architecture Flow
```
HTTP Response (HTTP status codes)
API Client (catches HTTP errors, throws HttpError)
Service (catches HttpError, returns Result<DomainData, DomainError>)
PageQuery (uses Service, returns Result<ViewData, PresentationError>)
RSC Page (handles PresentationError, renders template or error)
```
## Key Principles
1. **PageQueries use Services**: Never call API Clients directly
2. **Services return Result**: All service methods return `Result<T, DomainError>`
3. **Services handle HTTP errors**: Convert HttpError → DomainError
4. **PageQueries handle presentation errors**: Convert DomainError → PresentationError
5. **No HTTP knowledge in PageQueries**: They don't know about status codes
## Benefits
1. **Clean Architecture**: Each layer has clear boundaries
2. **Type Safety**: Errors are typed at each boundary
3. **Testability**: Can mock services in PageQueries
4. **Flexibility**: Can swap HTTP implementations
5. **Framework Agnostic**: PageQueries remain pure
## Complete Architecture: Read + Write Flows
### Read Flow (PageQueries)
```
HTTP Response (HTTP status codes)
API Client (catches HTTP errors, throws HttpError)
Service (catches HttpError, returns Result<DomainData, DomainError>)
PageQuery (uses Service, returns Result<ViewData, PresentationError>)
RSC Page (handles PresentationError, renders template or error)
```
### Write Flow (Mutations)
```
User Action (form submission, button click)
Server Action (validates input, creates Command Model)
Mutation (uses Service, returns Result<void, MutationError>)
Service (catches HttpError, returns Result<void, DomainError>)
API Client (throws HttpError on failure)
Server Action (handles Result, revalidates data, returns to client)
Client Component (shows success message or error)
```
## Mutation Error Handling
### Layer 1: API Client (HTTP → Technical Errors)
```typescript
// apps/website/lib/api/user/UserApiClient.ts
export class UserApiClient {
async updateUserStatus(userId: string, status: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/users/${userId}/status`, {
method: 'PUT',
body: JSON.stringify({ status }),
headers: this.getHeaders(),
});
if (!response.ok) {
if (response.status === 404) {
throw new HttpNotFoundError('User not found');
}
if (response.status === 403) {
throw new HttpForbiddenError('Insufficient permissions');
}
if (response.status === 400) {
const error = await response.json();
throw new HttpValidationError(error.message);
}
throw new HttpError(`HTTP ${response.status}`);
}
}
}
```
### Layer 2: Service (Technical → Domain Errors)
```typescript
// apps/website/lib/services/user/UserService.ts
export type UserServiceError =
| { type: 'notFound'; message: string }
| { type: 'forbidden'; message: string }
| { type: 'validation'; message: string }
| { type: 'serverError'; message: string };
export class UserService {
constructor(private apiClient: UserApiClient) {}
async updateUserStatus(userId: string, status: string): Promise<Result<void, UserServiceError>> {
try {
await this.apiClient.updateUserStatus(userId, status);
return Result.ok(undefined);
} catch (error) {
if (error instanceof HttpNotFoundError) {
return Result.err({ type: 'notFound', message: error.message });
}
if (error instanceof HttpForbiddenError) {
return Result.err({ type: 'forbidden', message: error.message });
}
if (error instanceof HttpValidationError) {
return Result.err({ type: 'validation', message: error.message });
}
if (error instanceof HttpError) {
return Result.err({ type: 'serverError', message: error.message });
}
return Result.err({ type: 'serverError', message: 'Unknown error' });
}
}
}
```
### Layer 3: Mutation (Domain → Presentation Errors)
```typescript
// apps/website/lib/mutations/UpdateUserStatusMutation.ts
import { UserService, type UserServiceError } from '@/lib/services/user/UserService';
// Presentation error IDs
type UpdateUserStatusError = 'userNotFound' | 'noPermission' | 'invalidData' | 'updateFailed';
export class UpdateUserStatusMutation implements Mutation<UpdateUserStatusInput, void> {
constructor(private userService: UserService) {}
async execute(input: UpdateUserStatusInput): Promise<Result<void, UpdateUserStatusError>> {
const serviceResult = await this.userService.updateUserStatus(input.userId, input.status);
if (serviceResult.isErr()) {
const error = serviceResult.error;
switch (error.type) {
case 'notFound':
return Result.err('userNotFound');
case 'forbidden':
return Result.err('noPermission');
case 'validation':
return Result.err('invalidData');
case 'serverError':
return Result.err('updateFailed');
default:
return Result.err('updateFailed');
}
}
return Result.ok(undefined);
}
}
```
### Layer 4: Server Action (Presentation → User)
```typescript
// apps/website/app/actions/userActions.ts
'use server';
import { UpdateUserStatusMutation } from '@/lib/mutations/UpdateUserStatusMutation';
import { UserService } from '@/lib/services/user/UserService';
import { UserApiClient } from '@/lib/api/user/UserApiClient';
import { revalidatePath } from 'next/cache';
export async function updateUserStatus(input: UpdateUserStatusInput) {
// Manual wiring
const errorReporter = new ConsoleErrorReporter();
const logger = new ConsoleLogger();
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const apiClient = new UserApiClient(baseUrl, errorReporter, logger);
const userService = new UserService(apiClient);
const mutation = new UpdateUserStatusMutation(userService);
const result = await mutation.execute(input);
if (result.isErr()) {
// Return error to client
return { success: false, error: result.error };
}
// Success - revalidate and return
revalidatePath('/admin/users');
return { success: true };
}
```
### Layer 5: Client Component (Handles Result)
```typescript
// apps/website/components/admin/UserStatusForm.tsx
'use client';
import { updateUserStatus } from '@/app/actions/userActions';
import { useState } from 'react';
export function UserStatusForm({ userId }: { userId: string }) {
const [status, setStatus] = useState('active');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(false);
const result = await updateUserStatus({ userId, status });
if (result.success) {
setSuccess(true);
} else {
// Map mutation error to user-friendly message
switch (result.error) {
case 'userNotFound':
setError('User not found');
break;
case 'noPermission':
setError('You do not have permission to update this user');
break;
case 'invalidData':
setError('Invalid status selected');
break;
case 'updateFailed':
setError('Failed to update user status');
break;
}
}
};
return (
<form onSubmit={handleSubmit}>
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
</select>
<button type="submit">Update</button>
{error && <div className="error">{error}</div>}
{success && <div className="success">User updated!</div>}
</form>
);
}
```
## Complete Error Flow Comparison
### Read Flow (PageQuery)
```
HTTP 404 → HttpNotFoundError → Service Error {type: 'notFound'} → PageQuery Error 'notFound' → notFound()
```
### Write Flow (Mutation)
```
HTTP 404 → HttpNotFoundError → Service Error {type: 'notFound'} → Mutation Error 'userNotFound' → Client shows "User not found"
```
## Key Differences
| Aspect | PageQuery | Mutation |
|--------|-----------|----------|
| **Purpose** | Fetch data for rendering | Perform write operations |
| **Return Type** | `Result<ViewData, PresentationError>` | `Result<void, MutationError>` |
| **Error Handling** | RSC handles (notFound, redirect) | Client handles (show message) |
| **Side Effects** | None (pure) | Revalidation, cache invalidation |
| **User Feedback** | Error page or redirect | Toast message or inline error |
## Implementation Steps
1. **Update Services**: Return `Result<T, DomainError>` for both read and write
2. **Update PageQueries**: Use Services, map DomainError → PresentationError
3. **Update Mutations**: Use Services, map DomainError → MutationError
4. **Remove ErrorWithStatusCode**: Delete from all PageQueries
5. **Add ESLint Rules**: Prevent status codes and direct API usage
## ESLint Rules Needed
1. **No status codes in PageQueries**: Prevent `statusCode` checks
2. **No status codes in Mutations**: Prevent `statusCode` checks
3. **PageQueries must use Services**: Prevent direct API client usage
4. **Mutations must use Services**: Prevent direct API client usage
5. **Services must return Result**: Enforce Result pattern
This complete architecture handles both read and write flows with clean separation of concerns.

View File

@@ -110,7 +110,7 @@ Rules:
- MUST be side-effect free.
- MUST NOT call HTTP.
- MUST NOT call the API.
- Input: API Transport DTO
- Input: `Result<ApiDto, string>` or `ApiDto`
- Output: ViewModel
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
@@ -131,7 +131,7 @@ Rules:
- MUST be side-effect free.
- MUST NOT call HTTP.
- MUST NOT call the API.
- Input: API Transport DTO
- Input: `Result<ApiDto, string>` or `ApiDto`
- Output: ViewData
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
@@ -140,6 +140,26 @@ Canonical placement in this repo:
- `apps/website/lib/builders/view-data/**`
### 4.3 Result Type
Definition: Type-safe error handling for all operations.
Purpose: eliminate exceptions and provide explicit error paths.
Rules:
- All PageQueries return `Result<ApiDto, string>`
- All Mutations return `Result<void, string>`
- Use `ResultFactory.ok(value)` for success
- Use `ResultFactory.error(message)` for errors
- Never throw exceptions
See [`Result.ts`](apps/website/lib/contracts/Result.ts:1).
Canonical placement in this repo:
- `apps/website/lib/contracts/Result.ts`
### 4.3 Display Object
Definition: deterministic, reusable, UI-only formatting/mapping logic.
@@ -163,12 +183,14 @@ Canonical placement in this repo:
```text
RSC page.tsx
PageQuery
PageQuery.execute()
API client (infra)
API Transport DTO
Result<ApiDto, string>
ViewData Builder (lib/builders/view-data/)
ViewData
@@ -184,6 +206,8 @@ API client (useEffect)
API Transport DTO
Result<ApiDto, string>
ViewModel Builder (lib/builders/view-models/)
ViewModel (lib/view-models/)
@@ -206,68 +230,53 @@ Allowed:
- client submits intent (FormData, button action)
- server action performs UX validation
- **server action calls a service** (not API clients directly)
- service orchestrates API calls and business logic
- **server action calls a mutation** (not services directly)
- mutation orchestrates services for writes
**Server Actions must use Services:**
**Server Actions must use Mutations:**
```typescript
// ❌ WRONG - Direct API client usage
// ❌ WRONG - Direct service usage
'use server';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import { AdminService } from '@/lib/services/admin/AdminService';
export async function updateUserStatus(userId: string, status: string) {
const apiClient = new AdminApiClient(...);
await apiClient.updateUserStatus(userId, status); // ❌ Should use service
const service = new AdminService(...);
await service.updateUserStatus(userId, status); // ❌ Should use mutation
}
// ✅ CORRECT - Service usage
// ✅ CORRECT - Mutation 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 { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation';
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);
const mutation = new AdminUserMutation();
const result = await mutation.updateUserStatus(userId, status);
if (result.isErr()) {
console.error('updateUserStatus failed:', result.getError());
throw new Error('Failed to update user status');
}
revalidatePath('/admin/users');
}
```
**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
1. Server Action (thin wrapper) - handles framework concerns (revalidation)
2. Mutation (framework-agnostic) - creates infrastructure, calls service
3. Service (business logic) - orchestrates API calls
4. API Client (infrastructure) - makes HTTP requests
5. Result - type-safe error handling
**Rationale**:
- Services orchestrate API calls (can grow to multiple calls)
- Keeps server actions consistent with PageQueries
- Mutations are framework-agnostic (can be tested without Next.js)
- Consistent pattern with PageQueries
- Type-safe error handling with Result
- 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).
See [`MUTATIONS.md`](docs/architecture/website/MUTATIONS.md:1) and [`SERVICES.md`](docs/architecture/website/SERVICES.md:1).
## 7) Authorization (strict)
@@ -333,10 +342,11 @@ See [`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:1).
3. API Transport DTOs never reach Templates.
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.
6. All operations return `Result<T, E>` for type-safe error handling.
7. ViewData Builders transform API DTO → ViewData (RSC).
8. ViewModel Builders transform API DTO → ViewModel (Client).
9. Builders are pure and deterministic.
10. Server Actions are the only write entry point.
11. Server Actions must use Mutations (not Services directly).
12. Mutations orchestrate Services for writes.
13. Authorization always belongs to the API.

View File

@@ -39,14 +39,15 @@ 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);
const mutation = new AdminUserMutation();
const result = await mutation.updateUserStatus(userId, status);
if (result.isErr()) {
console.error('updateUserStatus failed:', result.getError());
throw new Error('Failed to update user status');
}
revalidatePath('/admin/users');
}
```
@@ -54,6 +55,8 @@ export async function updateUserStatus(userId: string, status: string) {
```typescript
// lib/mutations/admin/AdminUserMutation.ts
import { Result, ResultFactory } from '@/lib/contracts/Result';
export class AdminUserMutation {
private service: AdminService;
@@ -70,8 +73,13 @@ export class AdminUserMutation {
this.service = new AdminService(apiClient);
}
async updateUserStatus(userId: string, status: string): Promise<void> {
await this.service.updateUserStatus(userId, status);
async updateUserStatus(userId: string, status: string): Promise<Result<void, string>> {
try {
await this.service.updateUserStatus(userId, status);
return ResultFactory.ok(undefined);
} catch (error) {
return ResultFactory.error('UPDATE_USER_STATUS_FAILED');
}
}
}
```
@@ -82,11 +90,13 @@ export class AdminUserMutation {
2. **Mutation** (framework-agnostic) - creates infrastructure, calls service
3. **Service** (business logic) - orchestrates API calls
4. **API Client** (infrastructure) - makes HTTP requests
5. **Result** - type-safe error handling
### Rationale
- **Framework independence**: Mutations can be tested without Next.js
- **Consistency**: Mirrors PageQuery pattern for reads/writes
- **Type-safe errors**: Result pattern eliminates exceptions
- **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.)
@@ -99,7 +109,7 @@ export class AdminUserMutation {
| 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 |
| Returns | `Result<ApiDto, string>` | `Result<void, string>` |
| Revalidation | N/A | Server Action handles it |
### See Also