Naalya Handbook

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 HttpException subclasses (NotFoundException, ConflictException, BadRequestException, …) instead of returning null or an error object. The exception is the control flow.
  • Catching & shaping — a global AllExceptionsFilter intercepts every thrown value and normalizes it into the shared ErrorResponseDto envelope, while a SentryGlobalFilter reports the unexpected ones.
  • Documenting — shared @ApiNotFoundErrorResponse / @ApiConflictErrorResponse / @ApiAuthErrorResponses decorators 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:

  1. Callers can't forget to check. A returned error value is easy to ignore; an unhandled null becomes a confusing crash three layers up. A thrown exception unwinds the stack until the global filter catches it — there's no silent path.
  2. The status code travels with the error. Because the exception type is the status, your service never touches the Response object. The controller stays a thin pass-through; HTTP concerns live in exactly one place.
  3. 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:

apps/server/src/app/campus/campus.service.ts
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:

SituationThrowStatus
Referenced record doesn't existNotFoundException404
Unique constraint / duplicate (e.g. email taken)ConflictException409
Bad input or a domain rule violationBadRequestException400
Authenticated user lacks permissionForbiddenException403
Missing / expired / malformed tokenUnauthorizedException401

A real conflict, from the user service when an email is already registered:

apps/server/src/app/user/user.service.ts
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.

apps/server/src/common/middleware/request-id.middleware.ts
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:

apps/server/src/common/interceptors/logging.interceptor.ts
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:

apps/server/src/common/filters/all-exceptions.filter.ts
@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.

apps/server/src/common/filters/all-exceptions.filter.ts
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:

libs/shared/src/common/dto/error-response.dto.ts
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:

apps/server/src/app/application/admission-application.service.ts
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.

libs/shared/src/common/decorators/api-error-response.decorator.ts
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:

apps/server/src/app/campus/campus.service.ts
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:

apps/server/src/app/campus/campus.controller.ts
@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:

apps/server/src/app/campus/campus.controller.ts
@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:

terminal
pnpm run start:dev   # then open /docs and find archiveCampus

Then 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:

LevelCallUse for
loglogger.log(...)Normal request summaries, lifecycle events
warnlogger.warn(...)Recoverable oddities — a retry, a missing-but-optional value
errorlogger.error(...)Failures, with a stack — reserved for genuine problems
debug / verboselogger.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.

anti-pattern — do not do this
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 HttpException subclasses from services for every expected failure — never return null or 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*ErrorResponse decorators, and carry @ApiAuthErrorResponses() at the controller level.
  • Use the per-class Logger at 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 an HttpException as a bare Error.
  • Never log secrets, tokens, credentials, headers, or raw request bodies — not even at debug.
  • Never remove the AllExceptionsFilter or SentryGlobalFilter registrations; both are required for shaping and reporting.

Where to go next

On this page