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.
Backend Clean Architecture & Domain-Driven Design
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.
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:
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:
- Only the Root is accessible from outside the boundary. External code holds a reference to
Order, never directly toLineItem. - All mutations inside the boundary go through Root methods.
order.addItem(), neverlineItem.setQuantity(). - 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:
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:
Now consider what happens when external code tries to bypass the Aggregate Root:
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.
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.
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.statusANDOrder.items.lengthsimultaneously → both inside the Aggregate ✅ - "An Order cannot be placed without a verified customer payment method." — requires
OrderANDCustomer.paymentMethodssimultaneously. ButCustomerhas 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.itemsAND all otherOrder.itemsglobally → 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:
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/.
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:
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:
Now both repository implementations are tested with exactly the same suite:
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:
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.
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.
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 →
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.