If you build microservices in Node/TypeScript, it’s way too easy to end up with business rules scattered everywhere:
The result is predictable:
broken invariants, “impossible” bugs, and tests that require half the universe (DB, NestJS, Kafka…) just to verify one rule.
Domain-Driven Design (DDD) isn’t “more classes for fun.” It’s a design choice:
Business rules live in the domain model, independent from frameworks and infrastructure.
In this post we’ll cover two core building blocks, with TypeScript examples:
reconstitute()An Aggregate is a consistent cluster of domain objects controlled through a single entry point: the Aggregate Root.
Practical rule:
If a rule must always be true, enforce it inside the Aggregate Root.
Example invariants for an Order:
Instead of allowing this from anywhere:
order.status = 'CONFIRMED'…expose intention-revealing methods:
order.confirm()
order.cancel()
order.deliver()That way, the only way to change state is through methods that protect invariants.
A healthy domain layer should not import:
@nestjs/*typeormkafkajsThe domain should depend only on:
DomainError, AggregateRoot, simple types)This gives you:
If you can’t test your business rules without booting NestJS, your “domain” probably isn’t a domain.
Primitives don’t carry meaning.
If quantity is just a number, nothing prevents:
-301.2A Value Object wraps the primitive and enforces rules at creation time.
export class Quantity {
private constructor(public readonly value: number) {
if (!Number.isInteger(value) || value <= 0) {
throw new DomainError(`Quantity must be a positive integer. Got: ${value}`);
}
Object.freeze(this);
} static of(value: number): Quantity {
return new Quantity(value);
}
}Note: Object.freeze() is shallow. Real immutability comes from
Yes: floating point + money can bite you.
For demos, rounding is okay, but in production you should prefer:
Here’s the “cents” approach:
type Currency = 'EUR' | 'USD' | 'CHF';export class Money {
private constructor(
public readonly cents: number,
public readonly currency: Currency
) {
if (!Number.isInteger(cents) || cents < 0) {
throw new DomainError(`Money cents must be >= 0. Got: ${cents}`);
}
Object.freeze(this);
} static of(amount: number, currency: Currency): Money {
const cents = Math.round(amount * 100);
return new Money(cents, currency);
} add(other: Money): Money {
if (this.currency !== other.currency) {
throw new DomainError('Currency mismatch');
}
return new Money(this.cents + other.cents, this.currency);
} multiply(qty: Quantity): Money {
return new Money(this.cents * qty.value, this.currency);
} toNumber(): number {
return this.cents / 100;
}
}place() vs reconstitute(): the detail that prevents disastersA key rule when using Domain Events:
place() creates a new order ⇒ can emit eventsreconstitute() rebuilds from the DB ⇒ must not emit eventsIf you emit events during rehydration, you can do insane things like:
Recommended pattern:
class Order {
static place(...) { /* validate + create + addEvent */ }
static reconstitute(snapshot: OrderSnapshot) { /* rebuild, NO events */ }
}DomainError ≠ HTTP errorThe domain layer shouldn’t know what a 400 or 409 is.
DomainErrorThat’s how you keep rules pure and reusable.
DDD isn’t mandatory. It’s worth it when:
It may be overkill when:
Here’s the repo:
https://github.com/Vegetam/microservices-ddd-kafka
July-27-2026 14:00:48
July-15-2026 14:39:41
July-15-2026 14:35:10