Why domain code should not throw HttpException
A common NestJS pattern is for a service method to throw NotFoundException or ConflictException when a record is missing or a uniqueness rule fails. It works at first because the built-in exception layer turns those classes into the right status code with no extra setup. The cost appears later. The service now encodes an HTTP decision: it has decided that "order not found" means 404 and that the response body has a particular shape. That decision belongs to the transport layer, not the business rule.
The coupling causes problems as soon as the same service runs outside an HTTP request:
- Queue consumers receive an
HttpExceptionwith a status code that has no meaning there. Retry logic ends up inspectinggetStatus()to decide whether a failure is permanent. - CLI commands and scheduled jobs have to catch HTTP classes to print a useful message or set an exit code.
- Microservice transports expect their own exception types, such as
RpcException. An HTTP exception thrown there is not translated the way it is in a controller. - Unit tests assert on status codes instead of on the business outcome, so a test for a pricing rule fails when someone changes 422 to 400.
The alternative is to have domain code throw plain, transport-agnostic errors that describe what went wrong. Exception filters at the edge then map those errors to HTTP responses. The teams that gain the most are those running several transports or sharing domain modules across applications. If you have one HTTP API and no plans for a second entry point, the pattern mostly pays off by keeping tests and error responses consistent.
// orders/order.errors.ts: domain errors carry meaning, not status codes
export class OrderNotFoundError extends Error {
constructor(public readonly orderId: string) {
super(`Order ${orderId} not found`);
this.name = 'OrderNotFoundError';
}
}
// orders/orders.service.ts: no imports from @nestjs/common's HTTP exceptions
export class OrdersService {
constructor(private readonly repo: { findById(id: string): Promise<Order | null> }) {}
async getOrder(id: string): Promise<Order> {
const order = await this.repo.findById(id);
// Before: throw new NotFoundException(...) tied this rule to HTTP 404
if (!order) throw new OrderNotFoundError(id);
return order;
}
}
// orders/orders.service.spec.ts: the test asserts the business outcome
it('rejects unknown orders', async () => {
const service = new OrdersService({ findById: async () => null });
await expect(service.getOrder('ord_123')).rejects.toBeInstanceOf(OrderNotFoundError);
});
// A queue consumer can decide retry policy from the error type alone
async function handleJob(service: OrdersService, orderId: string) {
try {
await service.getOrder(orderId);
} catch (err) {
if (err instanceof OrderNotFoundError) return; // permanent: do not retry
throw err; // unknown failure: let the queue retry
}
}
type Order = { id: string; total: number };
A service throws a transport-agnostic OrderNotFoundError instead of NotFoundException. A unit test and a queue consumer can then act on the error type without knowing about HTTP status codes.
Designing a transport-agnostic domain error hierarchy
Start with a single base class, DomainError, that extends the built-in Error and carries three things: a code, a message and an optional details object. The code is a stable, machine-readable string such as ORDER_NOT_FOUND or INVENTORY_CONFLICT. The message is for humans and can change wording freely. details holds structured context a client can act on, such as which fields failed validation.
From that base, derive four subclasses that describe what went wrong in business terms, not which status code to send:
EntityNotFoundError: the requested aggregate does not exist or is not visible to the caller.ConflictError: the operation clashes with current state, such as a duplicate key or a stale version.ValidationFailedError: input violates a domain rule, with per-field reasons indetails.PermissionDeniedError: the actor is known but not allowed to perform this action.
None of these classes import anything from @nestjs/common. That keeps them usable in a queue consumer, a CLI or a gRPC handler.
Treat codes as a public API contract. Clients will branch on them, so renaming one is a breaking change that needs the same care as removing an endpoint field. Curate details with the same discipline. Include what the caller can fix, and leave out database IDs, SQL fragments, stack traces and other users' data.
Codes also matter for a mechanical reason. instanceof checks fail when two copies of a shared domain package end up in the dependency tree, or when an error crosses a serialization boundary. A string comparison on code still works in both cases.
// domain/errors.ts: no @nestjs/common imports, so these work in HTTP, queues, CLI or gRPC.
export type ErrorDetails = Readonly<Record<string, unknown>>;
export abstract class DomainError extends Error {
constructor(
// Stable, machine-readable code. Clients branch on it, so renaming it is a breaking change.
public readonly code: string,
message: string, // For humans; wording can change freely.
// Curated, caller-actionable context only: no DB ids, SQL, stack traces or other users' data.
public readonly details?: ErrorDetails,
) {
super(message);
this.name = new.target.name;
// Keeps the prototype chain correct when compiling to ES5 targets.
Object.setPrototypeOf(this, new.target.prototype);
}
}
// Subclasses describe what went wrong in business terms, not which HTTP status to send.
export class EntityNotFoundError extends DomainError {}
export class ConflictError extends DomainError {}
export class PermissionDeniedError extends DomainError {}
export class ValidationFailedError extends DomainError {
constructor(code: string, message: string, fields: Record<string, string>) {
super(code, message, { fields }); // Per-field reasons the caller can fix.
}
}
// Compare on code, not instanceof. This still works with duplicate package copies or after JSON round-trips.
export function hasErrorCode(err: unknown, code: string): boolean {
return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === code;
}
// Usage:
// throw new EntityNotFoundError('ORDER_NOT_FOUND', 'Order not found.');
// throw new ValidationFailedError('ORDER_INVALID', 'Order is invalid.', { quantity: 'must be at least 1' });
// if (hasErrorCode(err, 'INVENTORY_CONFLICT')) { /* retry with a fresh version */ }
This framework-free DomainError hierarchy uses a stable string code, a human-readable message and curated details. A code-based check replaces instanceof so error matching works across package copies and serialization boundaries.
How the filter pipeline works: one mapping table, one response shape
When a service throws, the error propagates up through the controller and into Nest's exceptions layer. Nest looks for a filter whose @Catch() list matches the thrown value, checking method-scoped filters first, then controller-scoped, then global. Only one filter handles a given exception. A DomainErrorFilter bound to @Catch(DomainError) claims domain errors. A filter decorated with a bare @Catch() matches everything else: framework HttpExceptions, driver errors and plain TypeErrors. Without that fallback, Nest's built-in handler returns its own body for unknown errors, and that body does not match your contract.
The domain filter does no reasoning of its own. It reads error.code and looks up the status in a single table:
| Code | Status | Rationale |
|---|---|---|
ORDER_NOT_FOUND |
404 | Resource absent |
ORDER_ALREADY_PAID |
409 | Conflicts with current state; retrying after a refresh may succeed |
ORDER_TOTAL_INVALID |
422 | Input is semantically wrong in any state |
INVOICE_ACCESS_DENIED |
404 | Hides whether the invoice exists |
QUOTA_EXCEEDED |
429 | Client should back off |
Writing the table forces two decisions. Use 409 when the request is valid but collides with stored state, and use 422 when the request would fail no matter what is stored. Use 403 when the caller may know the resource exists, and use 404 when its existence is itself sensitive. Both filters emit the same body: code, message, details, requestId, path and timestamp. Clients branch on code and never parse message, so you can reword messages without breaking any consumer.
+---------+ throws +------------+ propagates
| Service |----------->| Controller |----------------+
+---------+ +------------+ |
v
+----------------------------------------------------------------+
| Nest exceptions layer |
| method -> controller -> global @Catch(); one filter wins |
+----------------------------------------------------------------+
| |
DomainError anything else
v v
+-----------------------------+ +-----------------------------+
| DomainErrorFilter | | Fallback filter |
| @Catch(DomainError) | | bare @Catch() |
| looks up error.code | | HttpException, driver error,|
+-----------------------------+ | TypeError, ... |
v +-----------------------------+
+-----------------------------+ |
| Code -> status table | |
| ORDER_NOT_FOUND 404 | |
| ORDER_ALREADY_PAID 409 | |
| ORDER_TOTAL_INVALID 422 | |
| INVOICE_ACCESS_DENIED 404 | |
| QUOTA_EXCEEDED 429 | |
+-----------------------------+ |
+-----------------+----------------+
v
+----------------------------------------------------------------+
| Same body: code, message, details, requestId, path, timestamp |
+----------------------------------------------------------------+
A thrown error goes to the first filter whose @Catch() list matches, checked from method to controller to global scope: domain errors go to DomainErrorFilter and its code-to-status table, all other errors go to the bare @Catch() fallback, and both filters return the same response body.
Implementing the domain filter and the catch-all fallback
The domain filter
A domain filter is a class implementing ExceptionFilter, decorated with @Catch(DomainError) so it only sees your hierarchy. Inside catch(exception, host), call host.switchToHttp() to get the request and response, look up the error's code in the mapping table, and set status and body explicitly. Do not rely on defaults: an unmapped code should resolve to 500, never to whatever the framework would guess. If you need both Express and Fastify, write the response through HttpAdapterHost rather than calling res.status().json() directly.
Normalizing framework exceptions
HttpException subclasses still reach you from guards, pipes and third-party modules. The ValidationPipe's BadRequestException carries an array of messages in getResponse(); move that into details and assign a stable code such as VALIDATION_FAILED. This works, but it couples you to the payload format of that exception. A framework upgrade that changes the payload changes your contract, so cover it with a test.
The catch-all fallback
A second filter with a bare @Catch() handles everything else. For an HttpException, it keeps the original status so a 404 or 403 is not turned into a 500. For anything else, it returns a generic 500 with no stack trace or internal message, and logs the full error with the request ID. Choose log severity by status class. 4xx responses are client mistakes, so log them at debug or info. 5xx responses get error level and the full detail.
Non-HTTP contexts
Check host.getType() first. For rpc and ws, rethrow or map to the transport's own exception type. For graphql, return an error the GraphQL layer can format. Never call HTTP response methods in any of these contexts.
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
@Catch() // bare @Catch(): sees everything the domain filter did not handle
export class FallbackFilter implements ExceptionFilter {
private readonly logger = new Logger('FallbackFilter');
constructor(private readonly adapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
// rpc, ws and graphql have their own error handling; never touch HTTP objects there.
if (host.getType() !== 'http') throw exception;
const ctx = host.switchToHttp();
const req = ctx.getRequest();
const requestId = req.headers['x-request-id'] ?? 'unknown';
// Goes through the adapter so the same code works on Express and Fastify.
const { httpAdapter } = this.adapterHost;
if (exception instanceof HttpException) {
// Keep the framework's status so a 404 or 403 is not masked as a 500.
const status = exception.getStatus();
if (status >= 500) {
this.logger.error(`${status} requestId=${requestId}: ${exception.message}`, exception.stack);
} else {
// 4xx is a client mistake: low severity, no stack.
this.logger.debug(`${status} requestId=${requestId}: ${exception.message}`);
}
httpAdapter.reply(ctx.getResponse(), { code: 'HTTP_ERROR', message: exception.message, requestId }, status);
return;
}
// Unknown failure: log everything server-side, reveal nothing to the client.
const err = exception instanceof Error ? exception : new Error(String(exception));
this.logger.error(`Unhandled error requestId=${requestId}: ${err.message}`, err.stack);
httpAdapter.reply(
ctx.getResponse(),
{ code: 'INTERNAL_ERROR', message: 'Internal server error', requestId },
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
A catch-all fallback filter that rethrows outside HTTP contexts, keeps the status of HttpExceptions, logs 4xx at debug and 5xx with the stack, and returns a generic 500 tagged with the request ID for unknown errors.
Registering filters: method, controller and global scope
@UseFilters() on a handler or controller is resolved before global filters, so it suits narrow overrides such as a legacy endpoint that must keep an old error shape. For the domain filter and the catch-all fallback, global registration is the norm, and there are two ways to do it.
| Approach | Scope | Dependency injection | Typical use |
|---|---|---|---|
@UseFilters() on method |
One handler | Yes, if passed as a class | One-off contract exceptions |
@UseFilters() on controller |
All handlers in class | Yes, if passed as a class | A module with its own error format |
app.useGlobalFilters() |
Whole app | No, you pass an instance built outside the container | Simple filters with no dependencies |
APP_FILTER provider |
Whole app | Yes | Filters that need a logger, config or metrics |
useGlobalFilters() takes an instance you construct in main.ts, so the container never resolves its constructor arguments. A filter registered as an APP_FILTER provider is built by the container, so it can inject a structured logger, or a config flag that hides stack traces in production. It applies globally no matter which module declares it.
Only one filter handles a given exception: the first whose @Catch() matches. The catch-all should be registered so the domain filter is evaluated before it. If you mix both global mechanisms, their relative order is hard to see, and the catch-all can win and silently bypass your mapping table. Pick one mechanism.
Filters never see these errors:
- Failures during bootstrap or lifecycle hooks.
- Rejections from promises nobody awaited. These become unhandled rejections.
- Errors raised while streaming a response after headers have already been sent.
Testing the mapping table, the filters and the response contract
Tests are layered the same way as the code. Each layer catches a different kind of regression.
Unit-test the mapping table on its own
The mapping table is a plain function from error class to status and error code, so test it without Nest at all. Iterate over every concrete domain error and assert that each one resolves to an explicit entry. This catches the most common drift: someone adds a new subclass and it silently falls through to 500. Also assert that subclasses resolve to their own entry rather than to their parent's, because lookup order is easy to break during refactoring.
Unit-test the filter with a mocked ArgumentsHost
Build a fake host whose switchToHttp() returns stub getRequest() and getResponse() objects. The response stub needs a status mock that returns the response itself, so the chained json call works. Then call catch() directly and assert on the arguments passed to status and json. This keeps the tests fast and isolates the response-shaping logic, including fields such as a request ID or path.
E2E-test the contract
Unit tests can't prove the filters are actually registered. If your filters are registered with useGlobalFilters in main.ts, the e2e setup has to repeat that call. Registering them as APP_FILTER providers removes that duplication. Send HTTP requests that cover four cases:
- a not-found domain error
- a conflict domain error
- a
ValidationPiperejection, to confirm framework exceptions use the same body shape - a route that throws a plain
Error
For the last case, assert a 500 status. Also assert that the body contains no stack trace, no original message and no internal class names. That assertion is what keeps internal details from leaking to clients.
When to avoid it, alternatives, and impact on engineering time
Skip the hierarchy when a service is small, speaks only HTTP, and is mostly CRUD. If every failure is "not found" or "invalid input" and there is no queue consumer or gRPC handler sharing the same services, built-in HttpException subclasses already give you a consistent shape. Adding a mapping layer there only adds a file to open when debugging.
| Approach | Where HTTP knowledge lives | Main cost |
|---|---|---|
Throw HttpException directly |
Domain and services | Couples logic to one transport |
| Map errors in controllers | Every handler | Duplicated try/catch blocks that drift apart |
| Result/Either types from services | Callers that unwrap results | More verbose call sites, but failures are explicit in signatures |
Interceptors with catchError |
Per-route or global interceptor | Only covers errors raised inside the observable chain, so it overlaps with filters |
| Domain errors plus filters | One mapping table | Upfront indirection |
Result types work well alongside filters. Use them for expected outcomes the caller must handle, and keep thrown domain errors for cases that should end the request.
The development cost is front-loaded. The team must agree on base classes, error codes and the response contract, and newcomers have to learn where translation happens. The payoff comes later. A new error type becomes one reviewed change to the hierarchy and the table, not edits scattered across controllers. Clients write a single parser. Because 4xx responses are classified on purpose, alerts can target unmapped 500s, and those alerts are not buried under expected validation failures.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nestjs exception filters, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.