16 min left
3196 words
16 minutes

TypeScript DDD Aggregates & Repositories

In short: A DDD aggregate groups domain objects behind one root that enforces their immediate invariants. A write-side repository loads and saves that root.

The Multi-Object Problem#

Last time, in Value Objects & Entities - Building Blocks That Can’t Break, we made invalid states impossible for a single User. Email validates itself. Age validates itself. The User entity composes them and manages identity.

Rules can also span multiple objects.

Consider an Order containing OrderLine items, each with a quantity and a unitPriceCents. The business adds a rule: “A customer can’t place an order exceeding $1,000.” All monetary values below use integer cents; JavaScript number values are not safe for fractional currency.

Where does that rule live?

// ❌ Scattered invariant: the $1000 rule lives in the service layer
class OrderService {
private static readonly MAX_TOTAL_CENTS = 100_000;
async placeOrder(
customerId: string,
items: { product: string; quantity: number; priceCents: number }[]
): Promise<Result<Order, string>> {
const orderResult = Order.create(customerId);
if (!orderResult.ok) return err(orderResult.error);
const order = orderResult.value;
let total = 0;
for (const item of items) {
total += item.quantity * item.priceCents;
}
if (total > OrderService.MAX_TOTAL_CENTS) {
return err("Order total cannot exceed $1,000");
}
for (const item of items) {
const lineResult = order.addLine(
item.product,
item.quantity,
item.priceCents
);
if (!lineResult.ok) return err(lineResult.error);
}
return ok(order);
}
}

This works until someone adds a second entry point, such as an admin endpoint or batch import, and forgets to re-check the total. Now orders over $1,000 slip through.

The rule is scattered. It lives in a service, not in the domain. Any code path that bypasses that service bypasses the rule.

In TDD Isn’t Just for Catching Bugs - It’s Your Permission Slip, I showed you how testing behavior gives you confidence to refactor. In the Value Objects article, I showed you how types make invalid states impossible. Now we need something that prevents invalid states across multiple objects.

That something is an Aggregate.


What Is a DDD Aggregate and Aggregate Root?#

An aggregate is a cluster of domain objects treated as a single unit. One object, the Aggregate Root, is the entry point for changes to the aggregate.

Here are the rules:

  1. Changes go through the root. External code modifies an OrderLine through Order, which gives the root a chance to enforce its invariants.
  2. Invariants are enforced at the boundary. The $1,000 rule lives inside the aggregate. Nobody outside needs to check it.
  3. Prefer transactions within one aggregate. Each aggregate is a consistency boundary. Cross-aggregate transactions are sometimes necessary, but many cross-aggregate rules can use eventual consistency or a process manager instead.
  4. Write-side repositories work with aggregates. You load an Order, not an OrderLine, when a command needs to change it.

Aggregate boundary diagram: external code changes the Order aggregate through its root, which contains OrderLine value objects. The root enforces its invariants before changing state.

Let me show you what this looks like in code. First, the building blocks:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}

If you’ve been following the series, that Result<T, E> type should look familiar. It’s the same pattern from the permission-slip article, and I cover it in depth in The Result Pattern. Explicit errors, no exceptions, testable by design.

Now the aggregate:

class OrderLine {
private constructor(
private readonly product: string,
private readonly quantity: number,
private readonly unitPriceCents: number
) {}
static create(
product: string,
quantity: number,
unitPriceCents: number
): Result<OrderLine, string> {
if (!product.trim()) return err("Product name is required");
if (!Number.isSafeInteger(quantity) || quantity <= 0) {
return err("Quantity must be a positive integer");
}
if (!Number.isSafeInteger(unitPriceCents) || unitPriceCents < 0) {
return err("Unit price in cents must be a non-negative integer");
}
if (!Number.isSafeInteger(unitPriceCents * quantity)) {
return err("Line total is too large");
}
return ok(new OrderLine(product, quantity, unitPriceCents));
}
getLineTotalCents(): number {
return this.unitPriceCents * this.quantity;
}
getProduct(): string {
return this.product;
}
getQuantity(): number {
return this.quantity;
}
}

OrderLine is a value object inside the aggregate: it has no identity and cannot change after creation. Its static create() factory returns a Result, so invalid lines cannot exist.

Now the aggregate root:

enum OrderStatus {
Draft = "draft",
Submitted = "submitted",
Cancelled = "cancelled",
}
class OrderId {
private constructor(private readonly value: string) {}
static generate(): OrderId {
return new OrderId(crypto.randomUUID());
}
static fromString(value: string): Result<OrderId, string> {
if (!value.trim()) return err("Order ID is required");
return ok(new OrderId(value));
}
getValue(): string {
return this.value;
}
equals(other: OrderId): boolean {
return this.value === other.value;
}
}
class Order {
private lines: OrderLine[] = [];
private status: OrderStatus = OrderStatus.Draft;
private constructor(
private readonly id: OrderId,
private readonly customerId: string,
private readonly maxTotalCents: number
) {}
static create(
customerId: string,
maxTotalCents: number = 100_000
): Result<Order, string> {
if (!customerId.trim()) return err("Customer ID is required");
if (!Number.isSafeInteger(maxTotalCents) || maxTotalCents <= 0) {
return err("Max total in cents must be a positive integer");
}
return ok(new Order(OrderId.generate(), customerId, maxTotalCents));
}
addLine(
product: string,
quantity: number,
unitPriceCents: number
): Result<void, string> {
if (this.status !== OrderStatus.Draft) {
return err("Cannot modify a non-draft order");
}
const lineResult = OrderLine.create(product, quantity, unitPriceCents);
if (!lineResult.ok) return err(lineResult.error);
const currentTotalCents = this.calculateTotalCents();
const newTotalCents =
currentTotalCents + lineResult.value.getLineTotalCents();
if (!Number.isSafeInteger(newTotalCents)) {
return err("Order total is too large");
}
if (newTotalCents > this.maxTotalCents) {
return err(
`Order total cannot exceed $${this.maxTotalCents / 100}. Current: $${currentTotalCents / 100}, adding $${lineResult.value.getLineTotalCents() / 100}`
);
}
this.lines.push(lineResult.value);
return ok(undefined);
}
submit(): Result<void, string> {
if (this.lines.length === 0) {
return err("Cannot submit an empty order");
}
if (this.status !== OrderStatus.Draft) {
return err("Order has already been submitted or cancelled");
}
this.status = OrderStatus.Submitted;
return ok(undefined);
}
cancel(): Result<void, string> {
if (this.status === OrderStatus.Cancelled) {
return err("Order is already cancelled");
}
if (this.status === OrderStatus.Submitted) {
return err("Cannot cancel a submitted order");
}
this.status = OrderStatus.Cancelled;
return ok(undefined);
}
getTotalCents(): number {
return this.calculateTotalCents();
}
getLineCount(): number {
return this.lines.length;
}
getStatus(): OrderStatus {
return this.status;
}
getId(): OrderId {
return this.id;
}
getCustomerId(): string {
return this.customerId;
}
private calculateTotalCents(): number {
return this.lines.reduce((sum, line) => sum + line.getLineTotalCents(), 0);
}
}

addLine() verifies the order status, validates the new line through OrderLine.create(), and checks the total before mutating state. The $1,000 rule lives inside Order, so every entry point that modifies an order through the aggregate runs the same checks.


The TDD Cycle for Aggregates#

I wrote tests first and let the design sharpen under them. The suite below is the polished result; in practice, write one failing behavior at a time and make it pass before adding the next one.

Let me walk you through the cycle.

Red: Write the Failing Tests#

describe("Order Aggregate", () => {
it("should create a draft order", () => {
const result = Order.create("customer-1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.getStatus()).toBe("draft");
expect(result.value.getCustomerId()).toBe("customer-1");
}
});
it("should add a line to a draft order", () => {
const order = Order.create("customer-1");
if (!order.ok) throw new Error("Setup failed");
const result = order.value.addLine("Widget", 2, 5_000);
expect(result.ok).toBe(true);
expect(order.value.getLineCount()).toBe(1);
expect(order.value.getTotalCents()).toBe(10_000);
});
it("should reject lines that exceed the max total", () => {
const order = Order.create("customer-1", 10_000);
if (!order.ok) throw new Error("Setup failed");
order.value.addLine("Widget", 1, 7_500);
const result = order.value.addLine("Gadget", 1, 5_000);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("exceed");
}
expect(order.value.getTotalCents()).toBe(7_500);
});
it("should reject adding lines to a submitted order", () => {
const order = Order.create("customer-1");
if (!order.ok) throw new Error("Setup failed");
order.value.addLine("Widget", 1, 1_000);
order.value.submit();
const result = order.value.addLine("Gadget", 1, 2_000);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("non-draft");
}
});
it("should reject submission of empty order", () => {
const order = Order.create("customer-1");
if (!order.ok) throw new Error("Setup failed");
const result = order.value.submit();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("empty");
}
});
it("should reject double submission", () => {
const order = Order.create("customer-1");
if (!order.ok) throw new Error("Setup failed");
order.value.addLine("Widget", 1, 1_000);
order.value.submit();
const result = order.value.submit();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("already been submitted");
}
});
it("should reject negative quantity in order lines", () => {
const order = Order.create("customer-1");
if (!order.ok) throw new Error("Setup failed");
const result = order.value.addLine("Widget", -1, 1_000);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("positive");
}
});
});

These tests describe behavior: what the order accepts, what it rejects, and what state transitions are valid. They check order.getLineCount(), the observable contract, not the internal lines array.

Green: Make the Tests Pass#

This is where I write the minimal Order, OrderLine, and OrderId classes to satisfy the tests. The full implementation is above.

Refactor: Clean Up#

Once everything passes, I look for patterns:

  • Status transitions are repeated. I could extract a transitionStatus() method.
  • Total calculation is inline. It’s already a private method.
  • Line validation is delegated to OrderLine.create(). Good separation.

The tests protect me during refactoring. I can rename methods, restructure internals, extract helper classes. As long as the tests stay green, the behavior is preserved.

That’s the permission slip from the first article in action. I wrote the expected behavior first, and the tests guided the structure.


What Is a Repository?#

So we have an aggregate that protects invariants. But aggregates live in memory. At some point, you need to save them to a database.

Here’s what most teams do:

// ❌ Domain code coupled to the database
class OrderService {
async placeOrder(
customerId: string,
items: ItemDto[]
): Promise<{ customerId: string; lines: ItemDto[] }> {
const order = { customerId, lines: items };
// Domain logic mixed with persistence
await db.query("INSERT INTO orders ...");
for (const line of order.lines) {
await db.query("INSERT INTO order_lines ...");
}
return order;
}
}

The domain logic is tangled with SQL. Unit tests now need database seams or mocks, and changing persistence affects the service. Business rules are harder to see through the query noise.

A Repository fixes this by introducing an interface:

interface OrderRepository {
getById(id: OrderId): Promise<Result<Order, string>>;
save(order: Order): Promise<Result<void, string>>;
findByCustomerId(customerId: string): Promise<Result<Order[], string>>;
delete(id: OrderId): Promise<Result<void, string>>;
}

Repository pattern diagram: domain code depends on the OrderRepository interface; in-memory and Postgres implementations sit behind it, keeping SQL and persistence details out of the domain.

This example has four methods: load, save, find, and delete. Its repository speaks in terms of aggregates, not tables.

The domain code depends on save() and getById(), not on whether the repository uses PostgreSQL, MongoDB, a file system, or an in-memory Map.

The In-Memory Repository#

For testing, I always start with an in-memory implementation:

class InMemoryOrderRepository implements OrderRepository {
private orders = new Map<string, Order>();
async getById(id: OrderId): Promise<Result<Order, string>> {
const order = this.orders.get(id.getValue());
if (!order) {
return err(`Order not found: ${id.getValue()}`);
}
return ok(order);
}
async save(order: Order): Promise<Result<void, string>> {
this.orders.set(order.getId().getValue(), order);
return ok(undefined);
}
async findByCustomerId(customerId: string): Promise<Result<Order[], string>> {
const found = Array.from(this.orders.values()).filter(
(o) => o.getCustomerId() === customerId
);
return ok(found);
}
async delete(id: OrderId): Promise<Result<void, string>> {
if (!this.orders.has(id.getValue())) {
return err(`Order not found: ${id.getValue()}`);
}
this.orders.delete(id.getValue());
return ok(undefined);
}
clear(): void {
this.orders.clear();
}
}

This fake is fast, deterministic, and uses the same interface as the production repository, keeping domain tests free of a database.

describe("Order checkout flow", () => {
let repo: InMemoryOrderRepository;
beforeEach(() => {
repo = new InMemoryOrderRepository();
});
it("should make a submitted order available", async () => {
const orderResult = Order.create("customer-1");
if (!orderResult.ok) throw new Error("Setup failed");
const order = orderResult.value;
const lineResult = order.addLine("Widget", 2, 5_000);
expect(lineResult.ok).toBe(true);
const submitResult = order.submit();
expect(submitResult.ok).toBe(true);
const saveResult = await repo.save(order);
expect(saveResult.ok).toBe(true);
const loadResult = await repo.getById(order.getId());
expect(loadResult.ok).toBe(true);
if (loadResult.ok) {
expect(loadResult.value.getStatus()).toBe("submitted");
expect(loadResult.value.getTotalCents()).toBe(10_000);
expect(loadResult.value.getLineCount()).toBe(1);
}
});
it("should find orders by customer", async () => {
const a = Order.create("customer-1");
const b = Order.create("customer-2");
const c = Order.create("customer-1");
if (!a.ok || !b.ok || !c.ok) throw new Error("Setup failed");
expect(a.value.addLine("Widget", 1, 1_000).ok).toBe(true);
expect(c.value.addLine("Gadget", 1, 2_000).ok).toBe(true);
await repo.save(a.value);
await repo.save(b.value);
await repo.save(c.value);
const found = await repo.findByCustomerId("customer-1");
expect(found.ok).toBe(true);
if (found.ok) {
expect(found.value).toHaveLength(2);
expect(found.value[0].getTotalCents()).toBe(1_000);
expect(found.value[1].getTotalCents()).toBe(2_000);
}
});
it("should return error for non-existent order", async () => {
const result = await repo.getById(OrderId.generate());
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("not found");
}
});
});

These tests need no database, Docker container, or network. They run in milliseconds.

When I need a real database, I write a PostgresOrderRepository that implements the same interface. The domain code does not change, but the adapter needs its own integration tests for SQL, mapping, transactions, and rehydration.

The fake proves the checkout flow works with the OrderRepository interface. It does not prove persistence works: because it stores the same mutable Order instance, it cannot exercise serialization or rehydration. The production adapter’s integration tests cover those concerns.

This is dependency inversion in practice: the domain defines the interface it needs. Infrastructure provides the implementation.

What an Aggregate Does Not Guarantee: Concurrency#

An aggregate protects invariants in one in-memory instance. It does not stop two requests from loading the same order, both passing validation, and overwriting each other when they save. A production repository must preserve the boundary atomically, usually with optimistic concurrency (a version checked on save) or a transaction and lock when contention requires it. A stale save should return an error that the application can retry or surface to the caller.


Real-World: The Checkout Flow#

Let me put it all together. Here’s a checkout flow that uses the aggregate, the repository, and the Result type:

class CheckoutService {
constructor(private readonly orderRepo: OrderRepository) {}
async checkout(
customerId: string,
items: { product: string; quantity: number; unitPriceCents: number }[]
): Promise<Result<Order, string>> {
const orderResult = Order.create(customerId);
if (!orderResult.ok) return err(orderResult.error);
const order = orderResult.value;
for (const item of items) {
const lineResult = order.addLine(
item.product,
item.quantity,
item.unitPriceCents
);
if (!lineResult.ok) return err(lineResult.error);
}
const submitResult = order.submit();
if (!submitResult.ok) return err(submitResult.error);
const saveResult = await this.orderRepo.save(order);
if (!saveResult.ok) return err(saveResult.error);
return ok(order);
}
}

The service is thin: it coordinates creating the aggregate, adding lines, submitting, and persisting. The order-specific business rules live inside Order, and every error path is explicit.

And the test:

describe("CheckoutService", () => {
let repo: InMemoryOrderRepository;
let service: CheckoutService;
beforeEach(() => {
repo = new InMemoryOrderRepository();
service = new CheckoutService(repo);
});
it("should complete a valid checkout", async () => {
const result = await service.checkout("customer-1", [
{ product: "Widget", quantity: 2, unitPriceCents: 5_000 },
{ product: "Gadget", quantity: 1, unitPriceCents: 3_000 },
]);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.getTotalCents()).toBe(13_000);
expect(result.value.getStatus()).toBe("submitted");
const saved = await repo.getById(result.value.getId());
expect(saved.ok).toBe(true);
}
});
it("should reject checkout exceeding $1000", async () => {
const result = await service.checkout("customer-1", [
{ product: "Expensive Thing", quantity: 11, unitPriceCents: 10_000 },
]);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("exceed");
}
});
it("should reject checkout with no items", async () => {
const result = await service.checkout("customer-1", []);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("empty");
}
});
it("should reject checkout with invalid line", async () => {
const result = await service.checkout("customer-1", [
{ product: "Widget", quantity: -1, unitPriceCents: 1_000 },
]);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("positive");
}
});
});

Four tests cover the checkout flow with no mocks or database: valid orders, overspending, empty orders, and invalid lines.

When I add a PostgresOrderRepository for production, I write integration tests for that class specifically. The domain logic, including the checkout flow, invariants, and state transitions, remains testable without infrastructure.


Common Mistakes#

I’ve made all of these. Learn from my failures.

1. Anemic Aggregates#

// ❌ Anemic aggregate: just a data structure
class Order {
constructor(
public readonly id: string,
public lines: OrderLineDto[],
public status: string,
public customerId: string
) {}
}
// All logic lives in the service
class OrderService {
addLine(order: Order, line: OrderLineDto): void {
const total = order.lines.reduce((s, l) => s + l.priceCents * l.qty, 0);
if (total + line.priceCents * line.qty > 100_000)
throw new Error("Too much");
order.lines.push(line);
}
}

The aggregate has no behavior. It’s a bag of data. The invariants live in the service, and any code that bypasses the service bypasses the rules.

// ✅ Rich aggregate: owns its invariants
class Order {
private lines: OrderLine[] = [];
private status: OrderStatus = OrderStatus.Draft;
addLine(
product: string,
quantity: number,
priceCents: number
): Result<void, string> {
// The aggregate protects itself
}
}

2. Giant Aggregates#

I once put Customer, Order, Payment, and Shipping into a single aggregate. It was 2,000 lines. Every test loaded the entire object graph. Every change touched the same class.

Aggregates should be small. They protect one set of invariants. If two objects don’t share an invariant, they shouldn’t be in the same aggregate.

Guideline: if most operations load data they do not need, reconsider the aggregate boundary.

3. Repository as ORM#

// ❌ Repository leaking query details
interface OrderRepository {
findWhere(
filters: { status?: string; dateFrom?: Date; dateTo?: Date },
sort: string,
limit: number,
offset: number
): Promise<Order[]>;
}

This is a query builder, not a repository. The domain now knows about pagination, sorting, and filter shapes. You’ve coupled your domain to query infrastructure.

// ✅ Repository speaking in domain terms
interface OrderRepository {
getById(id: OrderId): Promise<Result<Order, string>>;
findByCustomerId(customerId: string): Promise<Result<Order[], string>>;
findSubmittedBetween(from: Date, to: Date): Promise<Result<Order[], string>>;
save(order: Order): Promise<Result<void, string>>;
}

Each method expresses a domain question, not a database query.

4. Skipping the Interface#

// ❌ Using the concrete class everywhere
class OrderService {
constructor(private readonly repo: PostgresOrderRepository) {}
}

Tests now need a real database or a replacement for a concrete class with complex internals.

// ✅ Depend on the interface
class OrderService {
constructor(private readonly repo: OrderRepository) {}
}

Tests use InMemoryOrderRepository; production uses PostgresOrderRepository.


FAQ#

What is an Aggregate Root in DDD?#

An aggregate root is the entry point for changes to an aggregate. Modifications to objects inside the aggregate go through the root. In this example, Order is the aggregate root: callers add an OrderLine through Order.addLine(). This lets the root enforce its invariants before it changes state.

How do you test aggregate invariants?#

Write tests that attempt to violate each invariant, and verify the aggregate rejects the attempt. For the $1,000 limit, I wrote a test that adds items until the total would exceed the limit, then asserts the aggregate returns an error. The key is testing the boundary where the rule triggers, alongside the happy path.

What is the repository pattern?#

The repository pattern abstracts persistence behind an interface, so domain code does not import a database driver. Write-side repositories normally work with aggregate roots. You call getById(orderId) and save(order), and the implementation decides whether that hits PostgreSQL, MongoDB, a file, or an in-memory Map. In this article, the domain defines OrderRepository and the tests use InMemoryOrderRepository against the same interface.

Should a repository return the aggregate or individual entities?#

For write operations, return the aggregate root. If a command needs to change an OrderLine, load its Order so the root can enforce invariants. Read models and reporting queries can return projections or individual records when they do not modify the aggregate.


What’s Next?#

We’ve covered a lot of ground: Value Objects protect single attributes, Entities manage identity, Aggregates protect invariants across multiple objects, and Repositories abstract persistence.

But there’s a question I keep getting: “How do I test the interactions between these pieces? Do I mock the repository? Mock the aggregate?”

I used to mock everything. Every test had 15 lines of jest.fn() setup. Tests passed, but code broke in production because the mocks did not match their collaborators.

Next article: “I Stopped Mocking Everything in TDD.” I cover when mocks couple tests to implementation details and when fakes are the better choice.


Continue the TDD & Software Design Series#

  1. TDD Isn’t Just for Catching Bugs - It’s Your Permission Slip
  2. Value Objects & Entities - Building Blocks That Can’t Break
  3. Aggregates & Repositories in TypeScript - Refactoring Without Fear
  4. Repository Updates in TypeScript: Locks and Optimistic Concurrency
  5. DDD Aggregate Boundaries and Persistence in TypeScript
  6. I Stopped Mocking Everything in TDD
  7. The Result Pattern: Why I Stopped Throwing Exceptions
  8. TDD in the Age of AI: Who Tests the Tests?
  9. TDD for Legacy Code: Start With Seams, Not Ideology
TypeScript DDD Aggregates & Repositories
https://corentings.dev/blog/tdd-aggregates-repositories/
Author
Corentin Giaufer Saubert
Published at
2026-07-07
License
CC BY-NC-SA 4.0
Share this post