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:
- Consistent response shape: Every error response must conform to the
ApiErrorResponseinterface ({ error: { code, message, details? } }). - Type safety: Error codes must be validated at compile time so that typos or unknown codes are caught before runtime.
- AJV integration: Fastify's built-in JSON schema validation (AJV) produces its own error format that must be normalized into the standard shape.
- Production safety: Internal error messages and stack traces must not leak to clients in production mode.
- 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:
AppErrorinstances -- Mapped directly toApiErrorResponseusing the error'scode,statusCode,message, and optionaldetails.- AJV validation errors -- Detected via the
error.validationproperty (set by Fastify's schema validation). Mapped toVALIDATION_ERROR(400) with field-level details: each validation failure includespath,message, andparams. - Unknown errors -- Logged at
errorlevel and returned asINTERNAL_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
warnlevel viarequest.log.warn - 5xx errors (server errors): logged at
errorlevel viarequest.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
ApiErrorResponseby construction. - Compile-time safety: Misspelled or unknown error codes are caught by TypeScript. Adding a new error code requires updating the
ErrorCodeunion, 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
ErrorCodeunion in@cornerstone/sharedand 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
AppErrordirectly with the desired code and status.
Amendment (#2129, #2131, #2132, 2026-10-01): codes are the client contract
Decision
- Clients translate by
code;messageis never shown in the UI.messageis untranslated English developer text, kept for logs and non-UI API consumers. The client mapscodeto copy throughtranslateApiError()anderrors.json, or through a page-specifict()key where a generic code (typicallyCONFLICT) has a page-specific meaning. Inclient/src, an ESLintno-restricted-syntaxrule rejects reading.error.messagefrom an API error body, including destructuringmessagefrom it.ApiClientError#messageholds the same server text and is out of the rule's reach, so review enforces that one. - A specific reason is the top-level
code, neverdetails.code. A client cannot translate a reason it has to dig for.detailscarries data only, for examplecycleorinvoiceIds. The dependency and milestone-link services, which used to throwConflictErrorwith the reason indetails.codeor with no reason at all, now throwDuplicateDependencyError/CircularDependencyError(both 409). - The union is derived from a runtime tuple.
ErrorCodeis now(typeof ERROR_CODES)[number], whereERROR_CODESis anas consttuple 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 anerrors.jsonkey in any locale, which a type-only union cannot do. It is the project-wide union convention in CLAUDE.md (Internationalization > Union-derived keys). - A code with no emitting path is deleted, not kept as Reserved.
OIDC_ERROR,MUTUALLY_EXCLUSIVE_BUDGET_LINKandBACKUP_NOT_CONFIGUREDwere removed on this basis, along with theirAppErrorsubclasses.
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_CODESand toerrors.jsonin 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. |