Global ApiExceptionFilter
Build a Fastify-compatible catch-all filter with HttpAdapterHost and safe normalization
Phase 13 - Professional Error Handling
Volume 04 - Professional Backend
Architecture milestone
exception
↓
normalize
↓
log
↓
httpAdapter.reply
Phase 13 creates one professional exception boundary and a correlation story that spans client response and server logs.
Build the global ApiExceptionFilter
The filter is intentionally a translation boundary. It receives unknown exceptions, decides which failures are
safe and known, creates one public envelope, logs diagnostics, then writes the response through NestJS
HttpAdapterHost.
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from "@nestjs/common";
import { HttpAdapterHost } from "@nestjs/core";
import { FastifyRequest } from "fastify";
import { Prisma } from "../../generated/prisma/client";
import { ApiErrorResponse } from "./api-error-response";
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(ApiExceptionFilter.name);
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const context = host.switchToHttp();
const request = context.getRequest<FastifyRequest>();
const response = context.getResponse();
const result = this.normalize(exception);
const requestId = String(request.id);
const path = httpAdapter.getRequestUrl(request);
const body: ApiErrorResponse = {
statusCode: result.statusCode,
code: result.code,
message: result.message,
requestId,
timestamp: new Date().toISOString(),
path,
...(result.details ? { details: result.details } : {}),
};
if (result.statusCode >= 500) {
this.logger.error("Unhandled request failure", {
requestId,
method: request.method,
path,
statusCode: result.statusCode,
exceptionName:
exception instanceof Error ? exception.name : typeof exception,
stack: exception instanceof Error ? exception.stack : undefined,
});
} else {
this.logger.warn("Request rejected", {
requestId,
method: request.method,
path,
statusCode: result.statusCode,
code: result.code,
});
}
httpAdapter.reply(response, body, result.statusCode);
}
private normalize(exception: unknown): {
statusCode: number;
code: string;
message: string;
details?: string[] | Record<string, unknown>;
} {
if (exception instanceof HttpException) {
return this.fromHttpException(exception);
}
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
return this.fromPrismaError(exception);
}
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
code: "INTERNAL_ERROR",
message: "An unexpected server error occurred.",
};
}
private fromHttpException(exception: HttpException) {
const statusCode = exception.getStatus();
const response = exception.getResponse();
if (typeof response === "string") {
return {
statusCode,
code: this.defaultCode(statusCode),
message: response,
};
}
if (response && typeof response === "object") {
const body = response as {
code?: unknown;
message?: unknown;
};
if (Array.isArray(body.message)) {
return {
statusCode,
code: typeof body.code === "string"
? body.code
: this.defaultCode(statusCode),
message: "Request validation failed.",
details: body.message.map(String),
};
}
if (typeof body.message === "string") {
return {
statusCode,
code: typeof body.code === "string"
? body.code
: this.defaultCode(statusCode),
message: body.message,
};
}
}
return {
statusCode,
code: this.defaultCode(statusCode),
message: "Request failed.",
};
}
private fromPrismaError(
exception: Prisma.PrismaClientKnownRequestError,
) {
switch (exception.code) {
case "P2002":
return {
statusCode: HttpStatus.CONFLICT,
code: "UNIQUE_CONSTRAINT_CONFLICT",
message: "A resource with the same unique value already exists.",
};
case "P2003":
return {
statusCode: HttpStatus.CONFLICT,
code: "RELATION_CONFLICT",
message: "The operation conflicts with an existing relationship.",
};
case "P2025":
return {
statusCode: HttpStatus.NOT_FOUND,
code: "RESOURCE_NOT_FOUND",
message: "The requested resource was not found.",
};
default:
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
code: "DATABASE_ERROR",
message: "A database operation failed.",
};
}
}
private defaultCode(statusCode: number): string {
switch (statusCode) {
case HttpStatus.BAD_REQUEST:
return "BAD_REQUEST";
case HttpStatus.UNAUTHORIZED:
return "UNAUTHORIZED";
case HttpStatus.FORBIDDEN:
return "FORBIDDEN";
case HttpStatus.NOT_FOUND:
return "NOT_FOUND";
case HttpStatus.CONFLICT:
return "CONFLICT";
default:
return `HTTP_${statusCode}`;
}
}
}
Three normalization paths
Input exception Normalization path Public result
HttpException Preserve status, code/message where 4xx/5xx contract
supplied
Prisma known request error Translate known P-codes 409/404 or sanitized DB failure
Anything else Treat as unexpected fault 500 INTERNAL_ERROR
Validation details without framework coupling
The filter does not need to know that Zod created the validation error. It inspects the HttpException response. If
the message is an array, TaskFlow uses a stable top-level message and moves those strings into details.
if (Array.isArray(body.message)) {
return {
statusCode,
code: "BAD_REQUEST",
message: "Request validation failed.",
details: body.message.map(String),
};
}
Adapter-agnostic response writing
httpAdapter.reply(
response,
body,
result.statusCode,
);
Why this matters
Earlier NestJS examples often use Express Response.json(). Our course explicitly chose Fastify in Phase 04, so the
exception boundary should not quietly depend on Express-only APIs.