Error Handling & Logging
How services throw HttpExceptions, how the global filter shapes them into one error envelope, and what the request logger records.
When something goes wrong on this backend, you don't return an error — you throw one. A service that can't find a campus throws NotFoundException. A handler whose email is taken throws ConflictException. Then you stop thinking about HTTP entirely, because a single global filter catches every thrown exception and turns it into one consistent JSON shape, the same for a 404, a 409, a validation failure, or an unexpected crash.
That discipline is the whole story, and it's worth internalizing early. The mental model has three moving parts that you'll meet in order on this page:
- Throwing — services raise NestJS
HttpExceptionsubclasses (NotFoundException,ConflictException,BadRequestException, …) instead of returningnullor an error object. The exception is the control flow. - Catching & shaping — a global
AllExceptionsFilterintercepts every thrown value and normalizes it into the sharedErrorResponseDtoenvelope, while aSentryGlobalFilterreports the unexpected ones. - Documenting — shared
@ApiNotFoundErrorResponse/@ApiConflictErrorResponse/@ApiAuthErrorResponsesdecorators advertise those failure shapes in the OpenAPI spec, so the generated client knows what a failed call looks like.
Logging rides alongside all of this: a global LoggingInterceptor wraps every request and records a one-line summary on the way out. By the end you'll be able to throw an error, watch it become a clean HTTP response, and document it — without writing a single res.status(...).json(...).
An HttpException is just a typed error with a status code
A NestJS HttpException is a plain JavaScript Error that also carries an HTTP status and a response body. The named subclasses — NotFoundException (404), ConflictException (409), BadRequestException (400), ForbiddenException (403), UnauthorizedException (401) — are convenience constructors that preset the status. You throw one; the framework reads the status off it. That's the entire contract.
Throw, don't return
The temptation, coming from other codebases, is to return a result object: { ok: false, error: 'not found' }. Don't. On this backend the rule is throw domain exceptions and let them propagate. Three reasons:
- Callers can't forget to check. A returned error value is easy to ignore; an unhandled
nullbecomes a confusing crash three layers up. A thrown exception unwinds the stack until the global filter catches it — there's no silent path. - The status code travels with the error. Because the exception type is the status, your service never touches the
Responseobject. The controller stays a thin pass-through; HTTP concerns live in exactly one place. - One envelope, every time. Since every error funnels through the same filter, every 4xx and 5xx response has the identical JSON shape. Frontend code reads one structure, not a dozen.
Here's the canonical pattern — the check-then-throw you'll write hundreds of times. This is CampusService.findOne, and every other service in the repo looks just like it:
async findOne(id: string) {
const campus = await this.campusRepository.findOne({ where: { id } });
if (!campus) {
throw new NotFoundException('Campus not found');
}
this.permissionScope.assertCanAccess(Action.READ, Resource.CAMPUS, campus);
return campus;
}Look up the record; if it's missing, throw; otherwise return it. The service never builds an HTTP response — it just signals what kind of failure happened, and the string passed to the constructor ('Campus not found') becomes the human-readable message the client sees. Pick the right subclass for the situation:
| Situation | Throw | Status |
|---|---|---|
| Referenced record doesn't exist | NotFoundException | 404 |
| Unique constraint / duplicate (e.g. email taken) | ConflictException | 409 |
| Bad input or a domain rule violation | BadRequestException | 400 |
| Authenticated user lacks permission | ForbiddenException | 403 |
| Missing / expired / malformed token | UnauthorizedException | 401 |
A real conflict, from the user service when an email is already registered:
if (await this.userRepository.exists({ email: dto.email })) {
throw new ConflictException('Email already in use');
}Exception to response
Once you throw, you've handed the problem off. Let's trace exactly what happens to that exception, because understanding the path is what lets you trust it.
The interceptor
Before the handler even runs, the request passed through requestIdMiddleware, which stamps a correlation id on it (from an incoming X-Request-Id header, or a fresh UUID) and echoes it back on the response. That id is the thread that ties a log line to an error response.
const REQUEST_ID_HEADER = 'X-Request-Id';
// ...
const id =
(req.headers[REQUEST_ID_HEADER.toLowerCase()] as string) ?? randomUUID();
req.requestId = id;
res.setHeader(REQUEST_ID_HEADER, id);Wrapping the handler is the global LoggingInterceptor. An interceptor in NestJS is a class that runs code around a request — before the handler, and again after it produces a result or an error. This one starts a timer, then taps the response stream to log a one-line summary on both the success and the error path:
return next.handle().pipe(
tap({
next: () => {
const duration = Date.now() - startTime;
this.logger.log(`${prefix}${method} ${originalUrl} ${response.statusCode} ${duration}ms`);
},
error: (error) => {
const duration = Date.now() - startTime;
const statusCode = error.status || 500;
this.logger.error(`${prefix}${method} ${originalUrl} ${statusCode} ${duration}ms`);
},
}),
);The prefix is the first 8 characters of the request id ([a1b2c3d4] ), so every line is greppable back to one request. Notice it logs method, URL, status, and duration — and deliberately nothing about the request body. That restraint is intentional; we'll come back to it.
The interceptor logs metadata, the filter shapes the body
These two pieces have different jobs and never overlap. The LoggingInterceptor writes a server log line (for you, in the console / log aggregator). The AllExceptionsFilter writes the HTTP response body (for the client). The interceptor sees the error fly by and notes it; it does not format what the caller receives.
The filter
The error keeps unwinding until it hits the global AllExceptionsFilter. The @Catch() decorator with no arguments means "catch every thrown value" — HttpExceptions and stray non-HTTP errors alike. Its job is to produce the one canonical envelope and send it:
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const envelope = this.buildEnvelope(exception, request);
if (envelope.statusCode >= HttpStatus.INTERNAL_SERVER_ERROR) {
this.logger.error(/* ... */ exception instanceof Error ? exception.stack : String(exception));
Sentry.captureException(exception);
}
response.status(envelope.statusCode).json(envelope);
}
}Two behaviors are doing the heavy lifting. First, buildEnvelope branches on the exception type: a real HttpException keeps its status, error name, and message; anything that isn't an HttpException is treated as an internal 500 with a deliberately generic 'Unexpected error.' message — your stack trace and exception details never leak to the client.
if (exception instanceof HttpException) {
const statusCode = exception.getStatus();
const rawBody = exception.getResponse();
// ...preserve status, error, message
}
// Anything that isn't an HttpException is treated as an internal server error.
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
error: STATUS_CODES[HttpStatus.INTERNAL_SERVER_ERROR] ?? 'Internal Server Error',
message: 'Unexpected error.',
...base,
};Second, only 5xx responses are forwarded to Sentry and logged with their stack. A 404 or 409 is an expected outcome — it's not a bug, so it doesn't page anyone. An unhandled crash is a regression, so it gets Sentry.captureException and a full stack in the logs. That split is what keeps your error monitoring signal high.
The envelope
Every response the filter emits matches the shared ErrorResponseDto. This is the single contract the frontend codes against — memorize its shape, not its field-by-field docs:
export class ErrorResponseDto {
statusCode: number; // 404
error: string; // 'Not Found' — the reason phrase
message: string; // 'Campus not found.' — human-readable
details?: string[]; // per-field validation messages (400 only)
missingFields?: string[]; // fields still required to complete the action
attachments?: { resource: string; count: number }[]; // what blocks a delete (409)
path: string; // '/api/v1/campuses/abc'
timestamp: string; // ISO time the error was produced
requestId: string; // the X-Request-Id correlation id
}The requestId here is the same id the LoggingInterceptor prefixed onto its log line. When a user reports an error, you ask for that id and grep the logs straight to the request. That's the payoff of threading it through everything.
Validation & missingFields
Two optional fields fill in for richer failures. When the global ValidationPipe rejects a DTO, its BadRequestException carries a message array; the filter splits that into a single message: 'Validation failed.' plus a details: string[] of the per-field problems. And a service can attach missingFields by throwing a BadRequestException with an object body — exactly how admission applications reject an incomplete submission:
if (missingFields.length > 0) {
throw new BadRequestException({
message: 'Application is incomplete',
missingFields,
});
}The filter recognizes the missingFields key and threads it into the envelope. So you get a precise list back — ['signatureId', 'dateSigned'] — without inventing a bespoke response shape. When you pass a string to BadRequestException you get a plain message; pass an object and you can carry message plus extra keys the filter understands.
attachments works the same way for blocked deletes: academic-year deletion throws a ConflictException with { message, attachments: [{ resource: 'enrollment', count: 214 }, …] }, and the frontend renders the report as "why you can't delete this".
Two filters are registered — and that's intentional
You'll see a SentryGlobalFilter registered as an APP_FILTER in app.module.ts, and the AllExceptionsFilter registered via app.useGlobalFilters(...) in main.ts. They cooperate: the Sentry filter (from @sentry/nestjs) ensures errors reach Sentry, and AllExceptionsFilter shapes the client response and also calls Sentry.captureException for 5xx. Don't remove either; reporting and the response envelope are both load-bearing.
Documenting failures
A working endpoint isn't done until its failures are documented too, so the generated client and the API reference both show what a bad call returns. You do that with the shared @Api*ErrorResponse decorators from @app/shared. Each one attaches an @ApiResponse for a given status, typed to ErrorResponseDto — so a documented 404 shows the exact same envelope the filter actually emits.
export const ApiNotFoundErrorResponse = createApiErrorResponse(404, 'Resource not found.');
export const ApiConflictErrorResponse = createApiErrorResponse(409, 'Resource conflict.');
export const ApiBadRequestErrorResponse = createApiErrorResponse(400, 'Request payload is invalid.');
// ...401, 403, 500
export const ApiAuthErrorResponses = () =>
applyDecorators(ApiUnauthorizedErrorResponse(), ApiForbiddenErrorResponse());The convenience @ApiAuthErrorResponses() bundles the 401 and 403 docs into one class-level decorator — you put it on the controller once and every route inherits both. You only reach for the specific decorators (@ApiNotFoundErrorResponse, @ApiConflictErrorResponse) on the individual handlers that can actually produce them.
You don't document 401, 403, and 500 per-route
At boot, injectGlobalErrorResponses(documentFactory) in main.ts walks every operation in the generated spec and injects the shared envelope as the default 401, 403, and 500 responses — so every endpoint advertises them without you repeating decorators. 401/403 are skipped on @Public() routes; 500 is added unconditionally; and a response you declared by hand is never overwritten. You only hand-document the specific 4xx an endpoint can throw.
Step 1: Throw
You're adding "archive a campus" and it must reject a campus that's already archived. Put the rule in the service, throwing the exception that matches the failure — a state conflict is a 409:
async archive(id: string) {
const campus = await this.campusRepository.findOne({ where: { id } });
if (!campus) {
throw new NotFoundException('Campus not found');
}
if (campus.archivedAt) {
throw new ConflictException('Campus is already archived');
}
return this.campusRepository.update(id, { archivedAt: new Date() });
}Two distinct failures, two distinct exceptions — a missing campus (404) and an already-archived one (409). The message strings are written for a human reading the response. Notice there is still no Response object anywhere in sight.
Step 2: Controller
The controller just awaits the service. It never inspects the result for an error or sets a status — the thrown exception handles all of that on its own:
@RequirePermissions({ action: Action.UPDATE, resource: Resource.CAMPUS })
@Patch(':id/archive')
@ApiOperation({ summary: 'Archive Campus', operationId: 'archiveCampus',
description: 'Archive a campus. Fails if the campus is already archived.' })
@ApiResponse({ status: 200, description: 'Campus archived.', type: CampusDto })
archive(@Param('id') id: string) {
return this.controllerService.archive(id);
}When archive throws, the filter takes over: it builds the envelope, sets the status, and writes the JSON. Your handler body is one line because that's all it should be.
Step 3: Document
Now make those failures visible in the API docs. The class already carries @ApiAuthErrorResponses() (covering 401/403 for every route), so on this handler you add only the failures it can actually produce — the 404 and the 409:
@Patch(':id/archive')
@ApiOperation({ summary: 'Archive Campus', operationId: 'archiveCampus', description: '...' })
@ApiResponse({ status: 200, description: 'Campus archived.', type: CampusDto })
@ApiNotFoundErrorResponse('Campus not found.')
@ApiConflictErrorResponse('Campus is already archived.')
archive(@Param('id') id: string) { /* ... */ }Each decorator takes an optional description; pass one that matches the real message so the docs read truthfully. You skip 401, 403, and 500 — injectGlobalErrorResponses already added those globally.
Step 4: Verify
Boot the server and confirm both halves. Open the live reference at /docs and find archiveCampus — it should list a 404 and a 409 alongside the 200, each typed to the error envelope:
pnpm run start:dev # then open /docs and find archiveCampusThen hit the endpoint against an already-archived campus and read the response. You should get a 409 whose body matches ErrorResponseDto exactly — statusCode, error, message, path, timestamp, requestId — and a matching [<requestId>] PATCH .../archive 409 12ms line in the server console. Same id in the body and the log: that's the system working.
Logging
The project logs through NestJS's built-in Logger, instantiated per class (new Logger(CampusService.name)) so each line is tagged with its source. It exposes the standard levels — and choosing the right one matters, because the filter and Sentry both key off severity:
| Level | Call | Use for |
|---|---|---|
log | logger.log(...) | Normal request summaries, lifecycle events |
warn | logger.warn(...) | Recoverable oddities — a retry, a missing-but-optional value |
error | logger.error(...) | Failures, with a stack — reserved for genuine problems |
debug / verbose | logger.debug(...) | Local-only detail, off in production |
The golden rule of the request logger you already saw: it records method, URL, status, and duration — and never the request body, headers, or query payload. That's deliberate, and it's the line you must not cross.
Never log a secret, a token, or PII
Request bodies on this API carry passwords, JWTs, refresh tokens, and student personal data. Never log a raw request body, an Authorization header, a token, or a credential — not even at debug. If you must log around an auth or payment flow, log the request id and the outcome ("token refresh failed for user <sub>"), never the secret itself. Logs are shipped to an aggregator and outlive the request; treat every line as permanently visible. The same rule covers console.log left in during debugging — strip it before you commit.
Gotchas
A handful of mistakes show up again and again. Internalize these.
Throwing the wrong exception type. The status code is the exception class, so a mis-picked class is a wrong HTTP status. Throwing BadRequestException (400) for a missing record makes the client think it sent bad input when really the resource is gone (404). Throwing NotFoundException for a duplicate email hides a genuine 409 conflict. Match the class to the failure — re-read the table above when unsure.
Swallowing errors. A try/catch that catches an exception and returns null, an empty array, or a fake-success value defeats the entire system — the filter never runs, the client gets a 200 for a failure, and nothing reaches Sentry. Only catch when you genuinely intend to recover or to re-throw a clearer exception; otherwise let it propagate. An empty catch {} is almost always a bug.
async findOne(id: string) {
try {
return await this.repo.findById(id); // throws? swallowed below
} catch {
return null; // caller now can't tell "missing" from "failed"
}
}Catching and re-wrapping as a 500. Catching an HttpException and re-throwing a plain Error (or throw new Error(...)) downgrades a clean 404 into an opaque 500 — the filter only preserves status for HttpExceptions. If you must catch, re-throw an HttpException, not a bare Error.
Building HTTP responses by hand. Reaching for @Res() and writing res.status(404).json(...) yourself bypasses both the filter and the standard envelope, so your endpoint returns a shape nothing else uses. Throw the exception and let the filter format it — that's the whole point of the convention.
Logging the request body to debug. The fastest way to leak a password into your logs is a "temporary" logger.debug(JSON.stringify(req.body)). Log the request id and the specific safe fields you need, never the whole payload — and remove debug logging before the PR.
Always Do / Never Do
These are the load-bearing conventions a new dev must internalize, distilled from the codebase's own rules.
Always:
- Throw NestJS
HttpExceptionsubclasses from services for every expected failure — never returnnullor an error object to signal a problem. - Let exceptions propagate to the global
AllExceptionsFilter; trust it to set the status and shape the body. - Document the specific 4xx an endpoint can produce with the shared
@Api*ErrorResponsedecorators, and carry@ApiAuthErrorResponses()at the controller level. - Use the per-class
Loggerat the right level, and include the request id when logging around a failure.
Never:
- Never build responses by hand with
@Res()/res.status().json()for errors — it bypasses the envelope. - Never swallow an exception into a silent
null/empty return, or re-wrap anHttpExceptionas a bareError. - Never log secrets, tokens, credentials, headers, or raw request bodies — not even at
debug. - Never remove the
AllExceptionsFilterorSentryGlobalFilterregistrations; both are required for shaping and reporting.
Where to go next
API Conventions
The controller, DTO, and @ApiOperation rules these error responses fit into.
Add an Endpoint
Put the throw-and-document flow to work on a real new route.
Auth & Permissions
Where the 401 and 403 exceptions actually originate — the guards.
Auditing
The other thing handlers record after a successful mutation: audit-log entries.