526 lines
17 KiB
Markdown
526 lines
17 KiB
Markdown
# 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 creates its own dependencies and converts HTTP errors to domain errors.
|
|
|
|
**See**: [DEPENDENCY_CONSTRUCTION.md](./DEPENDENCY_CONSTRUCTION.md) for why Services create their own dependencies.
|
|
|
|
```typescript
|
|
// apps/website/lib/services/dashboard/DashboardService.ts
|
|
import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient';
|
|
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
|
|
|
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 {
|
|
private apiClient: DashboardApiClient;
|
|
|
|
constructor() {
|
|
// Service creates its own dependencies
|
|
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
|
this.apiClient = new DashboardApiClient(
|
|
baseUrl,
|
|
new ConsoleErrorReporter(),
|
|
new ConsoleLogger()
|
|
);
|
|
}
|
|
|
|
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' });
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key Points:**
|
|
- ✅ Service creates its own API Client
|
|
- ✅ Service creates its own Logger and ErrorReporter
|
|
- ✅ Catches HTTP errors and converts to domain errors
|
|
- ✅ Returns Result type
|
|
|
|
### Layer 3: PageQuery (Domain → Presentation Errors)
|
|
|
|
PageQueries use Services and map domain errors to presentation errors.
|
|
|
|
**See**: [DEPENDENCY_CONSTRUCTION.md](./DEPENDENCY_CONSTRUCTION.md) for why we use manual construction.
|
|
|
|
```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 construction: Service creates its own dependencies
|
|
const dashboardService = new DashboardService();
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key Points:**
|
|
- ✅ PageQuery constructs only the Service
|
|
- ✅ Service handles its own dependencies (API Client, Logger, etc.)
|
|
- ❌ No API Client instantiation in PageQuery
|
|
- ✅ Map domain errors to presentation errors
|
|
- ✅ Transform API DTO to ViewData using Builder
|
|
|
|
### 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
|
|
import { UserApiClient } from '@/lib/api/user/UserApiClient';
|
|
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
|
|
|
export type UserServiceError =
|
|
| { type: 'notFound'; message: string }
|
|
| { type: 'forbidden'; message: string }
|
|
| { type: 'validation'; message: string }
|
|
| { type: 'serverError'; message: string };
|
|
|
|
export class UserService {
|
|
private apiClient: UserApiClient;
|
|
|
|
constructor() {
|
|
// Service creates its own dependencies
|
|
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
|
this.apiClient = new UserApiClient(
|
|
baseUrl,
|
|
new ConsoleErrorReporter(),
|
|
new ConsoleLogger()
|
|
);
|
|
}
|
|
|
|
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' });
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key Points:**
|
|
- ✅ Service creates its own API Client
|
|
- ✅ Service creates its own Logger and ErrorReporter
|
|
- ✅ Catches HTTP errors and converts to domain errors
|
|
- ✅ Returns Result type
|
|
|
|
### 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 { revalidatePath } from 'next/cache';
|
|
|
|
export async function updateUserStatus(input: UpdateUserStatusInput) {
|
|
// Manual construction: Mutation creates Service, Service creates dependencies
|
|
const mutation = new UpdateUserStatusMutation();
|
|
|
|
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 };
|
|
}
|
|
```
|
|
|
|
**Key Points:**
|
|
- ✅ Server Action constructs only the Mutation
|
|
- ✅ Mutation constructs the Service
|
|
- ✅ Service constructs its own dependencies
|
|
- ✅ No manual wiring needed
|
|
|
|
### 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.
|