Skip to content
ioob.dev
Go back

DDD Part 5 — Aggregate and Repository

· 9 min read
DDD Series (5/7)
  1. DDD Part 1 — Why Domain-Centric Design Matters
  2. DDD Part 2 — Ubiquitous Language and Bounded Context
  3. DDD Part 3 — Context Mapping
  4. DDD Part 4 — Entity and Value Object
  5. DDD Part 5 — Aggregate and Repository
  6. DDD Part 6 — Domain Service and Application Service
  7. DDD Part 7 — Domain Event and Anti-Corruption Layer
Table of contents

Table of contents

The Temptation to Put Everything in One Transaction

Say you’re building an ordering system. When an order is created, items are added, inventory is deducted, payment is requested, and a notification is sent. If all of this were processed in a single transaction, there’d be no data inconsistency. But the moment you wrap four or five tables in one transaction, the lock scope widens and throughput hits the floor.

But what if you split each table into individual transactions? You end up with states where the order was created but inventory deduction failed. Where do you draw the line between consistency and performance? DDD’s answer to this question is the Aggregate.

What Is an Aggregate

An Aggregate is a design pattern that groups related objects into a single unit, guaranteeing strong consistency only within that group. In Eric Evans’ words, it’s a unit of data change. The objects inside an aggregate must always maintain a valid state together, and external access goes only through the group’s entry point.

The key concept is boundary. Inside the aggregate boundary, strong consistency is guaranteed. Outside the boundary — meaning relationships with other aggregates — eventual consistency is allowed. This distinction determines the scope of transactions and governs the system’s scalability.

Aggregate Root — The Single Entry Point

Multiple entities and value objects can coexist within an aggregate. The sole gateway for external communication is the Aggregate Root.

flowchart TB
    subgraph OrderAggregate["Order Aggregate"]
        direction TB
        Order["Order<br/>(Aggregate Root)"]
        OI1["OrderItem"]
        OI2["OrderItem"]
        OI3["OrderItem"]
        SA["ShippingAddress<br/>(Value Object)"]

        Order --> OI1
        Order --> OI2
        Order --> OI3
        Order --> SA
    end

    Client["External Code"] -->|"Access only through Order"| Order
    Client -.->|"Direct access prohibited"| OI1

    style Order fill:#4a90d9

External code is not allowed to directly create or modify OrderItem. It must go through Order. The reason is clear: Order bears the responsibility of guaranteeing the validity of the entire state. For example, the rule that there must be at least one order item — OrderItem itself has no way to enforce this. Only Order can inspect its own item list and validate this rule.

The principle that the Aggregate Root is the sole entry point implies three things:

  1. External objects reference only the Aggregate Root. Internal entity references are not exposed outside.
  2. All state changes go through the Aggregate Root’s methods. Directly manipulating internal objects can break invariants.
  3. Persistence and retrieval happen at the Aggregate Root level. Something like OrderItemRepository doesn’t exist.

Transaction Boundary = Aggregate Boundary

In DDD, the principle is that a single transaction modifies only a single aggregate. This rule might feel excessive at first. You need to create an order and deduct inventory at the same time — you’re saying you can’t do both in one transaction?

Correct. The order aggregate and the inventory aggregate are processed in separate transactions. Inventory deduction is handled asynchronously by subscribing to the order creation event, or executed in a separate transaction. This way, creating an order doesn’t hold a lock on the inventory table, greatly improving concurrency.

Of course, a time gap arises where the order exists but inventory hasn’t been reduced yet. This is eventual consistency. You tolerate brief inconsistency while designing the system to ultimately reach a consistent state. Compensation transactions and the Saga pattern are used to bridge this gap.

The foundation of this structure is Domain Events, which are covered in detail in Part 7.

Aggregate Design Rules

Here are practical rules for designing aggregates well. The rules presented in Vaughn Vernon’s Implementing Domain-Driven Design hold up well in practice.

Rule 1: Keep Aggregates Small

Larger aggregates mean larger transaction scopes. If you put orders, shipping, payments, and reviews all in one aggregate, editing a single review locks the entire order.

Small aggregates reduce concurrency conflicts and use less memory. If the number of entities in a single aggregate exceeds three, it’s time to re-examine the design.

Rule 2: Reference Other Aggregates by ID Only

Placing direct object references between aggregates breaks the boundaries. If Order directly holds a Product object, loading an Order also pulls up the Product, and modifying the Product affects the Order’s transaction.

Using ID references eliminates this problem.

// Bad — direct reference to another Aggregate
class Order(
    val id: OrderId,
    val product: Product  // Direct reference to Product Aggregate
)

// Good — reference by ID only
class Order(
    val id: OrderId,
    val productId: ProductId  // Only stores the ID
)

If you need detailed Product information, the Application Service retrieves it separately through ProductRepository. Coupling between aggregates drops, and each can be independently deployed and scaled.

Rule 3: Actively Leverage Eventual Consistency

You need to let go of the desire to make everything immediately consistent. In the rule when an order is completed, loyalty points are earned — is there a business reason why the order completion and point accrual must happen simultaneously down to the millisecond? In most cases, no.

Here’s a guideline for determining whether eventual consistency applies:

Rule 4: Use Invariants as the Basis for Boundaries

The most important criterion for determining an aggregate’s boundary is invariants. An invariant is a business rule that must always hold true.

If there’s an invariant that the order total must equal the sum of all order items, then Order and OrderItem must be in the same aggregate. When a single OrderItem changes, Order’s total must be updated together. In contrast, “a review can be written after an order” is not an invariant. Since there’s no need for immediate consistency between orders and reviews, they should be separated into distinct aggregates.

Order Aggregate Implementation

Let’s translate theory into code. The Order Aggregate consists of Order (Root), OrderItem (internal Entity), and ShippingAddress (Value Object).

First, let’s define the Value Objects. Kotlin’s data class is well-suited for expressing Value Objects.

data class Money(
    val amount: BigDecimal,
    val currency: String = "KRW"
) {
    init {
        require(amount >= BigDecimal.ZERO) { "Amount must be zero or greater" }
    }

    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "Cannot add different currencies" }
        return Money(amount + other.amount, currency)
    }

    operator fun times(quantity: Int): Money {
        return Money(amount * quantity.toBigDecimal(), currency)
    }
}

data class ShippingAddress(
    val city: String,
    val street: String,
    val zipCode: String
)

Next, we define the internal entity OrderItem. OrderItem has its own identifier but doesn’t exist independently outside of Order.

class OrderItem(
    val id: Long,
    val productId: Long,
    val productName: String,
    val price: Money,
    val quantity: Int
) {
    init {
        require(quantity > 0) { "Quantity must be at least 1" }
    }

    fun totalPrice(): Money = price * quantity
}

Now let’s implement the Aggregate Root, Order. The key is that all state changes go through Order’s methods.

class Order private constructor(
    val id: Long,
    val customerId: Long,
    private val _items: MutableList<OrderItem> = mutableListOf(),
    private var _shippingAddress: ShippingAddress? = null,
    private var _status: OrderStatus = OrderStatus.CREATED
) {
    val items: List<OrderItem> get() = _items.toList()
    val shippingAddress: ShippingAddress? get() = _shippingAddress
    val status: OrderStatus get() = _status

    companion object {
        fun create(id: Long, customerId: Long): Order {
            return Order(id = id, customerId = customerId)
        }
    }

    fun addItem(item: OrderItem) {
        require(_status == OrderStatus.CREATED) {
            "Items can only be added in created status"
        }
        require(_items.size < 20) {
            "An order can have a maximum of 20 items"
        }
        _items.add(item)
    }

    fun removeItem(itemId: Long) {
        require(_status == OrderStatus.CREATED) {
            "Items can only be removed in created status"
        }
        val removed = _items.removeIf { it.id == itemId }
        require(removed) { "The specified item does not exist: $itemId" }
    }

    fun assignShippingAddress(address: ShippingAddress) {
        _shippingAddress = address
    }

    fun place() {
        require(_items.isNotEmpty()) { "Cannot place an order with no items" }
        requireNotNull(_shippingAddress) { "A shipping address is required" }
        _status = OrderStatus.PLACED
    }

    fun totalAmount(): Money {
        return _items.fold(Money(BigDecimal.ZERO)) { acc, item ->
            acc + item.totalPrice()
        }
    }
}

enum class OrderStatus {
    CREATED, PLACED, PAID, SHIPPED, DELIVERED, CANCELLED
}

Looking at Order.place(), it validates two invariants: items must not be empty, and a shipping address must exist. Because these validations are concentrated in the Aggregate Root, no matter what order external code calls methods in, transitions to invalid states are prevented.

Repository — Persistence at the Aggregate Level

If an Aggregate defines the boundary of consistency, a Repository is the abstraction that handles persistence and retrieval at that boundary level. The name Repository is a term Eric Evans defined in DDD, referring to an interface that acts like a collection of domain objects.

The core rule is simple. One Repository per Aggregate Root. OrderRepository exists, but OrderItemRepository does not. OrderItem is part of Order, so saving Order also saves OrderItem, and loading Order also loads OrderItem.

flowchart LR
    subgraph Domain["Domain Layer"]
        OR["OrderRepository<br/>(Interface)"]
    end

    subgraph Infra["Infrastructure Layer"]
        JPA["JpaOrderRepository<br/>(Implementation)"]
        DB[("Database")]
    end

    App["Application Service"] --> OR
    OR -.->|"implements"| JPA
    JPA --> DB

    style Domain fill:#5ca45c
    style Infra fill:#d4943a
    style App fill:#4a90d9

The Repository interface lives in the domain layer, and the implementation goes in the infrastructure layer. Thanks to this separation, domain logic doesn’t depend on whether JPA, MyBatis, or even an in-memory Map is used for persistence.

Repository Interface Design

The Repository interface follows the collection metaphor. It expresses operations of putting in, taking out, and finding domain objects.

interface OrderRepository {
    fun save(order: Order): Order
    fun findById(id: Long): Order?
    fun findByCustomerId(customerId: Long): List<Order>
    fun delete(order: Order)
}

What’s important here is that this interface is defined in the domain layer. save and findById are not SQL or JPA concepts — they’re domain language for put into the collection and find in the collection.

Repository vs DAO

Repository and DAO (Data Access Object) look similar but differ in their level of abstraction.

A DAO abstracts table-level CRUD. OrderDao provides insert, select, update, delete for the orders table, and OrderItemDao provides the same operations for the order_items table. It’s a 1:1 correspondence between tables and DAOs.

A Repository abstracts persistence at the Aggregate level. Calling OrderRepository.save(order) handles the orders table, order_items table, and shipping_addresses table all at once. The caller doesn’t need to know how many tables there are or in what order they’re saved.

CriterionDAORepository
Unit of abstractionTableAggregate
Method naminginsertOrder, selectOrderByIdsave, findById
Return typeDB rows / DTOsDomain objects
CountOne per tableOne per Aggregate Root
Layer affiliationInfrastructureDomain (interface)

A DAO says insert this row into this table, while a Repository says store this order. The difference in abstraction level creates a difference in design thinking.

Repository Pattern with Spring Data JPA

Spring Data JPA pairs well with DDD’s Repository pattern. Extending JpaRepository auto-generates basic CRUD methods, and queries are automatically created based on method name conventions.

However, directly using Spring Data JPA’s JpaRepository in the domain layer creates an infrastructure dependency. The solution is to separate the domain interface from the infrastructure implementation.

First, define the JPA Entity. Keeping domain objects and JPA entities separate prevents the domain model from being polluted with JPA annotations.

@Entity
@Table(name = "orders")
class OrderJpaEntity(
    @Id
    val id: Long,

    @Column(name = "customer_id")
    val customerId: Long,

    @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
    @JoinColumn(name = "order_id")
    val items: MutableList<OrderItemJpaEntity> = mutableListOf(),

    @Embedded
    var shippingAddress: ShippingAddressEmbeddable? = null,

    @Enumerated(EnumType.STRING)
    var status: OrderStatus = OrderStatus.CREATED
)

@Entity
@Table(name = "order_items")
class OrderItemJpaEntity(
    @Id
    val id: Long,

    @Column(name = "product_id")
    val productId: Long,

    @Column(name = "product_name")
    val productName: String,

    @Column(name = "price")
    val price: BigDecimal,

    @Column(name = "currency")
    val currency: String,

    val quantity: Int
)

@Embeddable
data class ShippingAddressEmbeddable(
    val city: String,
    val street: String,
    val zipCode: String
)

The Spring Data JPA interface goes in the infrastructure layer.

interface OrderJpaRepository : JpaRepository<OrderJpaEntity, Long> {
    fun findByCustomerId(customerId: Long): List<OrderJpaEntity>
}

Finally, write the implementation of the domain Repository interface. This implementation handles the conversion between domain objects and JPA entities.

@Repository
class JpaOrderRepositoryAdapter(
    private val jpaRepository: OrderJpaRepository
) : OrderRepository {

    override fun save(order: Order): Order {
        val entity = toEntity(order)
        val saved = jpaRepository.save(entity)
        return toDomain(saved)
    }

    override fun findById(id: Long): Order? {
        return jpaRepository.findById(id)
            .map { toDomain(it) }
            .orElse(null)
    }

    override fun findByCustomerId(customerId: Long): List<Order> {
        return jpaRepository.findByCustomerId(customerId)
            .map { toDomain(it) }
    }

    override fun delete(order: Order) {
        jpaRepository.deleteById(order.id)
    }

    private fun toEntity(order: Order): OrderJpaEntity { /* conversion logic */ }
    private fun toDomain(entity: OrderJpaEntity): Order { /* conversion logic */ }
}

In this structure, the domain layer’s OrderRepository knows nothing about JPA, and all JPA-related code is isolated in the infrastructure layer. When testing domain models, you can plug in an in-memory implementation.

class InMemoryOrderRepository : OrderRepository {
    private val store = mutableMapOf<Long, Order>()

    override fun save(order: Order): Order {
        store[order.id] = order
        return order
    }

    override fun findById(id: Long): Order? = store[id]

    override fun findByCustomerId(customerId: Long): List<Order> {
        return store.values.filter { it.customerId == customerId }
    }

    override fun delete(order: Order) {
        store.remove(order.id)
    }
}

Being able to verify domain logic without a database in tests is a significant advantage. Tests are fast and run without external dependencies, making them stable in CI environments.

Common Aggregate Design Mistakes

Let’s address three aggregate design mistakes frequently seen in practice.

Mistake 1: Giant Aggregates

Putting payment info, shipment tracking, reviews, and refund history all inside an order. This means loading a single order joins all related tables, and editing one review locks the entire order. If their lifecycles are different, they should be separated into distinct aggregates.

Mistake 2: Processing All Relationships with Immediate Consistency

You need to examine whether the business rule “when an order is cancelled, loyalty points are immediately restored” truly requires immediate consistency. If point restoration happening one second later doesn’t affect the user experience, event-based eventual consistency is more appropriate.

Mistake 3: Code That Bypasses the Aggregate Root

Even when the Repository is well-designed at the Aggregate Root level, directly manipulating internal entities in the service layer breaks invariants.

// Wrong — bypassing the Aggregate Root
val order = orderRepository.findById(orderId)!!
order.items.first().quantity = 5  // Direct modification — invariant validation impossible

// Correct — change through the Aggregate Root
order.updateItemQuantity(itemId = 1, newQuantity = 5)  // Order performs validation
orderRepository.save(order)

The principle that state changes must go through the Aggregate Root’s methods isn’t just about following rules. It’s because invariant validation logic must be gathered in one place for maintainability.

Key Takeaways

Here’s a summary of what this article covered.


The next article covers where to place domain logic. Logic that feels awkward in an Entity goes to Domain Service; use case orchestration goes to Application Service — we’ll examine the differences and decision criteria.

-> Part 6: Domain Service, Application Service


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
DDD Part 4 — Entity and Value Object
Next Post
DDD Part 6 — Domain Service and Application Service