Rethinking Domain Boundaries in Our NestJS Microservices

Untangling circular module dependencies and leaky database entities in NestJS taught us why strict ports and adapters are the only way to keep growing microservices maintainable.

VW
Viery Wahyu•Author
•7 min read

NestJS gives engineering teams a dangerous sense of architectural security during the first तीन months of a project. Because the framework ships with opinionated decorators for modules, controllers, and injectable services right out of the box, it is easy to assume that simply running the CLI generator keeps your codebase cleanly structured. In reality, grouping files by technical folders leads almost inevitably to a tightly coupled monolith wearing a microservice costume. Across our core booking and financial settlement services, we woke up one morning to find that our Order Module imported our Payment Module, our Payment Module imported our Merchant Ledger Module, and our Merchant Ledger Module reached right back into our Order Module wrapped inside ugly forwardRef hacks just to stop the dependency injection container from crashing on startup.

When NestJS Modules Devolve Into Circular Spaghetti

Treating forwardRef as a normal framework utility is the first sign that your domain boundaries have collapsed. When we audited why our services had become so deeply entangled, we realized that our @Injectable() classes were doing three completely different jobs at the same time. A single service method would validate HTTP request DTOs, run complex fee calculation math, execute raw database queries through an ORM, and fire webhook payloads to an external payment gateway.

Because our business rules were welded directly to our database queries, whenever another domain needed to check an order rule, developers simply exported the entire database-backed service from one NestJS module and injected it into the neighboring module. Within a year, changing a single column in our PostgreSQL schema triggered cascading compilation errors across twelve different controllers and background queue workers. Worse still, running our unit test suite took minutes because testing a simple commission calculation required calling Test.createTestingModule() and mocking seventeen transitive infrastructure dependencies.

Stripping Framework Decorators From the Core Domain

Paying down that architectural debt required flipping our dependency graph inside out using Hexagonal Architecture, also known as Ports and Adapters. Instead of placing our database tables at the center of our universe and building NestJS services around them, we carved each microservice into three concentric rings consisting of a pure Domain layer at the core, an Application Use Case layer around it, and an outer Infrastructure layer where NestJS controllers, message brokers, and database drivers actually live.

Our strictest rule during the refactor was banning all NestJS decorators and third-party ORM imports from the inner domain directory. Domain entities became plain TypeScript classes that encapsulate state mutations and enforce business invariants through methods rather than exposing anemic public properties. Whenever the domain or application layer needs to persist state or talk to an external bank API, it defines an outbound Port interface paired with a native JavaScript Symbol token, since TypeScript interfaces evaporate at runtime and cannot serve as NestJS injection tokens on their own.

typescript
1// src/modules/settlement/domain/settlement.entity.ts
2export class MerchantSettlement {
3 constructor(
4 public readonly id: string,
5 public readonly merchantId: string,
6 private grossAmountCents: number,
7 private platformFeeCents: number,
8 private status: "PENDING" | "DISBURSED" | "FAILED"
9 ) {
10 if (grossAmountCents <= 0) {
11 throw new Error("Settlement gross amount must be greater than zero")
12 }
13 }
14
15 public calculateNetPayoutCents(): number {
16 return this.grossAmountCents - this.platformFeeCents
17 }
18
19 public markDisbursed(): void {
20 if (this.status !== "PENDING") {
21 throw new Error("Only pending settlements can transition to disbursed")
22 }
23 this.status = "DISBURSED"
24 }
25
26 public get currentStatus() {
27 return this.status
28 }
29}
30
31// src/modules/settlement/application/ports/settlement-repository.port.ts
32export const SETTLEMENT_REPOSITORY_PORT = Symbol("SETTLEMENT_REPOSITORY_PORT")
33
34export interface SettlementRepositoryPort {
35 findById(id: string): Promise<MerchantSettlement | null>
36 save(settlement: MerchantSettlement): Promise<void>
37}

Look at how clean and self-contained that domain boundary is. There are no @Entity() column decorators, no HTTP status code exceptions, and zero references to SQL dialects. Any engineer can read MerchantSettlement and immediately understand the exact financial invariants of the business, and unit tests can instantiate that class in half a millisecond using standard new MerchantSettlement() calls without booting the NestJS runtime at all.

Wiring Infrastructure Adapters Through Custom Providers

Once the core domain and its outbound ports are defined in pure TypeScript, the Application layer orchestrates the business workflow by injecting those abstract Symbol tokens, while the outer Infrastructure layer implements the concrete adapters. A database adapter is responsible for translating between our pure MerchantSettlement domain object and the underlying Drizzle or Prisma database row, ensuring that persistence concerns never leak inward.

This is where NestJS modules finally shine in their true architectural role as the dependency injection composition root. Inside the module definition at the outer boundary of the service, we bind our abstract SETTLEMENT_REPOSITORY_PORT symbol to the concrete DrizzleSettlementRepositoryAdapter class using a custom provider declaration.

typescript
1// src/modules/settlement/application/use-cases/disburse-settlement.use-case.ts
2import { Inject, Injectable } from "@nestjs/common"
3import {
4 SETTLEMENT_REPOSITORY_PORT,
5 type SettlementRepositoryPort,
6} from "../ports/settlement-repository.port"
7
8@Injectable()
9export class DisburseSettlementUseCase {
10 constructor(
11 @Inject(SETTLEMENT_REPOSITORY_PORT)
12 private readonly settlementRepo: SettlementRepositoryPort
13 ) {}
14
15 async execute(settlementId: string): Promise<{ netDisbursedCents: number }> {
16 const settlement = await this.settlementRepo.findById(settlementId)
17 if (!settlement) {
18 throw new Error(`Settlement record ${settlementId} not found`)
19 }
20
21 settlement.markDisbursed()
22 await this.settlementRepo.save(settlement)
23
24 return {
25 netDisbursedCents: settlement.calculateNetPayoutCents(),
26 }
27 }
28}
29
30// src/modules/settlement/settlement.module.ts
31import { Module } from "@nestjs/common"
32import { DisburseSettlementUseCase } from "./application/use-cases/disburse-settlement.use-case"
33import { SETTLEMENT_REPOSITORY_PORT } from "./application/ports/settlement-repository.port"
34import { DrizzleSettlementRepositoryAdapter } from "./infrastructure/adapters/drizzle-settlement.adapter"
35import { SettlementHttpController } from "./infrastructure/http/settlement.controller"
36
37@Module({
38 controllers: [SettlementHttpController],
39 providers: [
40 DisburseSettlementUseCase,
41 {
42 provide: SETTLEMENT_REPOSITORY_PORT,
43 useClass: DrizzleSettlementRepositoryAdapter,
44 },
45 ],
46 exports: [DisburseSettlementUseCase],
47})
48export class SettlementModule {}

Inverting the dependency flow through provide and useClass gives you surgical flexibility over your infrastructure. When we run fast integration tests in continuous integration, we can swap DrizzleSettlementRepositoryAdapter for an in-memory map adapter in one line of configuration. Even better, when we migrated one of our high-throughput ledger stores from standard PostgreSQL tables to a specialized financial ledger engine last quarter, we simply wrote a new outbound adapter class and updated the module provider binding without touching a single line of code inside DisburseSettlementUseCase or MerchantSettlement.

Breaking Cross-Domain Coupling With Outbox Events

Restructuring individual modules into hexagonal layers solved our internal code cleanliness, but we still had to eliminate the forwardRef circular imports between neighboring domain modules. Previously, when an order finished in the Booking Module, it directly injected the Settlement Module to create a pending payout and injected the Notification Module to dispatch a WhatsApp receipt.

We severed those direct module-to-module wires by enforcing asynchronous domain events across bounded contexts. Instead of importing neighboring services, a use case now appends a domain event to the aggregate root and persists it atomically inside a transactional outbox table within its own module boundary. Separate inbound event adapters in the Settlement and Notification modules listen to the message bus and trigger their own isolated use cases when an OrderCompletedEvent arrives. By forbidding direct synchronous service injections across domain boundaries, we deleted every single forwardRef call in the repository and made it trivial to extract high-load modules into standalone deployable containers whenever traffic demands it.

Why Hexagonal Boundaries Pay Off in Production Velocity

Refactoring a live NestJS microservice fleet toward Hexagonal Architecture and Domain-Driven Design requires discipline, and it is certainly overkill for simple CRUD configuration endpoints that just pass JSON straight to a database table. However, for the core engines where your company's actual business rules, pricing logic, and financial state machines live, decoupling your domain from framework decorators is the ultimate insurance policy against technical bankruptcy. When your business logic lives in pure TypeScript and treats databases and HTTP routers as swappable plugins at the outer edge, your architecture stays just as fast to modify in year five as it was on day one.

VW

Written by

Viery Wahyu

📍 Bali, Indonesia

Software Engineer

Software engineer based in Bali with over a decade of experience building fast web applications

The Journal

View all →