← All posts

Building Domain-Driven Services: Lessons from the Trenches

DDD Architecture Microservices

Domain-Driven Design is one of those concepts that sounds straightforward in a conference talk but gets messy the moment you open your IDE. After applying DDD across payment platforms, e-commerce systems, and SaaS products, here are the patterns that actually stick.

Start with the Domain, Not the Database

The most common mistake I see teams make is designing their database schema first and then trying to map domain concepts onto it. This leads to anaemic domain models — entities that are little more than data bags with getters and setters.

Instead, start by talking to domain experts. Model the language they use. If your accounting team talks about “ledger entries” and “reconciliation runs”, those should be first-class concepts in your code:

class LedgerEntry {
  constructor(
    private readonly id: EntryId,
    private readonly amount: Money,
    private readonly account: AccountRef,
    private readonly timestamp: Instant,
  ) {}

  reconcileWith(counterpart: LedgerEntry): ReconciliationResult {
    if (!this.amount.equals(counterpart.amount.negate())) {
      return ReconciliationResult.mismatch(this, counterpart);
    }
    return ReconciliationResult.matched(this, counterpart);
  }
}

The behaviour lives on the entity, not in a service class three layers away.

Bounded Contexts Are About Teams, Not Technology

A bounded context is not a microservice. It is a linguistic boundary — the space within which a term has a single, unambiguous meaning. “Account” means something different to the billing team than it does to the identity team.

The technology boundary (service, module, package) should follow the linguistic boundary, not the other way around. When you let deployment topology drive your domain model, you end up with distributed monoliths — the worst of both worlds.

Aggregate Design: Keep Them Small

The aggregate is your consistency boundary. Everything inside it must be consistent after every operation. Everything outside it is eventually consistent.

The practical implication: make aggregates as small as possible. A common anti-pattern is the “god aggregate” that tries to enforce consistency across too many concepts:

// ❌ Too large — locks too much data, hard to scale
class Order {
  items: OrderItem[];
  payment: Payment;
  shipping: ShippingDetails;
  invoice: Invoice;
}

// ✅ Better — separate aggregates, linked by ID
class Order {
  items: OrderItem[];
  paymentId: PaymentId;
  shippingId: ShippingId;
}

Each aggregate should be independently loadable, independently persistable, and independently testable.

Events as the Connective Tissue

Domain events are how aggregates communicate without coupling. When an Order is placed, it emits an OrderPlaced event. The payment service reacts to that event — it does not get called directly by the order service.

This pattern gives you:

The key insight is that events should describe what happened in business terms, not what changed in the database. InvoiceFinalised is a good event name. InvoiceRowUpdated is not.

Wrapping Up

DDD is not a framework you install. It is a way of thinking about software that keeps your code aligned with the problem it solves. Start small — pick one bounded context, model it carefully, and iterate from there.

The best codebases I have worked on are the ones where a new developer can read the code and understand the business domain without needing a separate wiki.