website refactor
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
481
docs/architecture/website/PAGE_QUERY_ERROR_HANDLING.md
Normal file
481
docs/architecture/website/PAGE_QUERY_ERROR_HANDLING.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user