website refactor
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
# Display Objects
|
# Displays
|
||||||
|
|
||||||
## Definition
|
## 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
|
- class-based
|
||||||
- immutable
|
- immutable
|
||||||
@@ -15,14 +15,20 @@ It answers the question:
|
|||||||
|
|
||||||
> “How should this specific piece of information be shown?”
|
> “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.
|
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
|
## Responsibilities
|
||||||
|
|
||||||
A Display Object MAY:
|
A Display MAY:
|
||||||
|
|
||||||
- format values (money, dates, durations)
|
- format values (money, dates, durations)
|
||||||
- handle localization only when localization inputs are deterministic (for example: mapping stable codes to stable labels)
|
- 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
|
- encapsulate UI display conventions
|
||||||
- be reused across multiple View Models
|
- be reused across multiple View Models
|
||||||
|
|
||||||
In addition, a Display Object MAY:
|
In addition, a Display MAY:
|
||||||
|
|
||||||
- normalize presentation inputs (for example trimming/casing)
|
- normalize presentation inputs (for example trimming/casing)
|
||||||
- expose multiple explicit display variants (for example `shortLabel`, `longLabel`)
|
- expose multiple explicit display variants (for example `shortLabel`, `longLabel`)
|
||||||
|
|
||||||
A Display Object MUST:
|
A Display MUST:
|
||||||
|
|
||||||
- be deterministic
|
- be deterministic
|
||||||
- be side-effect free
|
- be side-effect free
|
||||||
- operate only on presentation data
|
- operate only on presentation data
|
||||||
|
|
||||||
A Display Object MUST:
|
A Display MUST:
|
||||||
|
|
||||||
- be implemented as a **class** with a small, explicit API
|
- be implemented as a **class** with a small, explicit API
|
||||||
- accept only primitives/plain data in its constructor (or static factory)
|
- accept only primitives/plain data in its constructor (or static factory)
|
||||||
@@ -51,7 +57,7 @@ A Display Object MUST:
|
|||||||
|
|
||||||
## Restrictions
|
## Restrictions
|
||||||
|
|
||||||
A Display Object MUST NOT:
|
A Display MUST NOT:
|
||||||
|
|
||||||
- contain business logic
|
- contain business logic
|
||||||
- enforce domain invariants
|
- enforce domain invariants
|
||||||
@@ -60,7 +66,7 @@ A Display Object MUST NOT:
|
|||||||
- be sent back to the server
|
- be sent back to the server
|
||||||
- depend on backend or infrastructure concerns
|
- depend on backend or infrastructure concerns
|
||||||
|
|
||||||
In this repository, a Display Object MUST NOT:
|
In this repository, a Display MUST NOT:
|
||||||
|
|
||||||
- call `Intl.*`
|
- call `Intl.*`
|
||||||
- call `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()`
|
- call `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()`
|
||||||
@@ -82,41 +88,42 @@ Forbidden approaches:
|
|||||||
- any usage of `toLocale*`
|
- any usage of `toLocale*`
|
||||||
|
|
||||||
If a rule affects system correctness or persistence,
|
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
|
## Ownership & Placement
|
||||||
|
|
||||||
- Display Objects belong to the **presentation layer**
|
- Displays belong to the **presentation layer**
|
||||||
- They are frontend-only
|
- They are frontend-only
|
||||||
- They are not shared with the backend or core
|
- They are not shared with the backend or core
|
||||||
|
|
||||||
Placement rule (strict):
|
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
|
## Relationship to View Models
|
||||||
|
|
||||||
- View Models MAY use Display Objects
|
- View Models MAY use Displays
|
||||||
- Display Objects MUST NOT depend on View Models
|
- Displays MUST NOT depend on View Models
|
||||||
- Display Objects represent **parts**
|
- Displays represent **parts**
|
||||||
- View Models represent **screens**
|
- View Models represent **screens**
|
||||||
|
|
||||||
Additional strict rules:
|
Additional strict rules:
|
||||||
|
|
||||||
- View Models SHOULD compose Display Objects.
|
- View Models SHOULD compose Displays.
|
||||||
- Display Objects MUST NOT be serialized or passed across boundaries.
|
- Displays MUST NOT be serialized or passed across boundaries.
|
||||||
- They must not appear in server-to-client DTOs.
|
- 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
|
## Testing
|
||||||
|
|
||||||
Display Objects SHOULD be tested because they often contain:
|
Displays SHOULD be tested because they often contain:
|
||||||
|
|
||||||
- locale-specific behavior
|
- locale-specific behavior
|
||||||
- formatting rules
|
- formatting rules
|
||||||
@@ -131,9 +138,9 @@ Additionally:
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- Display Objects encapsulate **how something looks**
|
- Displays encapsulate **how something looks**
|
||||||
- View Models encapsulate **what a screen needs**
|
- View Models encapsulate **what a screen needs**
|
||||||
- Both are presentation concerns
|
- Both are presentation concerns
|
||||||
- Neither contains business truth
|
- 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 { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
export async function updateUserStatus(userId: string, status: string): Promise<void> {
|
export async function updateUserStatus(userId: string, status: string) {
|
||||||
try {
|
const mutation = new AdminUserMutation();
|
||||||
const mutation = new AdminUserMutation();
|
const result = await mutation.updateUserStatus(userId, status);
|
||||||
await mutation.updateUserStatus(userId, status);
|
|
||||||
revalidatePath('/admin/users');
|
if (result.isErr()) {
|
||||||
} catch (error) {
|
console.error('updateUserStatus failed:', result.getError());
|
||||||
console.error('updateUserStatus failed:', error);
|
|
||||||
throw new Error('Failed to update user status');
|
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
|
```typescript
|
||||||
// lib/mutations/admin/AdminUserMutation.ts
|
// lib/mutations/admin/AdminUserMutation.ts
|
||||||
|
import { Result, ResultFactory } from '@/lib/contracts/Result';
|
||||||
|
|
||||||
export class AdminUserMutation {
|
export class AdminUserMutation {
|
||||||
private service: AdminService;
|
private service: AdminService;
|
||||||
|
|
||||||
@@ -53,12 +56,22 @@ export class AdminUserMutation {
|
|||||||
this.service = new AdminService(apiClient);
|
this.service = new AdminService(apiClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUserStatus(userId: string, status: string): Promise<void> {
|
async updateUserStatus(userId: string, status: string): Promise<Result<void, string>> {
|
||||||
await this.service.updateUserStatus(userId, status);
|
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> {
|
async deleteUser(userId: string): Promise<Result<void, string>> {
|
||||||
await this.service.deleteUser(userId);
|
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/` |
|
| Location | `lib/page-queries/` | `lib/mutations/` |
|
||||||
| Framework | Called from RSC | Called from Server Actions |
|
| Framework | Called from RSC | Called from Server Actions |
|
||||||
| Infrastructure | Manual DI | Manual DI |
|
| 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 |
|
| Revalidation | N/A | Server Action handles it |
|
||||||
|
|
||||||
## 9) Example Flow
|
## 9) Example Flow
|
||||||
|
|
||||||
**Read:**
|
**Read (RSC):**
|
||||||
```
|
```
|
||||||
RSC page.tsx
|
RSC page.tsx
|
||||||
↓
|
↓
|
||||||
@@ -123,10 +136,14 @@ Service
|
|||||||
↓
|
↓
|
||||||
API Client
|
API Client
|
||||||
↓
|
↓
|
||||||
Page DTO
|
Result<ApiDto, string>
|
||||||
|
↓
|
||||||
|
ViewDataBuilder
|
||||||
|
↓
|
||||||
|
Template
|
||||||
```
|
```
|
||||||
|
|
||||||
**Write:**
|
**Write (Server Action):**
|
||||||
```
|
```
|
||||||
Client Component
|
Client Component
|
||||||
↓
|
↓
|
||||||
@@ -138,6 +155,8 @@ Service
|
|||||||
↓
|
↓
|
||||||
API Client
|
API Client
|
||||||
↓
|
↓
|
||||||
|
Result<void, string>
|
||||||
|
↓
|
||||||
Revalidation
|
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 be side-effect free.
|
||||||
- MUST NOT call HTTP.
|
- MUST NOT call HTTP.
|
||||||
- MUST NOT call the API.
|
- MUST NOT call the API.
|
||||||
- Input: API Transport DTO
|
- Input: `Result<ApiDto, string>` or `ApiDto`
|
||||||
- Output: ViewModel
|
- Output: ViewModel
|
||||||
|
|
||||||
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
|
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
|
||||||
@@ -131,7 +131,7 @@ Rules:
|
|||||||
- MUST be side-effect free.
|
- MUST be side-effect free.
|
||||||
- MUST NOT call HTTP.
|
- MUST NOT call HTTP.
|
||||||
- MUST NOT call the API.
|
- MUST NOT call the API.
|
||||||
- Input: API Transport DTO
|
- Input: `Result<ApiDto, string>` or `ApiDto`
|
||||||
- Output: ViewData
|
- Output: ViewData
|
||||||
|
|
||||||
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
|
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
|
||||||
@@ -140,6 +140,26 @@ Canonical placement in this repo:
|
|||||||
|
|
||||||
- `apps/website/lib/builders/view-data/**`
|
- `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
|
### 4.3 Display Object
|
||||||
|
|
||||||
Definition: deterministic, reusable, UI-only formatting/mapping logic.
|
Definition: deterministic, reusable, UI-only formatting/mapping logic.
|
||||||
@@ -163,12 +183,14 @@ Canonical placement in this repo:
|
|||||||
```text
|
```text
|
||||||
RSC page.tsx
|
RSC page.tsx
|
||||||
↓
|
↓
|
||||||
PageQuery
|
PageQuery.execute()
|
||||||
↓
|
↓
|
||||||
API client (infra)
|
API client (infra)
|
||||||
↓
|
↓
|
||||||
API Transport DTO
|
API Transport DTO
|
||||||
↓
|
↓
|
||||||
|
Result<ApiDto, string>
|
||||||
|
↓
|
||||||
ViewData Builder (lib/builders/view-data/)
|
ViewData Builder (lib/builders/view-data/)
|
||||||
↓
|
↓
|
||||||
ViewData
|
ViewData
|
||||||
@@ -184,6 +206,8 @@ API client (useEffect)
|
|||||||
↓
|
↓
|
||||||
API Transport DTO
|
API Transport DTO
|
||||||
↓
|
↓
|
||||||
|
Result<ApiDto, string>
|
||||||
|
↓
|
||||||
ViewModel Builder (lib/builders/view-models/)
|
ViewModel Builder (lib/builders/view-models/)
|
||||||
↓
|
↓
|
||||||
ViewModel (lib/view-models/)
|
ViewModel (lib/view-models/)
|
||||||
@@ -206,68 +230,53 @@ Allowed:
|
|||||||
|
|
||||||
- client submits intent (FormData, button action)
|
- client submits intent (FormData, button action)
|
||||||
- server action performs UX validation
|
- server action performs UX validation
|
||||||
- **server action calls a service** (not API clients directly)
|
- **server action calls a mutation** (not services directly)
|
||||||
- service orchestrates API calls and business logic
|
- mutation orchestrates services for writes
|
||||||
|
|
||||||
**Server Actions must use Services:**
|
**Server Actions must use Mutations:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ WRONG - Direct API client usage
|
// ❌ WRONG - Direct service usage
|
||||||
'use server';
|
'use server';
|
||||||
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
|
import { AdminService } from '@/lib/services/admin/AdminService';
|
||||||
|
|
||||||
export async function updateUserStatus(userId: string, status: string) {
|
export async function updateUserStatus(userId: string, status: string) {
|
||||||
const apiClient = new AdminApiClient(...);
|
const service = new AdminService(...);
|
||||||
await apiClient.updateUserStatus(userId, status); // ❌ Should use service
|
await service.updateUserStatus(userId, status); // ❌ Should use mutation
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ CORRECT - Service usage
|
// ✅ CORRECT - Mutation usage
|
||||||
'use server';
|
'use server';
|
||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
import { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation';
|
||||||
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';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
export async function updateUserStatus(userId: string, status: string) {
|
export async function updateUserStatus(userId: string, status: string) {
|
||||||
try {
|
const mutation = new AdminUserMutation();
|
||||||
// Create infrastructure
|
const result = await mutation.updateUserStatus(userId, status);
|
||||||
const logger = new ConsoleLogger();
|
|
||||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
if (result.isErr()) {
|
||||||
showUserNotifications: true,
|
console.error('updateUserStatus failed:', result.getError());
|
||||||
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');
|
throw new Error('Failed to update user status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/users');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pattern**:
|
**Pattern**:
|
||||||
1. Server action creates infrastructure (logger, errorReporter, apiClient)
|
1. Server Action (thin wrapper) - handles framework concerns (revalidation)
|
||||||
2. Server action creates service with infrastructure
|
2. Mutation (framework-agnostic) - creates infrastructure, calls service
|
||||||
3. Server action calls service method
|
3. Service (business logic) - orchestrates API calls
|
||||||
4. Server action handles revalidation and returns
|
4. API Client (infrastructure) - makes HTTP requests
|
||||||
|
5. Result - type-safe error handling
|
||||||
|
|
||||||
**Rationale**:
|
**Rationale**:
|
||||||
- Services orchestrate API calls (can grow to multiple calls)
|
- Mutations are framework-agnostic (can be tested without Next.js)
|
||||||
- Keeps server actions consistent with PageQueries
|
- Consistent pattern with PageQueries
|
||||||
|
- Type-safe error handling with Result
|
||||||
- Makes infrastructure explicit and testable
|
- 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)
|
## 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.
|
3. API Transport DTOs never reach Templates.
|
||||||
4. Templates accept ViewData only.
|
4. Templates accept ViewData only.
|
||||||
5. Page Queries do not format; they only compose.
|
5. Page Queries do not format; they only compose.
|
||||||
6. ViewData Builders transform API DTO → ViewData (RSC).
|
6. All operations return `Result<T, E>` for type-safe error handling.
|
||||||
7. ViewModel Builders transform API DTO → ViewModel (Client).
|
7. ViewData Builders transform API DTO → ViewData (RSC).
|
||||||
8. Builders are pure and deterministic.
|
8. ViewModel Builders transform API DTO → ViewModel (Client).
|
||||||
9. Server Actions are the only write entry point.
|
9. Builders are pure and deterministic.
|
||||||
10. Server Actions must use Mutations (not Services directly).
|
10. Server Actions are the only write entry point.
|
||||||
11. Mutations orchestrate Services for writes.
|
11. Server Actions must use Mutations (not Services directly).
|
||||||
12. Authorization always belongs to the API.
|
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';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
export async function updateUserStatus(userId: string, status: string) {
|
export async function updateUserStatus(userId: string, status: string) {
|
||||||
try {
|
const mutation = new AdminUserMutation();
|
||||||
const mutation = new AdminUserMutation();
|
const result = await mutation.updateUserStatus(userId, status);
|
||||||
await mutation.updateUserStatus(userId, status);
|
|
||||||
revalidatePath('/admin/users');
|
if (result.isErr()) {
|
||||||
} catch (error) {
|
console.error('updateUserStatus failed:', result.getError());
|
||||||
console.error('updateUserStatus failed:', error);
|
|
||||||
throw new Error('Failed to update user status');
|
throw new Error('Failed to update user status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/users');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -54,6 +55,8 @@ export async function updateUserStatus(userId: string, status: string) {
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// lib/mutations/admin/AdminUserMutation.ts
|
// lib/mutations/admin/AdminUserMutation.ts
|
||||||
|
import { Result, ResultFactory } from '@/lib/contracts/Result';
|
||||||
|
|
||||||
export class AdminUserMutation {
|
export class AdminUserMutation {
|
||||||
private service: AdminService;
|
private service: AdminService;
|
||||||
|
|
||||||
@@ -70,8 +73,13 @@ export class AdminUserMutation {
|
|||||||
this.service = new AdminService(apiClient);
|
this.service = new AdminService(apiClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUserStatus(userId: string, status: string): Promise<void> {
|
async updateUserStatus(userId: string, status: string): Promise<Result<void, string>> {
|
||||||
await this.service.updateUserStatus(userId, status);
|
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
|
2. **Mutation** (framework-agnostic) - creates infrastructure, calls service
|
||||||
3. **Service** (business logic) - orchestrates API calls
|
3. **Service** (business logic) - orchestrates API calls
|
||||||
4. **API Client** (infrastructure) - makes HTTP requests
|
4. **API Client** (infrastructure) - makes HTTP requests
|
||||||
|
5. **Result** - type-safe error handling
|
||||||
|
|
||||||
### Rationale
|
### Rationale
|
||||||
|
|
||||||
- **Framework independence**: Mutations can be tested without Next.js
|
- **Framework independence**: Mutations can be tested without Next.js
|
||||||
- **Consistency**: Mirrors PageQuery pattern for reads/writes
|
- **Consistency**: Mirrors PageQuery pattern for reads/writes
|
||||||
|
- **Type-safe errors**: Result pattern eliminates exceptions
|
||||||
- **Migration ease**: Can switch frameworks without rewriting business logic
|
- **Migration ease**: Can switch frameworks without rewriting business logic
|
||||||
- **Testability**: Can unit test mutations in isolation
|
- **Testability**: Can unit test mutations in isolation
|
||||||
- **Reusability**: Can be called from other contexts (cron jobs, etc.)
|
- **Reusability**: Can be called from other contexts (cron jobs, etc.)
|
||||||
@@ -99,7 +109,7 @@ export class AdminUserMutation {
|
|||||||
| Location | `lib/page-queries/` | `lib/mutations/` |
|
| Location | `lib/page-queries/` | `lib/mutations/` |
|
||||||
| Framework | Called from RSC | Called from Server Actions |
|
| Framework | Called from RSC | Called from Server Actions |
|
||||||
| Infrastructure | Manual DI | Manual DI |
|
| 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 |
|
| Revalidation | N/A | Server Action handles it |
|
||||||
|
|
||||||
### See Also
|
### See Also
|
||||||
|
|||||||
Reference in New Issue
Block a user