The Complete Responsive CMS Blog created by Francesco Malagrino

DDD in TypeScript: Aggregates & Value Objects (the practical version, no religion)

Category: Software Architecture & Written by Francesco Malagrino On February-21-2026 16:23:17

If you build microservices in Node/TypeScript, it’s way too easy to end up with business rules scattered everywhere:



  • a bit in the controller

  • a bit in the service

  • a “similar check” in a Kafka consumer

  • and then a background job that updates state bypassing everything


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:



  1. Aggregates (and the Aggregate Root)

  2. Value Objects


TL;DR (if you’re in a hurry)


 



  • Aggregate Root = the only place invariants must be enforced

  • Value Object = validation + meaning + immutability

  • Keep domain code framework-agnostic

  • Don’t emit domain events during reconstitute()


1) Aggregates: the gatekeeper of rules that must never break


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:



  • you can’t place an order with 0 items

  • quantity must be positive

  • you can’t confirm a cancelled order

  • you can’t deliver before confirmation


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.


2) Domain ≠ Framework: no Nest, no DB, no Kafka imports


A healthy domain layer should not import:



  • @nestjs/*

  • typeorm

  • kafkajs


The domain should depend only on:



  • other domain files

  • a small shared kernel (e.g. DomainError, AggregateRoot, simple types)


This gives you:



  • fast unit tests (just TypeScript)

  • rules reusable from HTTP, Kafka, CLI jobs…

  • insulation from infrastructure changes


If you can’t test your business rules without booting NestJS, your “domain” probably isn’t a domain.

3) Value Objects: stop primitive obsession


Primitives don’t carry meaning.


If quantity is just a number, nothing prevents:



  • -3

  • 0

  • 1.2


A Value Object wraps the primitive and enforces rules at creation time.


Quantity (Value Object)


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



  • no setters

  • valid construction

  • methods that return new instances


4) Money: the place where people always nitpick ðŸ˜…


Yes: floating point + money can bite you.


For demos, rounding is okay, but in production you should prefer:



  • storing money as integer minor units (cents)

  • or using a decimal library


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;
}
}

5) place() vs reconstitute(): the detail that prevents disasters


A key rule when using Domain Events:



  • place() creates a new order ⇒ can emit events

  • reconstitute() rebuilds from the DB ⇒ must not emit events


If you emit events during rehydration, you can do insane things like:



  • charging a customer again every time you load an Order

  • re-triggering a saga just because you ran a query


Recommended pattern:


class Order {
static place(...) { /* validate + create + addEvent */ }
static reconstitute(snapshot: OrderSnapshot) { /* rebuild, NO events */ }
}

6) DomainError ≠ HTTP error


The domain layer shouldn’t know what a 400 or 409 is.



  • domain throws DomainError

  • API layer (controller/filter) maps it to HTTP

  • Kafka consumers map it to “DLQ + log + metrics”


That’s how you keep rules pure and reusable.


When DDD makes sense (and when it doesn’t)


DDD isn’t mandatory. It’s worth it when:



  • rules are non-trivial and evolvin

  • more than one dev touches the service

  • multiple entry points exist (HTTP + Kafka + jobs)

  • you want fast, reliable unit tests


It may be overkill when:



  • CRUD is simple and stable

  • it’s just an admin panel

  • it’s a throwaway prototype


Want to see this applied in a real Kafka + outbox microservices PoC?


Here’s the repo:


https://github.com/Vegetam/microservices-ddd-kafka


Share


Comments

Share your thoughts about this post