If you want your application to stay scalable and maintainable, you need clear boundaries. Your business logic should be transport-agnostic: it should not know about HTTP, status codes, or Nest exceptions. The HTTP layer is where you decide how a business failure becomes a response.
Luckily, NestJS makes it easy to keep transport and business logic separated with exception filters. In this post I show how to map business exceptions to HTTP errors without turning your controllers into a mess.
Let's get started.
1) Define business exceptions
Start by defining business exceptions. The key is that they all extend a shared base class, so a single filter can handle the whole group.
// orders/order.exceptions.ts
export abstract class OrderException extends Error {
constructor(message: string) {
super(message);
}
}
export class OrderNotFoundException extends OrderException {
constructor(orderId: string) {
super(`Order ${orderId} not found`, { orderId });
}
}
export class OrderAlreadyPaidException extends OrderException {
constructor(orderId: string) {
super(`Order ${orderId} is already paid`, { orderId });
}
}
2) Throw business exceptions from your service
Your service contains business logic and throws business exceptions. It should not import or throw HTTP exceptions.
// orders/orders.service.ts
import { Injectable } from "@nestjs/common";
import { OrderAlreadyPaidException, OrderNotFoundException } from "./order.exceptions";
type Order = { id: string; paid: boolean };
@Injectable()
export class OrdersService {
private orders: Record<string, Order> = {
"order-1": { id: "order-1", paid: false },
"order-2": { id: "order-2", paid: true },
};
get(id: string) {
const order = this.orders[id];
if (!order) {
throw new OrderNotFoundException(id);
}
return order;
}
pay(id: string) {
const order = this.get(id);
if (order.paid) {
throw new OrderAlreadyPaidException(id);
}
order.paid = true;
return order;
}
}
3) Create a shared base filter that only does mapping
Now create one reusable base filter. It tries to map known business exception types to Nest HTTP exceptions. If there is no mapping, it delegates the original exception to Nest.
// filters/mapped-exception.filter.ts
import { ArgumentsHost, HttpException } from "@nestjs/common";
import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core";
export type ExceptionType<T = unknown> = new (...args: any[]) => T;
export type ExceptionMap = Map<ExceptionType, (e: any) => HttpException>;
function mapByType(map: ExceptionMap, ex: unknown) {
for (const [type, toHttp] of map) {
if (ex instanceof type) {
return toHttp(ex);
}
}
return null;
}
export abstract class MappedExceptionFilter extends BaseExceptionFilter {
protected abstract readonly map: ExceptionMap;
constructor(host: HttpAdapterHost) {
super(host.httpAdapter);
}
override catch(exception: unknown, host: ArgumentsHost) {
const mapped = mapByType(this.map, exception);
if (mapped) {
return super.catch(mapped, host);
}
return super.catch(exception, host);
}
}
4) Define the mapping for your module
Per module, you create a small filter that defines the mapping table for that module. This keeps mappings local and easy to change.
// orders/orders-exception.filter.ts
import { Catch, ConflictException, NotFoundException } from "@nestjs/common";
import { HttpAdapterHost } from "@nestjs/core";
import { ExceptionMap, MappedExceptionFilter } from "../../filters/mapped-exception.filter";
import { OrderAlreadyPaidException, OrderException, OrderNotFoundException } from "./order.exceptions";
@Catch(OrderException)
export class OrdersExceptionFilter extends MappedExceptionFilter {
protected readonly map: ExceptionMap = new Map([
[OrderNotFoundException, () => new NotFoundException("order not found")],
[OrderAlreadyPaidException, () => new ConflictException("order already paid")],
]);
constructor(host: HttpAdapterHost) {
super(host);
}
}
5) Attach the filter in your controller
Controllers stay clean: they do not need try/catch. They just call the service.
// orders/orders.controller.ts
import { Controller, Get, Param, Post, UseFilters } from "@nestjs/common";
import { OrdersService } from "./orders.service";
import { OrdersExceptionFilter } from "./orders-exception.filter";
@Controller("orders")
@UseFilters(OrdersExceptionFilter)
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Get(":id")
get(@Param("id") id: string) {
return this.orders.get(id);
}
@Post(":id/pay")
pay(@Param("id") id: string) {
return this.orders.pay(id);
}
}
6) Register the filter as a provider
Because the filter needs HttpAdapterHost, register it as a provider so Nest can inject it.
// orders/orders.module.ts
import { Module } from "@nestjs/common";
import { OrdersController } from "./orders.controller";
import { OrdersService } from "./orders.service";
import { OrdersExceptionFilter } from "./orders-exception.filter";
@Module({
controllers: [OrdersController],
providers: [OrdersService, OrdersExceptionFilter],
})
export class OrdersModule {}
Result
- Business logic stays transport-agnostic.
- Controllers stay small and readable.
- Each module owns its own mapping.
- Nest handles all exceptions that are not explicitly mapped.