Domain-Driven Design (DDD) — Bounded Contexts in Practice
A practical guide to Domain-Driven Design — bounded contexts, aggregates, value objects, and how to apply DDD without drowning in abstraction.
Domain-Driven Design sounds academic. Bounded contexts, ubiquitous language, aggregates, value objects — it feels like enterprise architecture for the sake of it.
But DDD solves a real problem: as systems grow, different parts of the business use the same words to mean different things. “User” in billing is different from “User” in shipping. “Order” in the warehouse has fields that the payment team doesn’t care about. Without clear boundaries, code becomes a tangled mess of shared models with too many responsibilities.
This post focuses on the practical parts of DDD — the concepts you’ll actually use.
Bounded contexts
A bounded context is a boundary within which a model has a specific, consistent meaning.
Example: an e-commerce system.
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Catalog │ │ Ordering │ │ Shipping │
│ │ │ │ │ │
│ Product: │ │ Order: │ │ Shipment: │
│ - name │ │ - items │ │ - tracking # │
│ - description │ │ - total │ │ - carrier │
│ - price │ │ - payment status │ │ - address │
│ - category │ │ - customer │ │ - weight │
│ - images │ │ │ │ │
│ │ │ Customer: │ │ Package: │
│ Product here = │ │ - name │ │ - dimensions │
│ "something to │ │ - email │ │ - items │
│ browse and buy" │ │ - billing addr │ │ - fragile? │
│ │ │ │ │ │
│ Customer here = │ │ Product here = │ │ Customer here = │
│ "someone who │ │ "a line item with │ │ "a delivery │
│ browses" │ │ price and qty" │ │ address" │
└─────────────────┘ └──────────────────┘ └─────────────────┘
“Product” means something different in each context:
- Catalog: full product with images, descriptions, SEO data
- Ordering: a line item with price and quantity
- Shipping: dimensions and weight for packaging
Each context has its own model of “Product.” They don’t share a single Product class — that would force every context to know about fields it doesn’t need.
Ubiquitous language
Within a bounded context, everyone — developers, product managers, domain experts — uses the same terms to mean the same things. This is the ubiquitous language.
In the Ordering context:
- “Place an order” means creating a new order with items
- “Cancel an order” means reversing the order before fulfillment
- “Customer” means someone with billing information
In the Shipping context:
- “Ship” means creating a shipment with tracking
- “Customer” means a delivery address
- “Cancel” means stopping a shipment in transit
The terms are context-specific. Code should reflect this:
// Ordering context
package com.example.ordering
data class Customer(
val id: String,
val name: String,
val email: String,
val billingAddress: Address
)
// Shipping context
package com.example.shipping
data class Customer(
val id: String,
val name: String,
val deliveryAddress: Address,
val phone: String
)
Two different Customer classes. Same name, different meanings, different packages. This is intentional.
Aggregates
An aggregate is a cluster of domain objects that are treated as a single unit for data changes. Every aggregate has a root entity that controls access.
// Order is the aggregate root
class Order private constructor(
val id: OrderId,
val customerId: CustomerId,
private val _items: MutableList<OrderItem>,
private var _status: OrderStatus,
val createdAt: Instant
) {
val items: List<OrderItem> get() = _items.toList()
val status: OrderStatus get() = _status
val total: Money
get() = _items.fold(Money.ZERO) { acc, item -> acc + item.subtotal }
fun addItem(productId: ProductId, quantity: Int, unitPrice: Money) {
require(_status == OrderStatus.DRAFT) { "Cannot modify a non-draft order" }
require(quantity > 0) { "Quantity must be positive" }
val existing = _items.find { it.productId == productId }
if (existing != null) {
existing.increaseQuantity(quantity)
} else {
_items.add(OrderItem(productId, quantity, unitPrice))
}
}
fun removeItem(productId: ProductId) {
require(_status == OrderStatus.DRAFT) { "Cannot modify a non-draft order" }
_items.removeAll { it.productId == productId }
}
fun place(): List<DomainEvent> {
require(_status == OrderStatus.DRAFT) { "Order is not in draft status" }
require(_items.isNotEmpty()) { "Cannot place an empty order" }
_status = OrderStatus.PLACED
return listOf(OrderPlaced(id, customerId, total, Instant.now()))
}
fun cancel(): List<DomainEvent> {
require(_status == OrderStatus.PLACED) { "Can only cancel placed orders" }
_status = OrderStatus.CANCELLED
return listOf(OrderCancelled(id, Instant.now()))
}
companion object {
fun create(customerId: CustomerId): Order {
return Order(
id = OrderId(UUID.randomUUID().toString()),
customerId = customerId,
_items = mutableListOf(),
_status = OrderStatus.DRAFT,
createdAt = Instant.now()
)
}
}
}
Aggregate rules
- All changes go through the root — you never modify
OrderItemdirectly, only throughOrder - Invariants are enforced by the root — “can’t add items to a placed order” is checked by
Order.addItem() - One aggregate per transaction — save one
Orderper database transaction, notOrder + Payment + Inventory - Reference other aggregates by ID —
customerId: CustomerId, notcustomer: Customer
Value objects
Value objects have no identity — they’re defined by their attributes. Two Money(10, "USD") are the same regardless of which variable holds them.
data class Money(val amount: BigDecimal, val currency: String) {
init {
require(amount >= BigDecimal.ZERO) { "Amount cannot be negative" }
}
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 * BigDecimal(quantity), currency)
}
companion object {
val ZERO = Money(BigDecimal.ZERO, "USD")
}
}
data class Address(
val street: String,
val city: String,
val state: String,
val zipCode: String,
val country: String
)
@JvmInline
value class OrderId(val value: String)
@JvmInline
value class CustomerId(val value: String)
@JvmInline
value class ProductId(val value: String)
Value objects are immutable. Changing an address means creating a new Address, not mutating the existing one. Kotlin’s data class and value class are perfect for this.
Domain events
When something important happens in the domain, publish an event:
sealed class DomainEvent {
abstract val occurredAt: Instant
}
data class OrderPlaced(
val orderId: OrderId,
val customerId: CustomerId,
val total: Money,
override val occurredAt: Instant
) : DomainEvent()
data class OrderCancelled(
val orderId: OrderId,
override val occurredAt: Instant
) : DomainEvent()
Domain events are facts about what happened. Other bounded contexts react to them:
Ordering → OrderPlaced → Shipping context creates shipment
→ Payment context initiates charge
→ Notification context sends email
Repositories
Repositories provide persistence for aggregates. One repository per aggregate root:
interface OrderRepository {
suspend fun findById(id: OrderId): Order?
suspend fun save(order: Order)
suspend fun findByCustomer(customerId: CustomerId): List<Order>
}
The repository hides database details. The domain layer doesn’t know about SQL, JPA, or MongoDB. It just calls save(order).
Application services
Application services orchestrate domain operations. They don’t contain business logic — they coordinate:
class PlaceOrderService(
private val orderRepository: OrderRepository,
private val eventPublisher: DomainEventPublisher
) {
suspend fun execute(command: PlaceOrderCommand): OrderId {
val order = orderRepository.findById(OrderId(command.orderId))
?: throw OrderNotFoundException(command.orderId)
val events = order.place()
orderRepository.save(order)
events.forEach { eventPublisher.publish(it) }
return order.id
}
}
The business rules (“can only place a draft order with items”) live in the Order aggregate. The service just loads, calls, saves, and publishes.
Context mapping — How contexts communicate
When bounded contexts need to talk to each other:
Shared Kernel
Two contexts share a small set of code (value objects, events):
// shared-kernel module
data class Money(val amount: BigDecimal, val currency: String)
sealed class OrderEvent {
data class OrderPlaced(val orderId: String, val total: Money) : OrderEvent()
}
Use sparingly — shared code creates coupling.
Anti-Corruption Layer (ACL)
When integrating with an external system or legacy context, translate between their model and yours:
class LegacyOrderAdapter(
private val legacyClient: LegacyOrderClient
) : ExternalOrderPort {
override suspend fun getOrder(id: String): Order {
val legacyOrder = legacyClient.fetchOrder(id) // legacy format
return Order(
id = OrderId(legacyOrder.orderNumber),
customerId = CustomerId(legacyOrder.clientCode),
// translate legacy fields to your domain model
)
}
}
The ACL prevents the legacy system’s model from leaking into your domain.
Practical: When to apply DDD
Good fit
- Complex business domains (finance, healthcare, logistics)
- Multiple teams working on different parts of the system
- Business rules change frequently
- Domain experts are available to collaborate
Overkill
- Simple CRUD apps (todo lists, basic dashboards)
- Technical domains with little business logic
- Small teams where everyone understands the whole system
- Prototypes and MVPs
Start small
You don’t need to apply DDD everywhere. Start with:
- Identify bounded contexts — draw the boundaries, name them
- Use value objects —
Money,Email,PhoneNumberinstead of raw strings - Protect invariants — put business rules in domain objects, not services
- Publish domain events — let other contexts react asynchronously
You can adopt aggregates, repositories, and application services later when complexity demands it.
Common mistakes
1. Sharing a single model across contexts
One Product class used by catalog, ordering, shipping, and analytics. It grows to 50 fields. No one understands it. Changes in one context break another.
2. Anemic domain models
// Anemic — data class with no behavior
data class Order(var status: String, var items: List<Item>)
// Service does all the work
class OrderService {
fun placeOrder(order: Order) {
if (order.status != "DRAFT") throw Exception("...")
if (order.items.isEmpty()) throw Exception("...")
order.status = "PLACED"
}
}
The Order is just a data bag. Business logic is scattered in services. Fix: put behavior in the domain object.
3. Over-engineering simple domains
Not everything needs aggregates, domain events, and bounded contexts. If the domain is “save this form to a database,” a simple repository pattern is fine.
Summary
| Concept | Purpose |
|---|---|
| Bounded Context | A boundary with a consistent model and language |
| Ubiquitous Language | Shared vocabulary within a context |
| Aggregate | Cluster of objects with an enforced boundary |
| Value Object | Immutable object defined by its attributes |
| Domain Event | Record of something that happened |
| Repository | Persistence for aggregate roots |
| Application Service | Orchestrates domain operations |
| Anti-Corruption Layer | Translates between contexts |
DDD is about aligning your code with the business. Bounded contexts keep models clean. Aggregates protect invariants. Value objects prevent primitive obsession. Apply the pieces that solve real problems in your domain — don’t adopt the whole framework because a book told you to.