Table of contents
- The Temptation to Put Everything in One Transaction
- What Is an Aggregate
- Aggregate Root — The Single Entry Point
- Transaction Boundary = Aggregate Boundary
- Aggregate Design Rules
- Order Aggregate Implementation
- Repository — Persistence at the Aggregate Level
- Repository Interface Design
- Repository vs DAO
- Repository Pattern with Spring Data JPA
- Common Aggregate Design Mistakes
- Key Takeaways
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:
- External objects reference only the Aggregate Root. Internal entity references are not exposed outside.
- All state changes go through the Aggregate Root’s methods. Directly manipulating internal objects can break invariants.
- Persistence and retrieval happen at the Aggregate Root level. Something like
OrderItemRepositorydoesn’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:
- Ask the domain expert: “Would it be a problem if these two things didn’t happen simultaneously?”
- If the answer is “a few seconds is fine,” it’s a candidate for eventual consistency
- If the answer is “absolutely not,” they need to be in the same aggregate
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.
| Criterion | DAO | Repository |
|---|---|---|
| Unit of abstraction | Table | Aggregate |
| Method naming | insertOrder, selectOrderById | save, findById |
| Return type | DB rows / DTOs | Domain objects |
| Count | One per table | One per Aggregate Root |
| Layer affiliation | Infrastructure | Domain (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.
- An Aggregate defines the unit of consistency boundaries and corresponds to the transaction scope
- The Aggregate Root is the sole entry point to the outside, responsible for all state changes and invariant validation
- Keep aggregates small, reference other aggregates by ID only, and leverage eventual consistency between aggregates
- A Repository is an abstraction for persistence at the Aggregate Root level, with the interface in the domain layer and the implementation in the infrastructure layer
- The difference between Repository and DAO lies in the level of abstraction. Repository is a domain concept; DAO is a data access technology
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.




Loading comments...