Siddhant Deval
Siddhant Deval
backend5 min read

Aggregates & Repositories: Consistency Boundaries in Order Fulfillment

An Aggregate defines the transactional consistency boundary in your domain — one database transaction changes exactly one Aggregate. The Repository pattern provides the abstraction that makes domain code completely agnostic of the underlying persistence technology, whether PostgreSQL, MongoDB, or an in-memory test double.

Aggregates & Repositories: Consistency Boundaries in Order Fulfillment

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. But what happens when those objects are related? An Order owns LineItem objects. Can external code reach into an Order and mutate a LineItem directly? The answer determines whether your domain model has real invariant protection or just the illusion of it. The Aggregate pattern draws the boundary. The Repository pattern makes that boundary persist across requests.

This article implements the full Aggregate and Repository layer for the billing engine: the Order Aggregate Root with its consistency boundary, the IOrderRepository port defined in the Domain layer, the InMemoryOrderRepository used in all fast tests, and the rules for when you must not cross Aggregate boundaries.

Architectural Note

Prerequisites: Part 3 (Entities & Value Objects) — we extend Order, LineItem, Money, and OrderId built there. Part 2 (Clean Architecture Layers) — the IOrderRepository interface lives in src/domain/ and the PrismaOrderRepository lives in src/infrastructure/.


1. The Consistency Boundary Problem

Here is the bug this pattern prevents. It appears in every codebase that uses Entities without Aggregate boundaries:

TYPESCRIPT
// ❌ Anti-Pattern: External code holds a direct reference to a child Entity
const order = await orderRepo.findById(orderId);

// The LineItem is an Entity — it has a public setter that bypasses all Order guards
const lineItem = order.items.find(i => i.productId === targetProductId)!;
lineItem.setQuantity(-5);  // ✅ Compiles. The LineItem has no guard against this.
// The Order.total() now returns a negative number.
// The Order never knew this happened. No domain event raised.
await lineItemRepo.save(lineItem); // Saving the child directly — the Order is not involved

// Two separate transactions later:
// Order.total is negative. Payment calculation is wrong.
// Fraud detection triggers. Customer is blocked.

This is not a hypothetical. The pattern of having a separate LineItemRepository alongside OrderRepository is extremely common in codebases that adopt DDD vocabulary without the structural discipline. The consequence is that LineItem mutations bypass every invariant defined on Order.

The Aggregate pattern closes this hole with one absolute rule: external code never holds a direct reference to a child entity inside an Aggregate boundary. The only way to mutate a LineItem is through order.addItem(), order.updateItemQuantity(), or order.removeItem() — methods on the Aggregate Root that apply every relevant guard before delegating.


2. The Aggregate Root

2.1 Definition

An Aggregate is a cluster of domain objects (Entities and Value Objects) that are treated as a single unit for the purpose of data changes. The Aggregate Root is the single Entity that external objects are allowed to hold a reference to.

The three structural rules:

  1. Only the Root is accessible from outside the boundary. External code holds a reference to Order, never directly to LineItem.
  2. All mutations inside the boundary go through Root methods. order.addItem(), never lineItem.setQuantity().
  3. One database transaction modifies exactly one Aggregate. Cross-Aggregate consistency is achieved through Domain Events (Part 5), not distributed transactions.

2.2 The AggregateRoot<T> Base Class (Extended)

We established the base class in Part 3. Here is the full implementation with the domain event machinery:

TYPESCRIPT
// src/domain/shared/AggregateRoot.ts
import { Entity } from './Entity';
import { DomainEvent } from './DomainEvent';

export abstract class AggregateRoot<TId extends { equals(other: TId): boolean }>
  extends Entity<TId> {

  private readonly _domainEvents: DomainEvent[] = [];
  private _version: number = 0; // Optimistic concurrency guard — Part 11

  protected constructor(id: TId, version = 0) {
    super(id);
    this._version = version;
  }

  protected addDomainEvent(event: DomainEvent): void {
    this._domainEvents.push(event);
  }

  /**
   * Called by the Use Case after repository.save() has committed.
   * Clears the event list — events are dispatched once and forgotten.
   */
  pullDomainEvents(): DomainEvent[] {
    const events = [...this._domainEvents];
    this._domainEvents.length = 0;
    return events;
  }

  get version(): number { return this._version; }
  get hasDomainEvents(): boolean { return this._domainEvents.length > 0; }
}

2.3 The Order Aggregate — Enforcing Child Entity Access

The key change from Part 3's Order is making LineItem inaccessible as a mutable reference. External code receives a ReadonlyArray<LineItem> — it can read, but it cannot push, splice, or reassign through the getter:

TYPESCRIPT
// src/domain/order/Order.ts (relevant sections)
export class Order extends AggregateRoot<OrderId> {
  // ❌ If items were public:  external code could call order.items.push(rawItem)
  // ✅ With ReadonlyArray:    order.items returns an immutable view
  private _items: LineItem[] = [];

  get items(): ReadonlyArray<LineItem> {
    return this._items; // Typed as ReadonlyArray — push/splice are not available
  }

  // ✅ The only way to add an item is through this guarded method
  addItem(productId: ProductId, quantity: number, unitPrice: Money): void {
    if (this._status !== OrderStatus.PENDING)
      throw new DomainError(`Cannot add items to Order ${this.id} — already placed`);
    if (quantity <= 0)
      throw new DomainError(`Item quantity must be positive, got ${quantity}`);

    const existing = this._items.find(i => i.productId === productId);
    if (existing) {
      // Merge quantities on duplicate product — business rule enforced here
      const idx = this._items.indexOf(existing);
      this._items[idx] = existing.withAdditionalQuantity(quantity);
    } else {
      this._items.push(LineItem.create(productId, quantity, unitPrice));
    }
  }

  updateItemQuantity(productId: ProductId, newQuantity: number): void {
    if (this._status !== OrderStatus.PENDING)
      throw new DomainError(`Cannot update items on Order ${this.id} — already placed`);
    if (newQuantity <= 0)
      throw new DomainError(`New quantity must be positive, got ${newQuantity}`);

    const idx = this._items.findIndex(i => i.productId === productId);
    if (idx === -1)
      throw new DomainError(`ProductId ${productId} not found in Order ${this.id}`);

    this._items[idx] = this._items[idx].withQuantity(newQuantity);
  }

  removeItem(productId: ProductId): void {
    if (this._status !== OrderStatus.PENDING)
      throw new DomainError(`Cannot remove items from Order ${this.id} — already placed`);

    const idx = this._items.findIndex(i => i.productId === productId);
    if (idx === -1)
      throw new DomainError(`ProductId ${productId} not found in Order ${this.id}`);

    this._items.splice(idx, 1);
  }
}

Now consider what happens when external code tries to bypass the Aggregate Root:

TYPESCRIPT
const order = await orderRepo.findById(orderId);

// ❌ TypeScript Error: Property 'push' does not exist on type 'ReadonlyArray<LineItem>'
order.items.push(someItem);

// ❌ TypeScript Error: Index signature in type 'ReadonlyArray<LineItem>' only permits reading
order.items[0] = someOtherItem;

// ✅ Only valid mutation path:
order.addItem(productId, 2, Money.of('USD', 49.99));

The boundary is enforced by the type system at compile time. If the TypeScript compiler accepts the code, it respects the Aggregate boundary.


3. Aggregate Design Rules for the Billing Domain

3.1 Reference External Aggregates by ID Only

An Order must reference a Customer, but Order and Customer are separate Aggregates — they have separate consistency boundaries and are loaded/saved by separate transactions.

TYPESCRIPT
// ❌ Anti-Pattern: Order holds a direct reference to the Customer Aggregate
class Order extends AggregateRoot<OrderId> {
  private _customer: Customer; // ← Object reference to another Aggregate

  // Problem: Order.customer is now mutable — Customer state can change after Order is loaded
  // Problem: Loading an Order now requires loading the entire Customer Aggregate tree
  // Problem: A transaction saving Order could accidentally save Customer mutations too
}

// ✅ Correct: Order holds only the CustomerId — a Value Object (branded string)
class Order extends AggregateRoot<OrderId> {
  private _customerId: CustomerId; // ← ID reference only — Customer is a separate Aggregate

  // When the Use Case needs Customer data, it loads it separately via ICustomerRepository
  // The Order and Customer are saved in separate transactions — clear boundary
}

The rule is mechanical: objects inside the Aggregate boundary are referenced by object reference; objects outside the boundary are referenced by ID.

3.2 One Transaction Per Aggregate

The invariant "one database transaction modifies exactly one Aggregate" follows directly from Rule 1. If the PlaceOrderUseCase needs to update both Order and InventoryReservation, these cannot be part of the same atomic database transaction — InventoryReservation is a separate Aggregate.

TYPESCRIPT
// ❌ Anti-Pattern: Two Aggregates in one transaction — crosses consistency boundary
async execute(command: PlaceOrderCommand): Promise<void> {
  await db.$transaction(async (tx) => {
    await tx.order.update({ where: { id: command.orderId }, data: { status: 'PLACED' } });
    await tx.inventory.update({ where: { productId: command.productId }, data: { reserved: { increment: 1 } } });
    // ❌ This couples Order and Inventory into a single transaction
    // If Inventory fails, Order rollback cascades — tight coupling in disguise
  });
}

// ✅ Correct: Each Aggregate in its own transaction; cross-boundary coordination via Domain Events
async execute(command: PlaceOrderCommand): Promise<void> {
  const order = await this.orderRepo.findById(command.orderId);
  order.place();
  await this.orderRepo.save(order); // Transaction 1: only touches orders + line_items tables

  // Domain Events handle the cross-Aggregate side effects — no distributed transaction needed
  const events = order.pullDomainEvents();
  for (const event of events) await this.eventBus.publish(event);
  // InventoryReservationHandler handles its own Aggregate in its own transaction (Part 5)
}
Crucial Requirement

If you find yourself needing a database transaction across two Aggregates, it almost always means one of: (a) the Aggregate boundary is drawn incorrectly and these objects should be one Aggregate, or (b) eventual consistency via Domain Events is the correct model, and you are conflating "strongly consistent" with "immediately consistent."

3.3 Aggregate Size: The Invariant Boundary Test

How do you decide what belongs inside an Aggregate boundary? Apply the invariant boundary test: if you can state a business rule that requires checking data from multiple entities simultaneously during a mutation, those entities belong in the same Aggregate.

For the Order Aggregate:

  • "An Order cannot be placed with zero line items." — requires Order.status AND Order.items.length simultaneously → both inside the Aggregate ✅
  • "An Order cannot be placed without a verified customer payment method." — requires Order AND Customer.paymentMethods simultaneously. But Customer has its own invariants (max 5 payment methods) that are unrelated to Order placement → separate Aggregates, coordinate via Domain Event ✅
  • "Total inventory across all orders cannot exceed warehouse capacity." — requires Order.items AND all other Order.items globally → this invariant cannot be enforced at the Aggregate level; it requires a Domain Service or an eventually consistent saga ✅

4. The Repository Pattern

4.1 The IOrderRepository Port (Domain Layer)

The Repository interface is the most important abstraction in Clean Architecture. It must be defined in the Domain layer, using only Domain types, with no mention of any persistence technology:

TYPESCRIPT
// src/domain/order/ports/IOrderRepository.ts
import { Order } from '../Order';
import { OrderId } from '../OrderId';
import { CustomerId } from '../../customer/CustomerId';

export interface IOrderRepository {
  /** Load an Order by its ID. Returns null if not found. */
  findById(id: OrderId): Promise<Order | null>;

  /** Load all Orders for a specific customer */
  findByCustomerId(customerId: CustomerId): Promise<Order[]>;

  /**
   * Persist an Order and all its child LineItems atomically.
   * Must handle both INSERT (new Order) and UPDATE (existing Order).
   */
  save(order: Order): Promise<void>;

  /** Generate a new, globally unique OrderId */
  nextId(): OrderId;
}

Notice what is absent: PrismaClient, find, where, include, select, createMany, or any ORM vocabulary. The interface is pure domain language. The word "Prisma" appears nowhere in src/domain/.

Pro Tip & Optimization

Repository interface placement: The IOrderRepository interface lives at src/domain/order/ports/IOrderRepository.ts. The ports/ subdirectory is a naming convention that signals "this is a dependency that must be injected from outside the domain" — exactly the Port vocabulary from Hexagonal Architecture (Part 7).

4.2 The InMemoryOrderRepository (Infrastructure Layer — Test Double)

The in-memory repository is not a mock. It is a real implementation of IOrderRepository backed by a Map. It enforces the same interface contract as PrismaOrderRepository and can be used in any test that needs IOrderRepository behavior without a database:

TYPESCRIPT
// src/infrastructure/persistence/InMemoryOrderRepository.ts
import { IOrderRepository } from '../../domain/order/ports/IOrderRepository';
import { Order } from '../../domain/order/Order';
import { OrderId, generateOrderId } from '../../domain/order/OrderId';
import { CustomerId } from '../../domain/customer/CustomerId';

export class InMemoryOrderRepository implements IOrderRepository {
  // Use a Map for O(1) lookup by OrderId
  private readonly store = new Map<string, Order>();

  async findById(id: OrderId): Promise<Order | null> {
    return this.store.get(id) ?? null;
  }

  async findByCustomerId(customerId: CustomerId): Promise<Order[]> {
    return Array.from(this.store.values())
      .filter(order => order.customerId === customerId);
  }

  async save(order: Order): Promise<void> {
    // Deep clone to simulate database round-trip isolation
    // Prevents the in-memory test double from sharing references with the caller
    this.store.set(order.id, this.clone(order));
  }

  nextId(): OrderId {
    return generateOrderId();
  }

  // ── Test helpers (not part of IOrderRepository) ──

  /** Returns all stored Orders — useful in tests to assert persistence side effects */
  findAll(): Order[] {
    return Array.from(this.store.values());
  }

  /** Clear all data — use in beforeEach to ensure test isolation */
  clear(): void {
    this.store.clear();
  }

  get size(): number {
    return this.store.size;
  }

  private clone(order: Order): Order {
    // Reconstitute from snapshot to ensure the stored object is independent of the caller's reference
    return Order.reconstitute({
      id: order.id,
      customerId: order.customerId,
      status: order.status,
      items: [...order.items],
      shippingAddress: order.shippingAddress,
      version: order.version,
    });
  }
}

The clone() step is subtle but critical. Without it, the test double shares object references with the caller — mutating an Order after save() would mutate the stored version too, making tests non-isolated. The clone simulates the serialization/deserialization round-trip that a real database performs.

4.3 Repository Contract Tests: Both Implementations Must Pass the Same Suite

This is the most powerful technique in Clean Architecture testing. A shared abstract test suite defines the behavioral contract that any repository implementation must satisfy:

TYPESCRIPT
// tests/unit/domain/order/OrderRepositoryContract.ts
import { IOrderRepository } from '../../../../src/domain/order/ports/IOrderRepository';
import { Order } from '../../../../src/domain/order/Order';
import { CustomerId } from '../../../../src/domain/customer/CustomerId';
import { Money } from '../../../../src/domain/payment/Money';

/**
 * Abstract contract test suite.
 * Both InMemoryOrderRepository and PrismaOrderRepository must pass every test here.
 */
export function orderRepositoryContract(
  getRepo: () => IOrderRepository,
  beforeEachHook?: () => Promise<void>,
): void {
  describe('IOrderRepository contract', () => {
    let repo: IOrderRepository;

    beforeEach(async () => {
      if (beforeEachHook) await beforeEachHook();
      repo = getRepo();
    });

    it('should return null for a non-existent OrderId', async () => {
      const result = await repo.findById('ord_nonexistent' as any);
      expect(result).toBeNull();
    });

    it('should persist a new Order and retrieve it by ID', async () => {
      const customerId = 'cust_001' as CustomerId;
      const order = Order.create(customerId);
      order.addItem('prod_001' as any, 2, Money.of('USD', 49.99));
      order.setShippingAddress(testAddress());

      await repo.save(order);

      const retrieved = await repo.findById(order.id);
      expect(retrieved).not.toBeNull();
      expect(retrieved!.id).toBe(order.id);
      expect(retrieved!.items).toHaveLength(1);
      expect(retrieved!.items[0].quantity).toBe(2);
    });

    it('should update an existing Order on save', async () => {
      const order = Order.create('cust_001' as CustomerId);
      await repo.save(order);

      order.addItem('prod_002' as any, 1, Money.of('USD', 29.99));
      await repo.save(order);

      const retrieved = await repo.findById(order.id);
      expect(retrieved!.items).toHaveLength(1);
      expect(retrieved!.items[0].unitPrice.amount).toBeCloseTo(29.99);
    });

    it('should retrieve all Orders for a CustomerId', async () => {
      const customerId = 'cust_multi' as CustomerId;
      const order1 = Order.create(customerId);
      const order2 = Order.create(customerId);
      const orderOther = Order.create('cust_other' as CustomerId);

      await repo.save(order1);
      await repo.save(order2);
      await repo.save(orderOther);

      const customerOrders = await repo.findByCustomerId(customerId);
      expect(customerOrders).toHaveLength(2);
      expect(customerOrders.every(o => o.customerId === customerId)).toBe(true);
    });

    it('should generate unique OrderIds', () => {
      const ids = new Set(Array.from({ length: 100 }, () => repo.nextId()));
      expect(ids.size).toBe(100); // All 100 must be unique
    });
  });
}

Now both repository implementations are tested with exactly the same suite:

TYPESCRIPT
// tests/unit/infrastructure/InMemoryOrderRepository.test.ts
import { InMemoryOrderRepository } from '../../../src/infrastructure/persistence/InMemoryOrderRepository';
import { orderRepositoryContract } from '../../unit/domain/order/OrderRepositoryContract';

describe('InMemoryOrderRepository', () => {
  let repo: InMemoryOrderRepository;

  orderRepositoryContract(
    () => repo,
    async () => { repo = new InMemoryOrderRepository(); repo.clear(); }
  );
});
TYPESCRIPT
// tests/integration/PrismaOrderRepository.test.ts
import { PrismaClient } from '@prisma/client';
import { PrismaOrderRepository } from '../../../src/infrastructure/persistence/PrismaOrderRepository';
import { orderRepositoryContract } from '../../unit/domain/order/OrderRepositoryContract';

describe('PrismaOrderRepository (integration)', () => {
  const prisma = new PrismaClient();
  let repo: PrismaOrderRepository;

  orderRepositoryContract(
    () => repo,
    async () => {
      repo = new PrismaOrderRepository(prisma);
      await prisma.orderItem.deleteMany();
      await prisma.order.deleteMany();
    }
  );

  afterAll(() => prisma.$disconnect());
});

If PrismaOrderRepository passes all contract tests, it can replace InMemoryOrderRepository in production with zero behavioral risk. If it fails, the exact contract violation is identified before deployment. This is the Repository pattern's most underappreciated benefit.


5. The DomainEvent Base and AggregateRoot Integration

When order.place() succeeds, it raises an OrderPlaced domain event. The Use Case dispatches this after the repository commits. Here is how the event machinery connects to the Aggregate:

TYPESCRIPT
// src/domain/shared/DomainEvent.ts
export abstract class DomainEvent {
  public readonly eventId: string;
  public readonly occurredAt: Date;
  public readonly aggregateId: string;
  public readonly eventVersion: number;

  protected constructor(aggregateId: string, eventVersion: number = 1) {
    this.eventId = crypto.randomUUID();
    this.occurredAt = new Date();
    this.aggregateId = aggregateId;
    this.eventVersion = eventVersion;
  }

  /** Discriminator for runtime event type routing in handlers */
  abstract get eventType(): string;
}
TYPESCRIPT
// src/domain/order/events/OrderPlaced.ts
import { DomainEvent } from '../../shared/DomainEvent';
import { OrderId } from '../OrderId';
import { CustomerId } from '../../customer/CustomerId';
import { Money } from '../../payment/Money';

export class OrderPlaced extends DomainEvent {
  constructor(
    public readonly orderId: OrderId,
    public readonly customerId: CustomerId,
    public readonly total: Money,
  ) {
    super(orderId);
  }

  get eventType(): string { return 'order.placed'; }
}

The event is created inside the Order.place() method — the Aggregate controls when events are raised. The Use Case controls when they are dispatched. The handlers (Part 5) control what happens in response. This separation of concerns is the Mediator pattern applied at the domain event level.


6. The reconstitute() Static Factory

The IOrderRepository.findById() implementation (covered fully in Part 13) must reconstruct an Order from flat database rows. This requires a second construction path that bypasses creation-only invariants (like "items cannot be empty") — because a loaded Order in SHIPPED status with no PENDING restrictions should be loaded as-is, not validated against creation rules.

TYPESCRIPT
// src/domain/order/Order.ts
export interface OrderSnapshot {
  id: OrderId;
  customerId: CustomerId;
  status: OrderStatus;
  items: LineItem[];
  shippingAddress: Address | null;
  version: number;
}

export class Order extends AggregateRoot<OrderId> {
  // ── Creation factory — enforces all creation invariants ──
  static create(customerId: CustomerId): Order {
    if (!customerId) throw new DomainError('CustomerId is required');
    return new Order({
      id: generateOrderId(),
      customerId,
      status: OrderStatus.PENDING,
      items: [],
      shippingAddress: null,
      version: 0,
    });
  }

  // ── Reconstitution factory — bypasses creation invariants, trusts the database ──
  static reconstitute(snapshot: OrderSnapshot): Order {
    return new Order(snapshot);
    // No invariant checks here — we trust the data was valid when it was saved
    // A SHIPPED order with no items is valid stored state (items were shipped)
  }

  private constructor(private readonly snapshot: OrderSnapshot) {
    super(snapshot.id, snapshot.version);
    this._customerId = snapshot.customerId;
    this._status = snapshot.status;
    this._items = [...snapshot.items];
    this._shippingAddress = snapshot.shippingAddress;
  }
}

The two-factory pattern is a DDD standard. Order.create() is used by the PlaceOrderUseCase to create new orders. Order.reconstitute() is used exclusively by the Repository adapter to load existing orders from storage.

Performance / Safety Warning

Never call Order.reconstitute() in application or domain code — it should only appear in Repository adapter implementations. A design smell is when reconstitute() is called anywhere outside src/infrastructure/persistence/. If you see it in a use case, the repository abstraction is leaking.


7. Aggregate Boundaries for the Full Billing Domain

The billing engine has four Aggregates, each with its own Root, boundary, and Repository:

The dashed arrows between Aggregates represent ID references only — Order stores a CustomerId string, not a Customer object. When the use case needs both, it loads them separately via their respective Repositories.


Summary

Concept Domain Rule
Aggregate Root The sole gateway for mutations within the boundary; external code never holds direct references to child entities
ReadonlyArray TypeScript's mechanism for exposing child entity collections without mutation access
Consistency Boundary One database transaction modifies exactly one Aggregate
ID Reference Aggregates reference other Aggregates by ID only, never by object reference
IOrderRepository Defined in src/domain/; uses only domain types; zero ORM vocabulary
InMemoryOrderRepository A real implementation for fast tests; clones objects to simulate database isolation
Contract Tests A shared abstract test suite that both InMemory and Prisma implementations must pass
reconstitute() A second factory for loading from storage that bypasses creation-only invariants

What's Next

In Part 5, we tackle the cross-Aggregate coordination problem: when an Order is placed, the Inventory, Billing, and Notification services must react without direct coupling. Domain Events and the Mediator pattern are the answer. Part 5: Domain Events & Mediator →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Domain-Driven Design#Aggregates#Repository Pattern#TypeScript#Node.js#OOP#Consistency Boundaries
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.