PCSalt
YouTube GitHub
Back to Architecture
Architecture · 4 min read

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

  1. All changes go through the root — you never modify OrderItem directly, only through Order
  2. Invariants are enforced by the root — “can’t add items to a placed order” is checked by Order.addItem()
  3. One aggregate per transaction — save one Order per database transaction, not Order + Payment + Inventory
  4. Reference other aggregates by IDcustomerId: CustomerId, not customer: 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:

  1. Identify bounded contexts — draw the boundaries, name them
  2. Use value objectsMoney, Email, PhoneNumber instead of raw strings
  3. Protect invariants — put business rules in domain objects, not services
  4. 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

ConceptPurpose
Bounded ContextA boundary with a consistent model and language
Ubiquitous LanguageShared vocabulary within a context
AggregateCluster of objects with an enforced boundary
Value ObjectImmutable object defined by its attributes
Domain EventRecord of something that happened
RepositoryPersistence for aggregate roots
Application ServiceOrchestrates domain operations
Anti-Corruption LayerTranslates 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.