mdashikjs/blog
All posts
Clean Architecture in NestJS: Separating Business Logic from Framework
Backend

Clean Architecture in NestJS: Separating Business Logic from Framework

Backend5 min

Clean Architecture in NestJS: Separating Business Logic from Framework

NestJS gives you modules, services, and controllers. But putting all your logic in services couples your business rules to the framework. Here is how I structure NestJS projects so the core logic is portable and testable.

NestJSClean ArchitectureTypeScriptPatterns
Share:

The problem with fat services

A typical NestJS service:

@Injectable()
export class OrderService {
  constructor(
    private db: PrismaService,
    private mailer: MailService,
    private stripe: StripeService,
  ) {}

  async createOrder(dto: CreateOrderDto) {
    const order = await this.db.order.create({ data: dto });
    await this.stripe.charge(order.total);
    await this.mailer.sendConfirmation(order);
    return order;
  }
}

This works but the business logic (create order, charge, notify) is tangled with Prisma, Stripe, and the mail service. You cannot test the logic without mocking three dependencies.

Layer separation

I split the code into three layers:

  1. Domain: Pure business logic. No framework imports. No database. Just functions and types.
  2. Application: Use cases that orchestrate domain logic. Depends on interfaces, not implementations.
  3. Infrastructure: NestJS controllers, Prisma repositories, Stripe client. Implements the interfaces.

Domain layer

// domain/order.ts — no imports from NestJS, Prisma, or any framework
export interface Order {
  id: string;
  items: OrderItem[];
  total: number;
  status: 'pending' | 'paid' | 'shipped';
}

export function calculateTotal(items: OrderItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

export function canCancel(order: Order): boolean {
  return order.status === 'pending';
}

Application layer

// application/create-order.use-case.ts
export interface OrderRepository {
  save(order: Order): Promise<Order>;
}

export interface PaymentGateway {
  charge(amount: number): Promise<string>;
}

export class CreateOrderUseCase {
  constructor(
    private orders: OrderRepository,
    private payments: PaymentGateway,
  ) {}

  async execute(items: OrderItem[]): Promise<Order> {
    const total = calculateTotal(items);
    const paymentId = await this.payments.charge(total);
    const order = await this.orders.save({ items, total, status: 'paid' });
    return order;
  }
}

Infrastructure layer

NestJS wires it all together:

@Module({
  providers: [
    { provide: 'OrderRepository', useClass: PrismaOrderRepository },
    { provide: 'PaymentGateway', useClass: StripePaymentGateway },
    CreateOrderUseCase,
  ],
})
export class OrderModule {}

Testing the use case

const mockRepo = { save: jest.fn().mockResolvedValue({ id: '1', status: 'paid' }) };
const mockPayment = { charge: jest.fn().mockResolvedValue('pay_123') };

const useCase = new CreateOrderUseCase(mockRepo, mockPayment);
const result = await useCase.execute([{ price: 50, quantity: 2 }]);

expect(mockPayment.charge).toHaveBeenCalledWith(100);

No NestJS test module. No database. Pure unit test that runs in milliseconds.

When this is overkill

CRUD apps with no complex business logic. If your service is just this.db.findMany(), adding use cases and repositories is ceremony with no payoff. Use this pattern when the domain logic is complex enough to warrant isolation.

MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.