DDD · Tactical Design
Designing Aggregates:
Transaction Boundaries and Invariants
An Aggregate isn't a folder for related data — it's the boundary of a transaction and the owner of an invariant. Get the boundary wrong, and every save becomes a negotiation between models that shouldn't know about each other.
Once you've settled a Bounded Context's boundary, the next question is what happens inside it. This is where tactical design lives: Aggregate, Entity, Value Object, Domain Event. Of these, the Aggregate Root decision matters most, because it's the one thing that quietly determines your transaction size, your lock contention, and how many other objects a single save has to know about.
The Aggregate Root's Job
An Aggregate Root encapsulates business rules and invariants. Nothing outside it changes its internal state directly — a change always goes through one of its own domain methods, and a violated invariant throws immediately, inside that method, not somewhere downstream.
// domain/OrderException.kt
sealed class OrderException(message: String) : RuntimeException(message)
class OrderMustHaveItemsException : OrderException("An order must have at least one item.")
class OrderAlreadyCancelledException : OrderException("This order has already been cancelled.")
class PaidOrderCannotBeCancelledException : OrderException("A paid order cannot be cancelled.")
// domain/Order.kt — private constructor() + companion object factory, no framework import.
enum class OrderStatus { PENDING, PAID, CANCELLED }
class Order private constructor() {
var orderId: String = ""
private set
var userId: String = ""
private set
var items: List<OrderItem> = emptyList()
private set
var status: OrderStatus = OrderStatus.PENDING
private set
private val domainEvents: MutableList<Any> = mutableListOf()
companion object {
fun create(orderId: String, userId: String, items: List<OrderItem>, status: OrderStatus): Order {
if (items.isEmpty()) throw OrderMustHaveItemsException()
return Order().apply {
this.orderId = orderId
this.userId = userId
this.items = items
this.status = status
}
}
}
fun pullDomainEvents(): List<Any> = domainEvents.toList().also { domainEvents.clear() }
fun cancel(reason: String) {
if (status == OrderStatus.CANCELLED) throw OrderAlreadyCancelledException()
if (status == OrderStatus.PAID) throw PaidOrderCannotBeCancelledException()
status = OrderStatus.CANCELLED
domainEvents += OrderCancelledEvent(orderId, reason, LocalDateTime.now())
}
}An Application Service never carries out business logic itself — it delegates to an Aggregate method and nothing more. If you find yourself writing an if statement about the domain inside a Command Service, that logic almost certainly belongs one layer down.
Reference Other Aggregates by ID, Never by Object
The transaction boundary is set at the Aggregate Root level — only one Aggregate changes per transaction. That's only possible if Aggregates don't hold direct object references to each other. Order holds a userId: String, never a User object. An object reference creates coupling that an ID reference avoids: loading one Aggregate never cascades into loading a graph of others just to satisfy a type.
Entities and Value Objects Live at the Same Layer, With Different Contracts
An Entity's equality is judged by a unique identifier — two objects with the same ID are the same object even if every other field differs, and it has a lifecycle: created, modified, deleted. A child Entity inside an Aggregate, like an OrderItem, is only ever accessed and modified through the Aggregate Root that owns it.
A Value Object has no identifier at all — its equality is judged by the combination of its values, and it's immutable.
// domain/MoneyException.kt
sealed class MoneyException(message: String) : RuntimeException(message)
class InvalidMoneyAmountException : MoneyException("The amount must be 0 or greater.")
class CurrencyMismatchException : MoneyException("The currencies are different.")
// domain/Money.kt — a data class gets equals()/hashCode()/copy() for free, no manual equals() needed.
enum class Currency { KRW, USD }
data class Money(val amount: Long, val currency: Currency) {
init {
if (amount < 0) throw InvalidMoneyAmountException()
}
fun add(other: Money): Money {
if (currency != other.currency) throw CurrencyMismatchException()
return Money(amount + other.amount, currency)
}
}Reach for a Value Object whenever an object's attributes alone convey its meaning and it doesn't need an identifier — an amount, an address, a coordinate pair — and whenever immutability needs to be guaranteed.
Deciding Where the Boundary Goes
Group objects into the same Aggregate when they're created and deleted together, and when they must always change together to keep an invariant intact — Order and OrderItem, because an order with no items isn't a valid order. Split them into separate Aggregates when they're looked up and modified independently, and a change on one side doesn't touch the other's invariants — Order and User, because cancelling an order doesn't affect the user's info at all.
A single save method changes dozens of rows. It directly contains another Aggregate as an object, not just an ID. Optimistic-lock conflicts start happening often. Any of these is a signal to look for a seam, not to add more indexes.
When the boundary genuinely isn't clear, start small. Merging two Aggregates later, once you've watched how they actually change in production, is a far cheaper move than trying to split an overgrown one apart under load.
Generating the Aggregate's Own ID
The ID is generated in the Domain layer — inside the Aggregate's own create() factory — and the server always generates it, never a client-supplied value. The format is a UUID v4 with hyphens stripped, a 32-character hex string, not an auto-increment number: an incrementing ID exposes record count and creation order externally, can collide across services or shards, and isn't determined until the DB assigns it, so it can never be pre-generated where the Domain layer needs it.
// common/GenerateId.kt
import java.util.UUID
fun generateId(): String = UUID.randomUUID().toString().replace("-", "")
// domain/Order.kt
class Order private constructor() {
var orderId: String = ""
private set
var userId: String = ""
private set
companion object {
// Called for a brand-new Order — the ID is generated here.
fun create(userId: String): Order =
Order().apply {
this.orderId = generateId()
this.userId = userId
}
// Called by the Repository implementation when restoring from the DB — the existing ID is passed straight through.
fun reconstitute(orderId: String, userId: String): Order =
Order().apply {
this.orderId = orderId
this.userId = userId
}
}
}On new creation, create() generates the ID itself; on restoring from the DB, the Repository implementation calls reconstitute() with the existing ID passed straight through. Either way, the Repository never issues a fresh ID of its own — it uses whatever ID the Aggregate already carries.
A Checklist for the Boundary
- Does a save through this Aggregate ever touch more than one table's worth of real invariant?
- Is a business rule split across two Aggregates that never load together?
- Does this Aggregate hold another Aggregate by object reference instead of by ID?
- Have optimistic-lock conflicts on this Aggregate become a recurring complaint?
- Would merging two Aggregates make more invariants provably true in one transaction?
None of this is about finding the one correct diagram. It's about keeping the unit that guards a rule exactly as large as the rule requires — no bigger, so it doesn't drag unrelated data into every lock, and no smaller, so the rule it's supposed to protect doesn't leak out into whichever Service happened to call it first.
docs/architecture/tactical-ddd.md — Aggregate/Entity/Value Object design and boundary criteria in full · docs/architecture/aggregate-id.md — the ID-generation rules and Repository handling