# 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 { 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> { 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 { async execute(): Promise> { // 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 ; } } return ; } ``` ## Architecture Flow ``` HTTP Response (HTTP status codes) ↓ API Client (catches HTTP errors, throws HttpError) ↓ Service (catches HttpError, returns Result) ↓ PageQuery (uses Service, returns Result) ↓ 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` 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) ↓ PageQuery (uses Service, returns Result) ↓ 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) ↓ Service (catches HttpError, returns Result) ↓ 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 { 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> { 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 { constructor(private userService: UserService) {} async execute(input: UpdateUserStatusInput): Promise> { 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(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 (
{error &&
{error}
} {success &&
User updated!
}
); } ``` ## 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` | `Result` | | **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` 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.