NestJS in Production · Structure, Validation and Errors
Domain Errors and Exception Filters
Throw domain errors from services and map them to HTTP responses in one exception filter, with consistent error bodies.
What you'll build
Services that throw domain errors (InsufficientFundsError, AccountNotFoundError) that know nothing about HTTP, and a global exception filter that maps them to 409 and 404 responses with a consistent body, while unexpected errors become a generic 500.
The idea
- Nest's built-in
HttpExceptionsubclasses (NotFoundException,ConflictException) are fine in controllers, but services are cleaner with domain errors. - An exception filter (
@Catch()) intercepts errors and builds the response; register it globally withapp.useGlobalFilters()(or theAPP_FILTERprovider). - Keep one error format for the whole API (
{ error, message, path }), and log unexpected errors without exposing them.
The code
import "reflect-metadata";
import { ArgumentsHost, Catch, Controller, ExceptionFilter, HttpException, Injectable, Module, Param, Post } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
class DomainError extends Error {}
class AccountNotFoundError extends DomainError {}
class InsufficientFundsError extends DomainError {}
@Injectable()
class AccountsService {
private balances = new Map([["ana", 30]]);
withdraw(name: string, amount: number) {
const balance = this.balances.get(name);
if (balance === undefined) throw new AccountNotFoundError(`account ${name} not found`);
if (amount > balance) throw new InsufficientFundsError(`balance ${balance} is less than ${amount}`);
if (name === "crash") throw new Error("ledger.internal timeout");
this.balances.set(name, balance - amount);
return { balance: balance - amount };
}
}
@Controller("accounts")
class AccountsController {
constructor(private readonly accounts: AccountsService) {}
@Post(":name/withdraw/:amount") withdraw(@Param("name") name: string, @Param("amount") amount: string) {
return this.accounts.withdraw(name, Number(amount));
}
}
const logged: string[] = [];
@Catch()
class ApiErrorFilter implements ExceptionFilter {
catch(err: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse();
const path = ctx.getRequest().url;
const status = err instanceof HttpException ? err.getStatus()
: err instanceof AccountNotFoundError ? 404 : err instanceof InsufficientFundsError ? 409 : 500;
if (status === 500) logged.push(`${path}: ${(err as Error).message}`);
res.status(status).json({ error: status, message: status === 500 ? "internal error" : (err as Error).message, path });
}
}
@Module({ controllers: [AccountsController], providers: [AccountsService] })
class AppModule {}
async function main() {
const app = await NestFactory.create(AppModule, { logger: false });
app.useGlobalFilters(new ApiErrorFilter());
await app.listen(0, "127.0.0.1");
const base = `http://127.0.0.1:${app.getHttpServer().address().port}`;
for (const path of ["/accounts/ana/withdraw/20", "/accounts/ana/withdraw/20", "/accounts/bob/withdraw/5", "/nope"]) {
const r = await fetch(base + path, { method: "POST" });
console.log(r.status, JSON.stringify(await r.json()));
}
await app.close();
}
main();
Output
201 {"balance":10}
409 {"error":409,"message":"balance 10 is less than 20","path":"/accounts/ana/withdraw/20"}
404 {"error":404,"message":"account bob not found","path":"/accounts/bob/withdraw/5"}
404 {"error":404,"message":"Cannot POST /nope","path":"/nope"}Run this on your computer — see “Get Started” for setup.
Common mistakes
- Throwing
HttpExceptiondeep in services, mixing HTTP concerns into business logic. - Filters that handle only some errors, so others fall back to a different format.
- Returning stack traces or internal messages in production.
Key points
- Services throw domain errors; one filter maps them to HTTP.
- Keep a single, documented error body for the API.
- Log unexpected errors; return a generic message.
Further reading: Exception filters
Practice questions
Why throw domain errors from services instead of HttpException?
Answer
Services stay independent of HTTP (reusable from queues, CLIs, tests), and the mapping to status codes lives in one place, the exception filter.
What does @Catch() with no arguments do?
Answer
The filter catches every exception, which suits a global filter that defines the API's single error format.
How do you register a global exception filter?
Answer
app.useGlobalFilters(new Filter()) in main.ts, or as an APP_FILTER provider in a module so it can use dependency injection.
Open in the app to mark lessons complete, take notes and track your progress.