GAP·MAP

Domain-driven design

[ middle depth ]

One word can support two valid models

Suppose an Identity team uses Account for credentials and login status. A Billing team uses Account for a contractual relationship, payment terms, and balance. Combining those meanings into one large Account model produces fields and rules that make sense to only half of its callers.

A bounded context states where one model applies. The Identity and Billing contexts can model Account differently. Each context also has a ubiquitous language, a vocabulary that domain experts and developers use consistently in conversations, documentation, and code.

// Identity context
type Account = {
  accountId: string;
  loginStatus: "active" | "locked";
};

// Billing context
type Account = {
  billingAccountId: string;
  paymentTermsDays: number;
};

The repeated name is acceptable because the context supplies its meaning. The integration between contexts needs an explicit mapping instead of a shared class that silently mixes both models.

Wrong "Ubiquitous language means the whole company must use one definition of Account."

Right The language is unified within an explicitly bounded context. Another context may use the same word with a different model.

Entities and value objects answer different sameness questions

An entity has identity that continues through time even while its attributes change. Two customer records with identical names are still different customers when the domain distinguishes their identities. Conversely, a renamed customer remains the same entity.

A value object has no conceptual identity. Its defining values determine equality, so two money values with the same amount and currency are interchangeable. DDD treats value objects as immutable and gives them side-effect-free operations that return new values.

type Money = Readonly<{
  amountInMinorUnits: number;
  currency: string;
}>;

function add(left: Money, right: Money): Money {
  if (left.currency !== right.currency) {
    throw new Error("Currencies must match");
  }

  return {
    amountInMinorUnits: left.amountInMinorUnits + right.amountInMinorUnits,
    currency: left.currency,
  };
}

This classification comes from the domain. A database primary key does not by itself turn a domain concept into an entity. An address may be a value in one context and an entity in another context that tracks a particular address record over time.

Aggregates protect invariants

An aggregate is a cluster of entities and value objects with a boundary for consistency. One entity is the aggregate root. Outside code holds references to the root and requests changes through it. The root, or a designated mechanism, enforces properties and invariants for the aggregate as a whole.

For an Order aggregate, the rule "the total may never be negative" belongs inside the boundary. A caller should not be able to mutate line items behind the root's back.

class Order {
  readonly orderId: string;
  #lines: Array<{ productId: string; quantity: number; unitPrice: number }> = [];

  constructor(orderId: string) {
    this.orderId = orderId;
  }

  addLine(productId: string, quantity: number, unitPrice: number): void {
    if (quantity <= 0 || unitPrice < 0) {
      throw new Error("The line would violate order rules");
    }

    this.#lines.push({ productId, quantity, unitPrice });
  }
}

The example demonstrates the access rule, not a complete order model. The business decides the real invariants and therefore the boundary. Database joins, object graphs, and "things related to an order" are not sufficient tests.

Wrong "An aggregate is all data loaded on the order screen."

Right An aggregate groups the state that must satisfy its rules together. Read models may join data from several aggregates without changing those write boundaries.

Domain events name meaningful changes

A domain event represents something that happened which domain experts care about, such as OrderCancelled. A row insertion is a technical occurrence and does not automatically qualify. Aggregates can raise domain events after changing state so other parts of the domain can react.

Events also help coordinate work across aggregate boundaries. The originating aggregate keeps its own invariants consistent. Reactions in other aggregates can happen asynchronously when the business permits eventual consistency.

Do not collapse a domain event into an integration message without considering the transaction. Microsoft distinguishes in-process domain events from integration events sent to other bounded contexts or systems. An integration event should represent a committed change, so it is published only after the originating persistence succeeds.

[ senior depth ]

Strategic DDD comes before object patterns

DDD has a strategic and a tactical part. Strategic design identifies subdomains, bounded contexts, and the relationships between their models. Tactical design applies patterns such as entities, value objects, aggregates, domain services, and domain events inside a bounded context.

This ordering matters. A polished aggregate built inside a confused model boundary still mixes languages and ownership. Start by naming each model in play, drawing its boundary, and recording where contexts share information or require translation. Eric Evans calls this a context map.

Wrong "DDD is a folder layout with entities, repositories, and services."

Right Those are tactical building blocks. DDD also addresses which model applies where and how different models relate.

The same term can mean different things across boundaries. An Identity account and a Billing account do not need a shared class. Their integration contract should carry the information required at the boundary, with translation into each context's own language.

// Integration contract emitted by Identity after its change commits.
type LoginAccountLocked = {
  identityAccountId: string;
  occurredAt: string;
};

// Billing translates the external identifier into its own model.
type BillingAccountLink = {
  identityAccountId: string;
  billingAccountId: string;
};

Aggregate boundaries guide transaction boundaries

Evans's aggregate pattern defines a boundary around entities and value objects, with one entity as the root. Under that rule, consistency rules apply synchronously inside the boundary and updates across boundaries are handled asynchronously. Microsoft notes that transaction scope across aggregates is debated: a single transaction can cover related aggregates when the business requires atomicity and the storage technology supports it. If that need recurs, reconsider whether the aggregate boundary models the invariant correctly.

An invariant therefore drives the write boundary. If reserving an order line and recalculating the order total must succeed or fail together, those changes belong under the same root. A customer profile does not join the Order aggregate merely because the order refers to a customer. The order can hold a customer identity.

type OrderLine = Readonly<{
  productId: string;
  quantity: number;
  unitPrice: number;
}>;

class Order {
  #lines: OrderLine[] = [];

  replaceLines(next: OrderLine[]): void {
    const total = next.reduce(
      (sum, line) => sum + line.quantity * line.unitPrice,
      0,
    );

    if (total < 0) {
      throw new Error("Order total cannot be negative");
    }

    this.#lines = [...next];
  }
}

Large aggregates widen the scope of synchronous consistency and can create needless contention. Tiny aggregates can push a rule that requires immediate consistency across boundaries where only asynchronous coordination remains. When the transaction boundary repeatedly fights the model, revisit the model instead of treating aggregate size as a fixed formula.

Domain events are facts, but delivery has another contract

OrderCancelled expresses a domain-significant fact in past tense. It can trigger side effects in other aggregates without putting notification or persistence infrastructure inside the aggregate. Microsoft's guidance typically puts handlers that use repositories or application APIs in the application layer.

An event used inside one bounded context and an event published to another system may describe the same occurrence, but their implementations differ. The external integration event must follow successful persistence. Publishing first creates a false fact if the originating transaction later rolls back.

async function cancelOrder(orderId: string): Promise<void> {
  const order = await orders.get(orderId);
  const domainEvents = order.cancel();

  await unitOfWork.commit(order);

  for (const event of toIntegrationEvents(domainEvents)) {
    await integrationPublisher.publish(event);
  }
}

This code shows publish-after-commit ordering, not a complete reliability solution. The cited guidance permits message brokers and durable mailbox-style infrastructure for inter-process delivery. It does not make a plain loop atomic with the database commit.

Wrong "Once an aggregate raises a domain event, remote consumers are guaranteed to receive it."

Right Raising a domain event represents a domain fact. Reliable inter-process delivery requires infrastructure and must not announce a transaction that failed.

When DDD costs more than it returns

Microsoft's microservice guidance uses different internal architectures for different subsystems. Its simple catalog, basket, and profile examples use straightforward CRUD designs, while the ordering subsystem receives advanced DDD patterns because its business rules are complex and change often.

Use the same cost test inside a monolith. Strategic work can still clarify language and model boundaries, but a simple maintenance screen may not benefit from aggregate, repository, and event machinery. DDD targets complex software and asks teams to focus modeling effort on the core domain.

A bounded context is a model boundary, often aligned with a subsystem or a team's work. It is not a command to deploy one microservice. Microsoft describes a bounded context as a microservice candidate and gives aggregate-to-context sizing as a general principle for microservice design. Deployment boundaries still depend on system requirements, coupling, and team ownership.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Architecture

was this useful?