15 KiB
15 KiB
PageQuery Error Handling Design (Option 1: Clean Architecture)
The Problem
The current DashboardPageQuery mixes HTTP concerns with domain logic:
// ❌ 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:
// 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:
// 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:
// 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:
// 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
- PageQueries use Services: Never call API Clients directly
- Services return Result: All service methods return
Result<T, DomainError> - Services handle HTTP errors: Convert HttpError → DomainError
- PageQueries handle presentation errors: Convert DomainError → PresentationError
- No HTTP knowledge in PageQueries: They don't know about status codes
Benefits
- Clean Architecture: Each layer has clear boundaries
- Type Safety: Errors are typed at each boundary
- Testability: Can mock services in PageQueries
- Flexibility: Can swap HTTP implementations
- 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)
// 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)
// 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)
// 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)
// 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)
// 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
- Update Services: Return
Result<T, DomainError>for both read and write - Update PageQueries: Use Services, map DomainError → PresentationError
- Update Mutations: Use Services, map DomainError → MutationError
- Remove ErrorWithStatusCode: Delete from all PageQueries
- Add ESLint Rules: Prevent status codes and direct API usage
ESLint Rules Needed
- No status codes in PageQueries: Prevent
statusCodechecks - No status codes in Mutations: Prevent
statusCodechecks - PageQueries must use Services: Prevent direct API client usage
- Mutations must use Services: Prevent direct API client usage
- Services must return Result: Enforce Result pattern
This complete architecture handles both read and write flows with clean separation of concerns.