Error Handling
Exception Hierarchy
The @modularityjs/exception package provides a typed exception hierarchy. Every exception extends FrameworkException:
class FrameworkException extends Error {
readonly code: string;
readonly context: Record<string, unknown>;
}Built-in Exceptions
| Exception | Code | HTTP Status | Use case |
|---|---|---|---|
AuthenticationException | AUTHENTICATION_REQUIRED | 401 | Missing or invalid credentials |
AuthorizationException | ACCESS_DENIED | 403 | Insufficient permissions |
NotFoundException | NOT_FOUND | 404 | Entity or resource not found |
ConflictException | CONFLICT | 409 | Duplicate entity or state conflict |
StateException | INVALID_STATE | 409 | Invalid operation for current state |
RateLimitExceededException | RATE_LIMIT_EXCEEDED | 429 | Too many requests |
ValidationException | VALIDATION_FAILED | 422 | Input validation failure |
ServiceUnavailableException | SERVICE_UNAVAILABLE | 503 | External service down |
Usage
import {
ConflictException,
NotFoundException,
ValidationException,
} from '@modularityjs/exception';
// Simple throw
throw new NotFoundException('User not found', { userId: '123' });
// Factory helpers
throw NotFoundException.forEntity('User', { id: '123' });
throw ConflictException.alreadyExists('User', { email: 'alice@example.com' });
// Validation errors (multiple)
throw new ValidationException([
{ field: 'email', message: 'Email is required', code: 'REQUIRED' },
{ field: 'age', message: 'Must be at least 18', code: 'MIN_VALUE' },
]);
// Validation helpers
throw ValidationException.requiredField('email');
throw ValidationException.invalidField('age', 'Must be at least 18');HTTP Error Handling
FrameworkExceptionFilter
The Fastify driver ships with FrameworkExceptionFilter and applies it implicitly: any FrameworkException that escapes a handler (and isn't caught by a user-registered filter) is converted to a structured JSON response. No registration required — user-declared @UseErrorFilter filters take precedence when their @CatchError matches.
A controller throwing a FrameworkException produces a JSON response:
{
"code": "NOT_FOUND",
"message": "User not found",
"context": { "userId": "123" }
}For ValidationException, the response includes the errors array:
{
"code": "VALIDATION_FAILED",
"message": "Validation failed: 2 error(s)",
"errors": [
{ "field": "email", "message": "Email is required", "code": "REQUIRED" },
{ "field": "age", "message": "Must be at least 18", "code": "MIN_VALUE" }
]
}Custom Error Filters
Create custom error filters with @CatchError(). A filter receives the error plus the inbound request and returns an ErrorResponse value (status, body, headers, cookies); the framework adapter applies it to the reply. Filters are pure — they decide what the response is, not how to write it.
import {
CatchError,
type ErrorResponse,
type HttpRequest,
} from '@modularityjs/http';
@CatchError({ error: PaymentError })
class PaymentErrorFilter {
catch(error: PaymentError, _request: HttpRequest): ErrorResponse {
return {
status: 402,
body: { code: 'PAYMENT_REQUIRED', message: error.message },
};
}
}ErrorResponse lets you set headers and cookies too — useful for redirects:
@CatchError({ error: AuthenticationException })
class RedirectToLoginFilter {
catch(): ErrorResponse {
return { status: 303, headers: { Location: '/login' } };
}
}Async filters return Promise<ErrorResponse> — the adapter awaits before finalizing.
Apply filters at the controller or method level with @UseErrorFilter():
@Injectable()
@Controller('/payments')
@UseErrorFilter(PaymentErrorFilter)
class PaymentController {
@Post('/charge')
@UseErrorFilter(StripeErrorFilter) // method-level filter runs first
async charge(@Body() body: ChargeRequest) {
// ...
}
}Filter resolution order: method-level filters are checked first, then controller-level filters. The first filter whose @CatchError({ error }) type matches the thrown error (via instanceof) handles it.
Related: middleware, interceptors, guards
An error filter is one of several wrap points on the HTTP request pipeline. Interceptors can also observe errors (a thrown handler propagates out through await next()), but transforming a thrown error into a response is the filter's job — interceptors are for the success path. Middleware runs before guards/auth/handler and can't see handler exceptions. For the full comparison of when to reach for each, see HTTP — Middleware vs. Interceptor vs. Plugin.