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:
- Domain: Pure business logic. No framework imports. No database. Just functions and types.
- Application: Use cases that orchestrate domain logic. Depends on interfaces, not implementations.
- 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.
