This article is published in English.
Six DDD Rules for Structuring Domains in NestJS Apps
Learn six practical domain-driven design rules for organizing NestJS modules, entities, and events so features stay isolated and maintainable.
Half a year into most NestJS codebases, a familiar friction sets in. You add a single field to support one feature, and a test in a completely unrelated corner of the app suddenly fails. Someone on the team asks where the order logic actually lives, and the honest answer is "spread out, a little bit everywhere."
This isn't a sign of sloppy engineering. It's usually a sign that the code was organized around technical layers instead of around the concepts it represents. Full-blown Domain-Driven Design is a dense body of practice that most teams never fully adopt. What follows is a leaner take: six rules that genuinely pay off inside a Nest application, skipping the ceremony that doesn't. Think of it as selective DDD.
Rule 1 — Don't make one model serve the whole app
Nearly every tangled backend has a God object at its center. Often it's called Order, it has dozens of nullable columns, and half the codebase imports it. Eventually a change made for the warehouse team quietly breaks checkout.
That single Order is actually three different concerns hiding under one name:
- Checkout cares about prices, discounts, and a payment intent.
- Fulfillment cares about SKUs and a shipping address, and nothing about discounts.
- Billing cares about an amount and an invoice number.
When one class tries to satisfy all three, a discount field ends up sitting right next to a shipping address. Change one and you risk breaking the other two.
The remedy is to give each area its own model and let them communicate through messages rather than a shared class.
class Cart {
lines: CartLine[];
discount: Money;
paymentIntentId: string;
}
class FulfillmentOrder {
orderId: string;
shipTo: Address;
picks: Pick[];
}
this.events.emit(new OrderPlaced(order.id, order.shipTo, picks));
Notice that FulfillmentOrder is identified only by orderId and holds no pricing or discount data at all — checkout passes fulfillment a fact, the OrderPlaced event, instead of exposing its own internal class. That means a pricing change can never leak into the warehouse logic just because the compiler allows it. Each of these areas forms a bounded context: its own model, where the word "order" means one precise thing. In a monolith, that might just be a set of modules that own their own database tables; in a microservices setup, it could be an entirely separate service. Either way, the rule holds — never share a model across the boundary.
A useful test is this: a change inside one context should never force you to edit another. If it does, your boundaries are drawn in the wrong place.
Rule 2 — A module is a domain, not a layer
Imagine your product manager asks for gift-wrapping support on orders. Look at what that costs when the project is organized by technical role instead of by subject matter:
src/
├── controllers/ # order, auth, product, shipment, payment...
├── services/ # order, auth, product, shipment, payment...
├── entities/
└── enums/ # every enum in the whole app
You open the controllers/ folder and scroll past authentication and shipping to find the order controller. Then you repeat that scroll in services/, and again in entities/ and enums/. Four or five folders, four or five scrolls, and the single feature you're adding is scattered across every one of them.
That layout answers the question "show me every controller," which almost nobody actually asks. The question people really have is "show me everything related to orders." So structure the code by domain first:
modules/orders/
├── controllers/
├── dto/
├── entities/
│ ├── order.entity.ts
│ └── order-status.enum.ts # the enum sits next to what it uses
├── repositories/
└── orders.module.ts
With this layout, gift-wrapping touches exactly one folder. Notice there is no catch-all enums/ directory at the top level — an enum belongs next to the thing it describes. The one guardrail to keep: common/ should only hold things that belong to no domain at all, like pagination helpers or a base repository class. The instant common/ starts to know what an order is, it has effectively become another module in disguise.
Rule 3 — Modules should depend on interfaces, not on each other's services
Imagine two features shipping in the same sprint. The product page needs to show "3 open orders," so catalog reaches in and injects OrderService. Meanwhile the receipt needs product names, so orders injects ProductService. Nest refuses to wire this up:
Nest cannot create the CatalogModule instance.
- A circular dependency between modules. Use forwardRef() to avoid it.
Wrapping the injection in forwardRef() silences the error, but it also permanently fuses the two modules together. The actual solution is to depend on a small interface that you define yourself, instead of reaching into another module's service.
catalog needs to block deletion of a product that still sits inside an open order — but only orders has that information. Rather than importing orders, catalog simply defines the question it needs answered:
export interface ProductUsageGuard {
isProductInUse(productId: string): Promise<boolean>;
}
The orders module supplies the answer by implementing that interface and registering itself, so catalog can ask the question without ever importing anything from orders:
for (const guard of this.guards) {
if (await guard.isProductInUse(id)) throw new ProductInUseError(id);
}
Before: catalog ⇄ orders circular — Nest won't boot
After: catalog ◄──implements── orders one way — catalog owns the interface
The dependency now points in a single direction, so there's no cycle and no need for forwardRef(). As a bonus, if a rule like "can't delete a product tied to an active subscription" shows up later, the subscriptions module can register its own guard, and catalog doesn't need to change at all.
Rule 4 — Keep controllers thin and let entities carry the logic
Consider a rule like "you can't cancel an order that has already shipped." Where should that logic live? In many codebases it ends up wherever it was first needed — typically buried inside a service. Then the admin dashboard needs the same check, and so does the nightly batch job, and eventually a webhook handler too. Each place reimplements the rule slightly differently, someone forgets the fourth copy, and suddenly shipped orders are getting refunded.
When an entity is nothing more than a bag of public fields that other code mutates directly, you get an anemic model — and the symptom is always the same: business rules leak out and get copy-pasted across every service that touches the data.
Instead, attach the rule to the object that actually owns the state:
@Entity()
export class Order {
status: OrderStatus = OrderStatus.DRAFT;
cancel(): void {
if (this.status === OrderStatus.SHIPPED) {
throw new Error('Cannot cancel an order that already shipped');
}
this.status = OrderStatus.CANCELLED;
}
Now there is exactly one place where "cancel" is defined, and no caller has any way to bypass the check — there's simply no alternate path. It's trivial to unit test without touching a database. The service layer just orchestrates the steps (order.cancel(), issue the refund, persist), while the controller shrinks to almost nothing:
@Post(':id/cancel')
cancel(@Param('id') id: string) {
return this.orders.cancel(id);
}
Here's the division of responsibility in one picture:
HTTP ─► Controller ─► Service ─► Order (the rules)
└─────► Repository ─► DB (the queries)
Entities enforce the rules, repositories handle queries, services coordinate the sequence of calls, and controllers deal exclusively with HTTP.
Rule 5 — Design your data so invalid states can't exist
Rule 4 pushed logic onto entities. Two more patterns finish the job, and each one closes off a specific class of bug.
Value objects handle a primitive that carries rules. An order total is just a number, which means nothing prevents a coupon from pushing it below zero, or a refund in EUR from landing on an order billed in USD. The rules that define "money" don't live anywhere in particular. Fix that by giving money its own type that enforces those rules:
export class Money {
private constructor(readonly cents: number, readonly currency: string) {}
static of(cents: number, currency: string): Money {
if (cents < 0) throw new Error('Money cannot be negative');
return new Money(cents, currency);
}
add(o: Money): Money {
if (o.currency !== this.currency) throw new Error('Currency mismatch');
return Money.of(this.cents + o.cents, this.currency);
}
}
With this in place, a negative total or a currency mismatch can no longer be constructed — the type itself blocks it. This is what's called a value object: a small, immutable type identified by its value rather than by an id. Use one whenever a primitive comes bundled with rules you find yourself checking again and again — money, email addresses, CIDR ranges — but skip it for something as plain as a bare identifier.
Aggregates handle a rule that spans several objects. An order's total needs to always match the sum of its lines. If OrderLine gets its own repository, sooner or later someone will persist a line without updating the parent order, and the total goes quietly wrong. The fix is to never allow that path in the first place: make Order the aggregate root — the single object you ever load or save, the one entry point into that piece of the model. There's no OrderLineRepository; lines are only ever modified through the order itself:
addLine(sku: string, price: Money, qty: number): void {
if (this.status !== OrderStatus.DRAFT) throw new Error('Order already placed');
this.lines.push(new OrderLine(sku, price, qty));
this.total = this.sumOfLines();
}
With one entry point, the invariant can't be violated by accident. Keep your aggregates as small as possible — only what genuinely needs to change within the same transaction — and refer to other aggregates by id rather than holding direct object references.
Rule 6 — Publish events instead of calling services directly
Checkout starts simple, then place() keeps growing: save the order, call shipping, call billing, send an email. At that point orders is importing half the application and has to know about every downstream step. Add a loyalty-points feature next quarter, and you're back editing the checkout module for something that has nothing to do with checkout.
Reverse the direction. orders does its own job and then announces what happened — it has no idea who, if anyone, is listening:
this.events.emit(new OrderPlaced(order.id, order.customerId, items));
Each interested context reacts independently:
@OnEvent(OrderPlaced.name)
handle(e: OrderPlaced) { return this.shipping.createShipment(e); }
Adding loyalty points now means adding a listener inside the loyalty module; orders stays untouched. Across separate services the same idea applies over a message broker (RabbitMQ, for example), with one extra safeguard: a transactional outbox. You write the event into an outbox table inside the same transaction that saves the order, and a separate worker publishes it afterward. Without that step, a crash between saving the order and publishing the event silently drops the event — in production, that's the gap between a reliable system and a haunted one.
One caution: events obscure the overall flow, since no single place shows the full sequence of what happens. Reserve them for reactions that cross context boundaries, not for steps that belong inside one cohesive job.
The payoff
Applying DDD selectively isn't about layering on more architecture. It's about putting each piece of logic where it belongs: models own the rules, modules own their domains, repositories own queries, and events connect one context to another. Stick to these boundaries, and your NestJS application stays easier to understand, modify, and grow over time.