Files
gridpilot.gg/docs/architecture/website/PAGE_QUERY_ERROR_HANDLING.md
2026-01-13 01:36:27 +01:00

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

  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)

// 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

  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.