Skip to content
Corentin GS

DDD Aggregate Boundaries and Persistence in TypeScript

№33 · · ·Updated ·1925 words ·10 min read
DDD Aggregate Boundaries and Persistence in TypeScript
In this piece

TL;DR

  • An aggregate is a transactional consistency boundary. Its root is an entity and the only object external code uses to change the aggregate.
  • A repository represents an aggregate root in the domain model. It does not represent a table, an ORM model, or every entity class.
  • One aggregate may map to several tables. A foreign key is an infrastructure constraint, not a domain navigation property.
  • Bounded contexts may model the same real-world customer differently. Synchronization between contexts is a separate integration problem.

The previous articles — Value Objects & Entities and Aggregates & Repositories in TypeScript in particular — made a few choices without naming them: Order owns its lines, only OrderRepository persists them, and a customer appears in an order as an ID rather than as a loaded object.

In TypeScript DDD, aggregate boundaries define which changes must remain consistent in one transaction. They are choices about where invariants live and which parts of the system change together.


What Is an Aggregate Root in DDD?

Aggregate Boundaries: Order is the root; OrderLine stays inside the boundary

An entity has continuity through identity. An aggregate is a cluster of domain objects that must be kept consistent in one transaction. The aggregate root is the entity through which external code reaches that cluster.

OrderLine can have an ID and still not be an aggregate root. If its quantity, price, and lifecycle are governed by Order, then external code should not load a line and mutate it independently.

The useful questions are practical rather than taxonomic:

QuestionLikely implication
Does it need its own identity?If not, it is probably a value object.
Can it exist without the proposed parent?If not, it may be an internal entity.
Must changes to it be consistent with the parent immediately?If yes, keep it in the same aggregate.
Is it changed independently under its own business rules?If yes, consider a separate aggregate root.
Do other aggregates need to identify it?Reference the root by ID, not an internal object.

These are heuristics, not a scoring system. For immediate consistency within an aggregate, ask: which invariant must be true when this transaction commits? Cross-aggregate processes can instead reconcile their state asynchronously.

For the running example, the maximum order total spans an order and its lines. Order is therefore the root that adds, removes, and prices lines. A Product or Customer has an independent lifecycle and is a separate aggregate.

class Order {
	private constructor(
		private readonly id: OrderId,
		private readonly customerId: CustomerId,
		private lines: OrderLine[],
		private status: OrderStatus
	) {}

	addLine(
		product: ProductId,
		quantity: number,
		unitPriceCents: number
	): Result<void, OrderError> {
		const nextTotal = this.getTotalCents() + quantity * unitPriceCents;
		if (nextTotal > 100_000) return err({ type: "maximum-total-exceeded" });

		const line = OrderLine.create(product, quantity, unitPriceCents);
		if (!line.ok) return line;

		this.lines.push(line.value);
		return ok(undefined);
	}
}

The real boundary is the rule that no application service loads an OrderLine directly, changes it, and persists it around the Order.


A Repository Represents a Root, Not a Table

Repository ≠ Table: one repository can persist one aggregate across several tables

Per Eric Evans and Vaughn Vernon, a repository works with aggregate roots — collection-like abstractions over the domain’s consistency boundaries. Martin Fowler’s Repository pattern makes the same separation between domain access and storage mechanics.

That gives a useful default:

interface OrderRepository {
	getById(id: OrderId): Promise<Result<Order, OrderError>>;
	add(order: Order): Promise<Result<void, OrderError>>;
	update(id: OrderId, change: UpdateOrder): Promise<Result<Order, OrderError>>;
}

There is no OrderLineRepository because OrderLine is not independently loaded or changed. The repository rehydrates Order, the callback invokes its behavior methods, and the adapter persists the changed root. A repository for lines would create a path around the root’s total-price and status invariants.

This is a conceptual rule, not a requirement to put every method in one enormous TypeScript interface. Separate read and write ports can be clearer in a larger application:

interface LoadOrder {
	getById(id: OrderId): Promise<Result<Order, OrderError>>;
}

interface UpdateOrderRepository {
	update(id: OrderId, change: UpdateOrder): Promise<Result<Order, OrderError>>;
}

The constraint remains the same: those ports operate on the Order root, not on its internal rows.

Read Models Are Different

An order-history screen might need order number, customer name, shipment state, and payment state. Loading several aggregates merely to render that screen is wasteful.

Use a query service or read model for that job. It can join tables, call other services, or read a projection. It is not an aggregate repository because it does not rehydrate an aggregate for behavior.


One Aggregate, Several Tables

Domain boundaries and relational-table boundaries answer different questions. This is a normal relational mapping for one order aggregate:

CREATE TABLE orders (
    id              UUID PRIMARY KEY,
    customer_id     UUID NOT NULL REFERENCES customers(id),
    status          TEXT NOT NULL,
    total_cents     INTEGER NOT NULL,
    version         INTEGER NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL,
    updated_at      TIMESTAMPTZ NOT NULL
);

CREATE TABLE order_lines (
    id               UUID PRIMARY KEY,
    order_id         UUID NOT NULL REFERENCES orders(id),
    product_id       UUID NOT NULL,
    quantity         INTEGER NOT NULL,
    unit_price_cents INTEGER NOT NULL
);

CREATE TABLE order_status_history (
    id          UUID PRIMARY KEY,
    order_id    UUID NOT NULL REFERENCES orders(id),
    from_status TEXT,
    to_status   TEXT NOT NULL,
    changed_at  TIMESTAMPTZ NOT NULL
);

The orders and order_lines rows map to the aggregate’s current state. total_cents is a denormalized cache of the lines: the adapter writes it from them and verifies it before reconstituting the aggregate. order_status_history is an append-only audit log. It is not event sourcing: in an event-sourced model, the events would be the authoritative state and the current status would be a projection.

OrderRepository may read and write all three tables in one local transaction. That does not make the repository “three repositories in one.” It is one persistence adapter for one aggregate.

Foreign Keys Do Not Violate DDD

The orders.customer_id foreign key is compatible with an Order domain class that only stores CustomerId.

class Order {
	// The domain has an ID, not a navigation property to Customer.
	private readonly customerId: CustomerId;
}

When Order and Customer share a database, a foreign key protects referential integrity and closes a race between “customer exists” and “insert order.” It does not give the domain permission to change Customer from inside Order.

When the aggregates belong to different services or databases, a cross-database foreign key is unavailable. The system then needs integration checks, reconciliation, and an explicit policy for missing or deleted references.


Mapping Is an Adapter Responsibility

Bounded Contexts &#x26; Workflows: different models locally; coordination happens through messages

The repository interface speaks domain language. The adapter translates relational shapes to domain shapes and back.

Keep the mapper boring and fail fast when stored data cannot produce a valid aggregate. It reads the orders row and its order_lines, reconstitutes each line, then reconstitutes the Order. On a write, it does the reverse.

Do not filter invalid rows and continue. A partial aggregate can pass a business check while silently losing data. Return a persistence error, alert on it, and repair the stored record.

Construction and Reconstitution Are Different

Order.create() handles a new business action. It can establish initial state, apply creation policy, and set defaults.

Order.reconstitute() restores previously persisted state. It should validate structural invariants, such as valid statuses and non-negative quantities. It should not replay creation side effects or reject historical states merely because new orders can no longer be created that way.

Neither factory should emit an integration message. Persistence rehydration is not a new domain event.


References Across Aggregates

An order can refer to a customer by ID without loading a customer object:

class Order {
	constructor(private readonly customerId: CustomerId) {}
}

If a use case requires the customer’s credit status, the application service decides whether to load Customer, query a read model, or ask an external service. That keeps the Order model from carrying a stale object graph and makes the cross-aggregate dependency visible where the use case lives.

Reference by ID is a default. A small immutable value copied into an aggregate, such as a shipping address captured at checkout, is often exactly the right model. The question is whether it has an independent lifecycle and invariant set.


Bounded Contexts Change the Model

A bounded context is a model and language boundary. An aggregate is a transactional boundary inside that context. They solve different problems.

Sales and Support may both speak about a customer, but their models can legitimately differ:

SalesCustomer owns credit, purchases, and pricing rules. SupportCustomer owns SLA, escalation, and ticket routing. The duplication cost is mapping, tests, and the risk that the two copies drift. The sharing cost is coupling, coordinated releases, and irrelevant fields leaking into each context. Pick the cost that matches the relationship between the teams and the language.

An Anti-Corruption Layer prevents the supplier’s domain model from leaking into the local context when one context consumes another context’s API or events.


Cross-Aggregate Workflows

The previous article showed a repository update for one aggregate. Placing an order can also reserve inventory and authorize payment. The correct coordination model depends on the consistency requirement and deployment boundary.

SituationTypical choice
One database, immediate all-or-nothing business requirementA local transaction, with deliberate lock ordering
One database, temporary inconsistency is acceptableCommit the order and publish an event through an outbox
Separate services or databasesA durable asynchronous process with retries and compensation

For the third case, use the word saga precisely. A saga is a sequence of local transactions with recovery behavior. It needs durable state, correlation IDs, idempotent handlers, and a policy for compensation failure.

For example, submitting an order can request inventory without loading Inventory inside Order. This is an orchestrated saga because the process manager keeps durable state and drives recovery:

1. SubmitOrder commits the Order and an OrderSubmitted outbox record together.
2. An outbox publisher delivers OrderSubmitted after that commit.
3. ReserveInventory deduplicates the message and handles it in Inventory's own transaction.
4. It commits inventory and an InventoryReserved or InventoryRejected outbox record with the same correlation ID.
5. The process manager deduplicates that result, then retries, cancels the order, or requests payment.

OrderSubmitted is an integration event: a deliberately stable message contract for another aggregate or service. A domain event expresses a fact within its bounded context; it can be delivered synchronously or asynchronously, but it is not automatically an external contract. Do not make the order wait for inventory unless the business rule requires one database transaction.

Two coordination styles are common:

  • Orchestration: a process manager records its state and sends commands. It is easier to observe and test because the workflow is central.
  • Choreography: each service reacts to events. It reduces central coordination but makes the overall flow harder to trace.

Do not call a synchronous in-memory event dispatcher a production saga. It can test routing and domain reactions. It cannot prove durable delivery, broker retries, ordering, or recovery after a process crash.

What to Test

Test each boundary for the property it owns:

LayerProperty it owns
Aggregate unit testInvariants and state transitions
In-memory repository testUse-case behavior and detached-snapshot semantics
PostgreSQL integration testTransactions, locks, SQL mapping, and version conflicts
Outbox integration testAggregate write and event recording commit together
Workflow testIdempotency, retries, compensation, and visible process state

That separation avoids the two usual mistakes: pretending a fake database proves production locking, and using a full broker test to check a simple order-total invariant.


A Decision Checklist

Before adding an entity, repository, table, or event, ask:

  1. Which invariant must hold at commit time?
  2. Which object is responsible for enforcing it?
  3. Does this object need an independent lifecycle?
  4. Is this a command-side aggregate operation or a read-side query?
  5. Do the participating models share a database, a service boundary, or only an event contract?
  6. What happens when the process is retried, duplicated, delayed, or partially fails?

The answers will not remove every design debate. They make the actual trade-off visible before a table layout or framework convention makes the decision by accident.


Sources

Next in TDD & Software Design

I Stopped Mocking Everything in TDD

Part 6 continues the series.