6.9 KiB
ADR-001: Standardized Error Handling for Service and Repository Layers
Date: 2025-12-12
Status: Accepted
Implemented: 2026-01-07
Context
Our application has experienced a recurring pattern of bugs and brittle tests related to error handling, specifically for "resource not found" scenarios. The root causes identified are:
- Inconsistent Return Types: Database repository methods that fetch a single entity (e.g.,
getUserById,getRecipeById) had inconsistent behavior when an entity was not found. Some returnedundefined, some returnednull, and others threw a genericError. - Burden on Callers: This inconsistency forced route handlers (the callers) to implement defensive checks for
undefinedornullbefore sending a response. These checks were often forgotten or implemented incorrectly. - Incorrect HTTP Status Codes: When a route handler forgot to check for an
undefinedresult and passed it tores.json(), the Express framework would interpret this as a server-side failure, resulting in an incorrect500 Internal Server Errorinstead of the correct404 Not Found. - Brittle Tests: Unit and integration tests for routes were unreliable. Mocks often threw a generic
new Error()when the actual implementation returnedundefinedor a specific custom error, leading to unexpected500status codes in test environments.
This pattern led to increased development friction, difficult-to-diagnose bugs, and a fragile test suite.
Decision
We will adopt a strict, consistent error-handling contract for the service and repository layers.
-
Always Throw on Not Found: Any function or method responsible for fetching a single, specific resource (e.g., by ID, checksum, or other unique identifier) MUST throw a
NotFoundErrorif that resource does not exist. It MUST NOT returnnullorundefinedto signify absence. -
Use Specific, Custom Errors: For other known, predictable failure modes (e.g., unique constraint violations, foreign key violations), the repository layer MUST throw the corresponding custom
DatabaseErrorsubclass (e.g.,UniqueConstraintError,ForeignKeyConstraintError). -
Centralize HTTP Status Mapping: The
errorHandlermiddleware is the single source of truth for mapping these specific error types to their corresponding HTTP status codes (e.g.,NotFoundError-> 404,UniqueConstraintError-> 409). -
Simplify Route Handlers: Route handlers should be simplified to use a standard
try...catchblock. All errors caught from the service/repository layer should be passed directly tonext(error), relying on theerrorHandlermiddleware to format the final response. No specialif (result === undefined)checks are needed.
Consequences
Positive
Robustness: Eliminates an entire class of bugs where undefined is passed to res.json(), preventing incorrect 500 errors.
Consistency & Predictability: All data-fetching methods now have a predictable contract. They either return the expected data or throw a specific, typed error.
Developer Experience: Route handlers become simpler, cleaner, and easier to write correctly. The cognitive load on developers is reduced as they no longer need to remember to check for undefined.
Improved Testability: Tests become more reliable and realistic. Mocks can now throw the exact error type (new NotFoundError()) that the real implementation would, ensuring tests accurately reflect the application's behavior.
Centralized Control: Error-to-HTTP-status logic is centralized in the errorHandler middleware, making it easy to manage and modify error responses globally.
Negative
Initial Refactoring: Requires a one-time effort to audit and refactor all existing repository methods to conform to this new standard. Convention Adherence: Developers must be aware of and adhere to this convention. This ADR serves as the primary documentation for this pattern.
Implementation Details
Custom Error Types
All custom errors are defined in src/services/db/errors.db.ts:
| Error Class | HTTP Status | PostgreSQL Code | Use Case |
|---|---|---|---|
NotFoundError |
404 | - | Resource not found |
UniqueConstraintError |
409 | 23505 | Duplicate key violation |
ForeignKeyConstraintError |
400 | 23503 | Referenced record doesn't exist |
NotNullConstraintError |
400 | 23502 | Required field is null |
CheckConstraintError |
400 | 23514 | Check constraint violated |
InvalidTextRepresentationError |
400 | 22P02 | Invalid data type format |
NumericValueOutOfRangeError |
400 | 22003 | Numeric overflow |
ValidationError |
400 | - | Request validation failed |
ForbiddenError |
403 | - | Access denied |
Error Handler Middleware
The centralized error handler in src/middleware/errorHandler.ts:
- Catches all errors from route handlers
- Maps custom error types to HTTP status codes
- Logs errors with appropriate severity (warn for 4xx, error for 5xx)
- Returns consistent JSON error responses
- Includes error ID for server errors (for support correlation)
Usage Pattern
// In repository (throws NotFoundError)
async function getUserById(id: number): Promise<User> {
const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
if (result.rows.length === 0) {
throw new NotFoundError(`User with ID ${id} not found.`);
}
return result.rows[0];
}
// In route handler (simple try/catch)
router.get('/:id', async (req, res, next) => {
try {
const user = await getUserById(req.params.id);
res.json(user);
} catch (error) {
next(error); // errorHandler maps NotFoundError to 404
}
});
Centralized Error Handler Helper
The handleDbError function in src/services/db/errors.db.ts provides centralized PostgreSQL error handling:
import { handleDbError } from './errors.db';
try {
await pool.query('INSERT INTO users (email) VALUES ($1)', [email]);
} catch (error) {
handleDbError(
error,
logger,
'Failed to create user',
{ email },
{
uniqueMessage: 'A user with this email already exists.',
defaultMessage: 'Failed to create user.',
},
);
}
Key Files
src/services/db/errors.db.ts- Custom error classes andhandleDbErrorutilitysrc/middleware/errorHandler.ts- Centralized Express error handling middleware
Related ADRs
- ADR-034 - Repository Pattern Standards (extends this ADR)