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.

Create a free account to save this page, take notes and track your progress. Sign in

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 HttpException subclasses (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 with app.useGlobalFilters() (or the APP_FILTER provider).
  • Keep one error format for the whole API ({ error, message, path }), and log unexpected errors without exposing them.

The code

Mapping domain errors in a global filter
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 HttpException deep 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.

Was this page helpful?