DDD Tactical Patterns in Code

Entities, value objects, aggregate sizing, and repository design as they actually showed up in TypeScript and Ruby during a portfolio-wide DDD migration.

The clearest memory I have of “wait, this is what DDD is for” is the second month of the portfolio migration at the London product agency I led at. A teammate had a Money field on an Order modeled as number. He’d added partial refunds, then QA found an order showing a total of 49.99999999999998. He stared at it and said, “I don’t even know where the rounding happened.” Yeah. That’s the day the tactical patterns earned their keep.

So this is about the small stuff. Entities, value objects, aggregates, repositories. The pieces of DDD you actually write code for, not the strategic stuff with the sticky notes on the wall. I’ll lean on TypeScript because most of the agency rewrites landed there, but a few of these I learned the hard way in Ruby first.

Value objects do more than wrap primitives

The cheapest DDD win is also the most underused. Wrap your domain primitives. Not because it’s “clean code”, but because the moment you give Money a class, you stop reasoning about float math and start reasoning about currency.

// domain/shared/money.ts
export class Money {
  private constructor(
    readonly amountMinor: number,
    readonly currency: "USD" | "EUR" | "GBP" | "TRY",
  ) {}

  static of(major: number, currency: Money["currency"]): Money {
    if (!Number.isFinite(major)) throw new Error("invalid amount");
    const minor = Math.round(major * 100);
    return new Money(minor, currency);
  }

  add(other: Money): Money {
    if (other.currency !== this.currency) throw new Error("currency mismatch");
    return new Money(this.amountMinor + other.amountMinor, this.currency);
  }

  subtract(other: Money): Money {
    return this.add(new Money(-other.amountMinor, this.currency));
  }

  equals(other: Money): boolean {
    return this.amountMinor === other.amountMinor && this.currency === other.currency;
  }
}

Two things this gives you for free. One, the constructor is private, so Money.of is the only way in, and it has to make a decision about rounding. Two, add enforces that you cannot add USD and EUR. That last one alone has caught more bugs in code review than any linter rule I’ve ever written.

The other rule that matters: value objects compare by value, not reference. Two Money.of(9.99, "USD") instances are equal. Entities are the opposite. Same customerId, same person, even if the field values drift.

Entities are identity, aggregates are consistency

An entity has an id. That’s the whole definition. Two Order instances with the same id are the same order, even if their internal state has diverged because one was loaded before a save and one after.

An aggregate is a boundary, not a class. It’s the set of objects that have to be consistent at the moment of a write. The aggregate root is the only thing the outside world touches. If you want to mutate a line item, you go through the order.

Here’s where teams trip. They draw the aggregate too big. “An Order has Customers and Inventory and Payments and a Shipping Address.” Now your “Order” aggregate spans four tables and any change anywhere takes a row lock on the customer’s entire history. I’ve seen this twice. Both times the fix was the same: shrink the aggregate.

// domain/orders/order.ts
import { Money } from "../shared/money";
import { OrderLine } from "./order-line";

export class Order {
  private events: DomainEvent[] = [];

  private constructor(
    readonly id: OrderId,
    readonly customerId: CustomerId,
    private status: "draft" | "confirmed" | "cancelled",
    private lines: OrderLine[],
  ) {}

  static draft(customerId: CustomerId, lines: OrderLine[]): Order {
    if (lines.length === 0) throw new Error("order must have at least one line");
    return new Order(OrderId.new(), customerId, "draft", lines);
  }

  total(): Money {
    return this.lines.reduce((acc, l) => acc.add(l.subtotal()), Money.of(0, "USD"));
  }

  confirm(): void {
    if (this.status !== "draft") throw new Error("only drafts can be confirmed");
    this.status = "confirmed";
    this.events.push({ kind: "OrderConfirmed", orderId: this.id });
  }

  pullEvents(): DomainEvent[] {
    const out = this.events;
    this.events = [];
    return out;
  }
}

The Order aggregate owns its lines and its status. It doesn’t own the customer. It references the customer by id. Same with inventory. If a write needs to touch both inventory and the order, that’s two aggregates, two repositories, one application service, and an eventually-consistent integration between them, not one fat root.

The rule I tell my teams: an aggregate is a transaction boundary. If you’d lock more than one of these for a single user action, you’re treating them as one aggregate even if they live in different files.

Repositories save aggregates, not rows

A repository is a collection of aggregates. That’s it. It does not have findByEmail, findActive, searchWithFilters, getTopTen. Those are queries, and queries belong to a read model, not a write model. I learned this slowly. The first DDD-flavored Rails apps I wrote had OrderRepository#mostRecentForCustomer and I kept telling myself it was fine because Active Record. It was not fine.

// domain/orders/order.repository.ts
import { Order } from "./order";
import { OrderId } from "./order-id";

export interface OrderRepository {
  load(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

Three methods at most. load, save, sometimes nextId if you want id generation in the domain. Everything else is a query, written in plain SQL or a query object, and pointed at a read-optimized projection. Mixing the two is how you end up with a “repository” that has thirty methods and no one can tell which ones are for the write path.

// infrastructure/orders/typeorm-order.repository.ts
import { Repository } from "typeorm";
import { OrderRow } from "./order.row";
import { Order } from "../../domain/orders/order";
import { OrderId } from "../../domain/orders/order-id";
import { toDomain, toRow } from "./order.mapper";

export class TypeOrmOrderRepository implements OrderRepository {
  constructor(private readonly orm: Repository<OrderRow>) {}

  async load(id: OrderId): Promise<Order | null> {
    const row = await this.orm.findOne({ where: { id: id.value }, relations: ["lines"] });
    return row ? toDomain(row) : null;
  }

  async save(order: Order): Promise<void> {
    await this.orm.save(toRow(order));
  }
}

The mapper is dumb on purpose. Domain in, row out, no business rules. Anything smarter than that, and your persistence layer starts making decisions the domain should be making.

War story: the aggregate that ate everything

A nightlife discovery and ticketing product we built at the agency. A teammate modeled the Event aggregate to include the venue, the lineup, every ticket type, every promo code, and every customer comp list. Big root. On a Friday night a promoter was editing a ticket type and another was editing a promo code on the same event. PostgreSQL serialized the transactions, one of them lost a write, and a ticket type silently rolled back. We learned about it from the box office the next morning when the price on the door was different from the price online.

First wrong fix was to add a column-level optimistic lock and tell the losing promoter to retry. Worked technically. The promoters hated it because their edits were unrelated. The real fix took a sprint. We split the aggregate: TicketCatalog, PromoBook, and Event ended up as three roots referring to each other by id. Different writes, different locks, no false contention. The lesson is one I repeat at every team I join. Aggregate size is a write-contention decision before it is a modeling decision.

War story: refunds that never balanced

This one is from the partial-refunds bug I opened with. Same agency, the boutique fitness product. Refunds were being computed in three places: a Rails controller, a background job, and a webhook from the payment provider. All three did float arithmetic on the order total. We had orders whose remaining balance was off by a fraction of a cent. Small enough to ignore for weeks. Then a customer disputed the difference and accounting needed to reconcile.

First wrong fix was a round call wherever the bug showed up. That just moved the problem. The real fix was a single Money value object, with all amounts in integer minor units, and a hard rule that no business logic outside the domain layer was allowed to do arithmetic on a price. Took two days to migrate the codebase, but the kicker was the migration script: we backfilled every Order’s total from its lines using the new Money math, and 11 percent of historical orders had a delta of one or more cents. Eleven percent. Quietly wrong, for months. The runbook now leads with “never represent money as a float, not even in a DTO.”

Takeaways

  • Wrap primitives in value objects. Money first, then ids, then anything else where two raw values mean the same thing.
  • Identity for entities, equality for value objects. They are not the same kind of object and treating them the same hides bugs.
  • Aggregates are transaction boundaries. Pick the smallest one that keeps a single user action consistent. If you’d lock multiple for one write, they’re one aggregate, sorry.
  • Repositories save aggregates and load aggregates. Queries are a separate concern with a separate model.
  • Float math has no place in a domain that handles money. Integer minor units, value object, no exceptions.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.