ADR 009 API Error Handling Strategy - steilerDev/cornerstone GitHub Wiki

ADR-009: API Error Handling Strategy

Status

Accepted

Context

Cornerstone needs a consistent, type-safe error handling strategy across the entire API surface. Without a centralized approach, each route handler would format errors independently, leading to inconsistent response shapes, duplicated logic, and potential information leakage in production (e.g., stack traces or internal error messages exposed to clients).

Key requirements:

  1. Consistent response shape: Every error response must conform to the ApiErrorResponse interface ({ error: { code, message, details? } }).
  2. Type safety: Error codes must be validated at compile time so that typos or unknown codes are caught before runtime.
  3. AJV integration: Fastify's built-in JSON schema validation (AJV) produces its own error format that must be normalized into the standard shape.
  4. Production safety: Internal error messages and stack traces must not leak to clients in production mode.
  5. Structured logging: Errors must be logged at appropriate severity levels for observability.

Alternatives Considered

A. Ad-hoc error handling per route -- Each route handler catches errors and formats responses individually. Simple initially but leads to inconsistency and duplication as the API grows.

B. Error middleware with plain string codes -- A centralized handler that uses string error codes without type constraints. Provides consistency but no compile-time safety -- a misspelled code like "NOT_FOUD" would only be caught at runtime (or never).

C. Typed error classes with a Fastify error handler plugin (chosen) -- A shared ErrorCode string literal union in @cornerstone/shared, domain-specific error subclasses in the server, and a Fastify plugin that registers a global setErrorHandler. Provides consistency, type safety, and clean separation of concerns.

Decision

We adopt approach C: typed error classes with a centralized Fastify error handler plugin.

Shared Error Code Type

A string literal union type ErrorCode is defined in @cornerstone/shared (shared/src/types/errors.ts), exported via the package barrel:

export type ErrorCode =
  | 'NOT_FOUND'
  | 'ROUTE_NOT_FOUND'
  | 'VALIDATION_ERROR'
  | 'UNAUTHORIZED'
  | 'FORBIDDEN'
  | 'CONFLICT'
  | 'INTERNAL_ERROR';

The ApiError interface's code field is typed as ErrorCode (not string), ensuring compile-time validation everywhere the type is used.

We chose a string literal union over a TypeScript enum because:

  • Unions produce no runtime JavaScript; enums generate a reverse-mapping object
  • Unions are more idiomatic in modern TypeScript
  • Unions compose better with other type operations (mapped types, conditional types)

Server Error Classes

A base AppError class and domain-specific subclasses live in server/src/errors/AppError.ts:

Class Error Code HTTP Status Default Message
AppError (any ErrorCode) (any) (required)
NotFoundError NOT_FOUND 404 "Resource not found"
ValidationError VALIDATION_ERROR 400 "Validation failed"
UnauthorizedError UNAUTHORIZED 401 "Unauthorized"
ForbiddenError FORBIDDEN 403 "Forbidden"
ConflictError CONFLICT 409 "Resource conflict"

Route handlers and services throw these errors; they never format ApiErrorResponse directly.

Fastify Error Handler Plugin

A Fastify plugin (server/src/plugins/errorHandler.ts) registered with fastify-plugin (for global scope) sets a single setErrorHandler that handles three categories:

  1. AppError instances -- Mapped directly to ApiErrorResponse using the error's code, statusCode, message, and optional details.
  2. AJV validation errors -- Detected via the error.validation property (set by Fastify's schema validation). Mapped to VALIDATION_ERROR (400) with field-level details: each validation failure includes path, message, and params.
  3. Unknown errors -- Logged at error level and returned as INTERNAL_ERROR (500). In production (NODE_ENV=production), the message is sanitized to "An internal error occurred" to prevent information leakage. In development, the original error message is preserved for debugging.

Logging Convention

  • 4xx errors (client errors): logged at warn level via request.log.warn
  • 5xx errors (server errors): logged at error level via request.log.error

This ensures client mistakes do not pollute error-level logs while genuine server failures are surfaced for alerting.

Plugin Registration Order

The error handler plugin declares a dependency on the config plugin (to access nodeEnv for production-mode detection) and is registered after config but before routes:

config -> errorHandler -> db -> routes

Consequences

Easier

  • Adding new endpoints: Route handlers throw NotFoundError, ValidationError, etc. and the centralized handler formats the response. No boilerplate error formatting in routes.
  • Maintaining consistency: All error responses are guaranteed to conform to ApiErrorResponse by construction.
  • Compile-time safety: Misspelled or unknown error codes are caught by TypeScript. Adding a new error code requires updating the ErrorCode union, which surfaces all places that need updating.
  • Client error handling: Frontend code can rely on a single, predictable error shape for all API calls.

More Difficult

  • Adding new error codes: Requires updating the ErrorCode union in @cornerstone/shared and rebuilding the shared package. This is intentional friction -- new error codes should be deliberate decisions.
  • Custom status codes: To use an HTTP status not covered by a subclass, developers must use the base AppError directly with the desired code and status.

Amendment (#2129, #2131, #2132, 2026-10-01): codes are the client contract

Decision

  1. Clients translate by code; message is never shown in the UI. message is untranslated English developer text, kept for logs and non-UI API consumers. The client maps code to copy through translateApiError() and errors.json, or through a page-specific t() key where a generic code (typically CONFLICT) has a page-specific meaning. In client/src, an ESLint no-restricted-syntax rule rejects reading .error.message from an API error body, including destructuring message from it. ApiClientError#message holds the same server text and is out of the rule's reach, so review enforces that one.
  2. A specific reason is the top-level code, never details.code. A client cannot translate a reason it has to dig for. details carries data only, for example cycle or invoiceIds. The dependency and milestone-link services, which used to throw ConflictError with the reason in details.code or with no reason at all, now throw DuplicateDependencyError / CircularDependencyError (both 409).
  3. The union is derived from a runtime tuple. ErrorCode is now (typeof ERROR_CODES)[number], where ERROR_CODES is an as const tuple exported from @cornerstone/shared. This reverses the "unions produce no runtime JavaScript" point above for this one type: the tuple lets a client test fail when any code lacks an errors.json key in any locale, which a type-only union cannot do. It is the project-wide union convention in CLAUDE.md (Internationalization > Union-derived keys).
  4. A code with no emitting path is deleted, not kept as Reserved. OIDC_ERROR, MUTUALLY_EXCLUSIVE_BUDGET_LINK and BACKUP_NOT_CONFIGURED were removed on this basis, along with their AppError subclasses.

Consequences

  • Error copy is consistent and localized. A server wording change can no longer change UI text.
  • Server messages may stay terse and technical; they are no longer user-facing.
  • A page that needs more than the generic copy for a code needs its own key, or a more specific server code. The second is preferred when the meaning is the same across pages.
  • Adding a code now means adding it to ERROR_CODES and to errors.json in every locale; CI fails otherwise.

Deviation Log

Date Section Deviation Resolution
2026-10-01 Shared Error Code Type (code list) The original code block lists seven codes and a type-only union. The union has since grown to dozens of members and, with this amendment, is derived from the runtime ERROR_CODES tuple, so neither the list nor the "no runtime JavaScript" rationale is current. The block is kept as the original decision; the amendment records the tuple and why. The authoritative code list is the API Contract's Error Codes table. This table did not previously exist on this page and was created by this entry.