129 lines
6.9 KiB
Markdown
129 lines
6.9 KiB
Markdown
# 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:
|
|
|
|
1. **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 returned `undefined`, some returned `null`, and others threw a generic `Error`.
|
|
2. **Burden on Callers**: This inconsistency forced route handlers (the callers) to implement defensive checks for `undefined` or `null` before sending a response. These checks were often forgotten or implemented incorrectly.
|
|
3. **Incorrect HTTP Status Codes**: When a route handler forgot to check for an `undefined` result and passed it to `res.json()`, the Express framework would interpret this as a server-side failure, resulting in an incorrect `500 Internal Server Error` instead of the correct `404 Not Found`.
|
|
4. **Brittle Tests**: Unit and integration tests for routes were unreliable. Mocks often threw a generic `new Error()` when the actual implementation returned `undefined` or a specific custom error, leading to unexpected `500` status 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.
|
|
|
|
1. **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 `NotFoundError` if that resource does not exist. It **MUST NOT** return `null` or `undefined` to signify absence.
|
|
|
|
2. **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 `DatabaseError` subclass (e.g., `UniqueConstraintError`, `ForeignKeyConstraintError`).
|
|
|
|
3. **Centralize HTTP Status Mapping**: The `errorHandler` middleware is the **single source of truth** for mapping these specific error types to their corresponding HTTP status codes (e.g., `NotFoundError` -> 404, `UniqueConstraintError` -> 409).
|
|
|
|
4. **Simplify Route Handlers**: Route handlers should be simplified to use a standard `try...catch` block. All errors caught from the service/repository layer should be passed directly to `next(error)`, relying on the `errorHandler` middleware to format the final response. No special `if (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`:
|
|
|
|
1. Catches all errors from route handlers
|
|
2. Maps custom error types to HTTP status codes
|
|
3. Logs errors with appropriate severity (warn for 4xx, error for 5xx)
|
|
4. Returns consistent JSON error responses
|
|
5. Includes error ID for server errors (for support correlation)
|
|
|
|
### Usage Pattern
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
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 and `handleDbError` utility
|
|
- `src/middleware/errorHandler.ts` - Centralized Express error handling middleware
|
|
|
|
## Related ADRs
|
|
|
|
- [ADR-034](./0034-repository-pattern-standards.md) - Repository Pattern Standards (extends this ADR)
|