Separating Transport from Business Logic in NestJS: Easy Exception Mapping

January 10, 2026 Updated: January 10, 2026 Rens Jaspers NestJS Backend Clean Architecture Exception Handling TypeScript

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.