01 / 16 Title
โ†โ†’ navigate ยท O overview ยท F fullscreen ยท G go to
DDD Deep Dive

Domain-Driven Design โ€” Deep Dive

Aggregates, Events, Repositories, Ports & Adapters, and Context Maps in a real system.

Mir Mursalin Ankur
Lead Software Engineer @ Nerddevs Ltd
Northwind, from the Intro deck, keeps growing โ€” every pattern here is introduced with the bug it prevents.
01 ยท Recap

Five Patterns, One Goal: Protect the Model as It Scales

PatternOptimizes forTrade-off
Ubiquitous LanguageShared understandingOngoing collaboration cost
Bounded ContextsModularity at scaleIntegration complexity
Entities + Value ObjectsExpressive modelsMore types + modeling work
Aggregates โ† todayConsistency + invariantsBoundary design is hard
Events + Repositories โ† todayDecoupling + clean domainMore infrastructure + discipline

This deck traces back to a specific source: Eric Evans' 2003 book Domain-Driven Design โ€” Aggregate, Entity, Value Object, Domain Event, and Repository are his tactical patterns by name.

DDD pattern trade-off table
02 ยท The Bug

When Nothing Owns the Rules, Everything Can Break Them

Order lives as a plain data bag. Two different parts of the codebase mutate it directly.

// order.ts โ€” just a data shape, no rules attached
interface Order { id: string; status: "draft" | "placed"; lineItems: LineItem[]; }

// checkout-controller.ts
function addPromoItem(order: Order, item: LineItem) {
  order.lineItems.push(item);   // works even if the order was already placed
}

// admin-panel.ts โ€” written by a different team, six months later
function forceAddItem(order: Order, item: LineItem) {
  order.lineItems.push(item);   // same mistake, independently
}
The real-world outcome
A support agent used the admin panel to "add a free gift" to an order that had already shipped. The warehouse re-picked it, and finance no longer matched the shipped-items count. Two teams, zero communication, same bug.
03 ยท Fix

One Door In โ€” Aggregates & the Aggregate Root

An aggregate is a cluster of domain objects treated as a single consistency boundary, controlled by one aggregate root. Outside code talks only to the root.

class Order {                    // Aggregate Root
  private lineItems: LineItem[] = [];
  private status = "draft";

  addLineItem(item: LineItem) {
    if (this.status !== "draft")
      throw new Error("Cannot modify a placed order");
    this.lineItems.push(item);
  }

  place(): OrderPlaced {
    if (!this.lineItems.length)
      throw new Error("Cannot place an empty order");
    this.status = "placed";
    return new OrderPlaced(this.id, this.lineItems);
  }
}

Both controllers now call order.addLineItem() โ€” the "already shipped" bug is no longer representable in code.

Aggregates diagram โ€” Order and Payments aggregate roots communicating via events
04 ยท The Bug

A Change in One Service Breaks Three Others

OrderService.place() looks harmless โ€” until you see everything it directly calls.

class OrderService {
  async place(order: Order) {
    const event = order.place();
    await inventoryService.reserveStock(event.lineItems);  // direct call
    await emailService.sendConfirmation(event.orderId);    // direct call
    await analyticsService.track("order_placed", event);  // direct call
    await loyaltyService.addPoints(event.orderId);         // added last sprint
  }
}
The real-world outcome
Every new subscriber means editing OrderService again. When reserveStock was renamed during a refactor, OrderService broke โ€” even though the change had nothing to do with placing an order.
05 ยท Fix

Let Other Contexts React โ€” Without Reaching In

A domain event captures something meaningful that already happened. Published outward, it becomes an integration event other contexts subscribe to independently.

class OrderPlaced {
  constructor(public readonly orderId: string, public readonly lineItems: LineItem[]) {}
}

class OrderService {
  async place(order: Order) {
    const event = order.place();
    eventBus.publish(event);   // ONE line โ€” no longer knows who's listening
  }
}

eventBus.on(OrderPlaced, e => inventoryService.reserveStock(e.lineItems));
eventBus.on(OrderPlaced, e => emailService.sendConfirmation(e.orderId));
eventBus.on(OrderPlaced, e => loyaltyService.addPoints(e.orderId));

Growth path, no domain code changes: in-process EventEmitter for an MVP โ†’ Redis pub/sub once split into services โ†’ Kafka once events need replay/audit at scale. Only the adapter under eventBus changes.

06 ยท The Bug

When the Database Leaks Into the Domain

async function place(orderId: string) {
  const rows = await db.query(`SELECT * FROM orders WHERE id = $1`, [orderId]);
  const order = rows[0];
  if (order.status !== "draft") throw new Error("Cannot modify a placed order");
  await db.query(`UPDATE orders SET status = 'placed' WHERE id = $1`, [orderId]);
  // business rule and SQL string, tangled in the same function
}
The real-world outcome
A migration to a different column-naming convention broke every business-rule function that touched orders. Nobody could test "can't place an empty order" without a real database.
07 ยท Fix

Keep the Database Out of the Domain

A repository is a domain-facing interface for loading and saving aggregates โ€” it hides persistence behind domain vocabulary.

interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

async function place(orderId: string, repo: OrderRepository) {
  const order = await repo.findById(orderId);
  const event = order.place();     // pure business logic, from Slide 4
  await repo.save(order);
  eventBus.publish(event);
}

Swap Postgres for DynamoDB โ€” only the concrete PostgresOrderRepository changes. place() is now unit-testable with an in-memory fake, zero database.

08 ยท Architecture

How a Context Is Layered โ€” Everything Points Inward

OrderRepository is a small example of a bigger idea: Ports & Adapters. It's a port Domain/Application owns; PostgresOrderRepository is the adapter plugging into it from Infrastructure.

LayerWhat lives hereFrom this deck
Domain (zero deps)Aggregates, Entities, VOs, EventsOrder, OrderPlaced
ApplicationOrchestrates the domainplace(orderId, repo)
InfrastructureImplements the portsPostgresOrderRepository, eventBus
InterfacesCalls in from outsidepublic/admin/service routes (Slide 14)
The dependency rule
Order and OrderPlaced have never heard of Postgres, Redis, or Express. Infrastructure and Interfaces depend on Domain โ€” never the reverse.
09 ยท Common Confusion

Ownership Matters More Than Physical Separation

Does Order Management need a physically separate database from Inventory? The rule isn't physical separation โ€” a bounded context must be the only thing that ever writes to (and directly reads) its own data.

ArchitectureWhat "database per context" looks like
MicroservicesUsually a genuinely separate physical database per context
Modular monolithOne physical instance is fine โ€” separate schemas, zero foreign keys crossing the boundary
The smell
A SQL join across two bounded contexts' tables means the database has quietly become an unplanned shared kernel. One writer per table, always.
10 ยท The Bug

When a Third Party's Model Leaks Into Yours

// scattered across 6 files, wherever a webhook is handled
function handleWebhook(payload: any) {
  const orderId = payload.order_ref;        // legacy snake_case
  const amount  = payload.amt_cents / 100;  // legacy field, cents not dollars
  const currency = payload.cur;             // legacy abbreviation
}
The real-world outcome
The gateway vendor renamed amt_cents to amount_minor_units in a v2 API. Six files broke, six different ways.
11 ยท Fix

A Translator at Every Boundary โ€” Not Just the Third-Party Edge

// payment-gateway-acl.ts โ€” the ONLY file that knows the vendor's field names
function translateGatewayWebhook(payload: LegacyGatewayPayload): PaymentCompleted {
  return new PaymentCompleted(payload.order_ref, Money.fromCents(payload.amt_cents, payload.cur));
}

// order-management/ProductRef.ts โ€” Order Mgmt's OWN view of Inventory's Product,
// even though Inventory is a fully-trusted in-house team
interface ProductRef { sku: string; available: boolean; }

The rule of thumb reverses what beginners assume: a translated reference is the default at every boundary, in-house or not. Shared Kernel โ€” jointly co-owning a small slice like Money โ€” is the deliberate exception.

12 ยท Synthesis

Two Questions: a Business One, a Coding One

Business โ€” team patterns:

  • Partnership โ€” Order Mgmt & Inventory, both in-house, evolve together via a standing contract review.
  • Customer/Supplier โ€” Inventory (upstream) ships on its schedule; Order Mgmt (downstream) formally requests changes.
  • Conformist โ€” Payments is a vendor; zero negotiating power, isolated behind the ACL.

Coding โ€” explicit contracts only:

  • Synchronous โ€” versioned REST/gRPC, "ask and wait now."
  • Async events โ€” a message bus, "tell me when it happens."
  • Published Language โ€” a jointly-versioned schema (OpenAPI/Protobuf) โ€” the same doc the Partnership meeting reviews.
Language-agnostic by design
Order Management could be TypeScript, Inventory a Go service, Payments a Python wrapper โ€” the contract is the boundary, not shared code. Every sample here is TypeScript-flavored pseudocode for readability, not a TypeScript requirement.
13 ยท Synthesis

Public API, Admin API, Service-to-Service โ€” Same Aggregate

Revisit Slide 3: the bug wasn't that the admin panel was "different code" โ€” it had its own separate door into lineItems instead of the same aggregate as everything else.

// public-api/orders-controller.ts
app.post("/orders/:id/items", (req, res) => order.addLineItem(req.body.item));

// admin-api/orders-controller.ts โ€” a DIFFERENT route, SAME aggregate method
app.post("/admin/orders/:id/items", requireSupportRole, (req, res) =>
  order.addLineItem(req.body.item)   // the invariant fires here too โ€” no bypass
);

Public, Admin, and Service-to-service are three Interfaces-ring adapters (Slide 9) โ€” all funneling into the identical Order aggregate.

14 ยท Synthesis

One Flow, Every Pattern in Its Place

 Public API      Admin API      Service-to-service
 (customer)      (support)        (other contexts)
     โ”‚               โ”‚                   โ”‚
     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                     โ–ผ
           โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   Order.place()         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
           โ”‚ Order (Aggregate โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ โ”‚ OrderPlaced event โ”‚
           โ”‚      Root)       โ”‚  enforces invariants   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
           โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                   โ”‚
                   โ”‚  OrderRepository.save()                      โ”‚  published on event bus
                   โ–ผ                                               โ–ผ
           โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
           โ”‚   Persistence    โ”‚                          โ”‚ Inventory context โ”‚
           โ”‚ (own schema/DB)  โ”‚                          โ”‚ reserves stock     โ”‚
           โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                          โ”‚ (a Go service)     โ”‚
                                                          โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                                                     โ”‚
                                                                     โ–ผ
                                                           โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                                           โ”‚ Payments context   โ”‚
                                                           โ”‚ (via ACL, external โ”‚
                                                           โ”‚  gateway webhook)   โ”‚
                                                           โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Ubiquitous Language named every box the way Northwind would. Bounded Contexts kept "Product" from fighting over meaning. Aggregates + Ports & Adapters stopped the admin-panel bug across all three doors. Events decoupled Inventory โ€” on a different stack โ€” from Order's internals. Repositories + schema boundaries kept SQL and cross-context joins out. The ACL kept a vendor's v2 migration to one file.

15 ยท Wrap-Up

Take It Back to Your Codebase

Remember
Simple CRUD apps, no sustained access to domain experts, short-lived or low-impact projects โ€” DDD does not pay off there. Use the tool where the domain, not the plumbing, is the hard part.
Sources: ByteByteGo ยท Nikki Siapno, Level Up Coding ยท Eric Evans, Domain-Driven Design (2003) ยท โ†— Read the blog ยท โ† Back to Intro deck

All slides โ€” click to jump