Skip to content
ioob.dev
Go back

DDD Part 2 — Ubiquitous Language and Bounded Context

· 8 min read
DDD Series (2/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

Three Different Products

Say you’re building an e-commerce system. In planning meetings, the word product comes up endlessly. But if you listen carefully, each person uses product with a different meaning.

The catalog team sees a product as display information. Name, description, images, category. It’s the data on the page that customers search and click.

The ordering team sees a product as an order item. Quantity, unit price, whether a discount applies. It’s the unit that customers add to their cart and pay for. The catalog’s images or categories are irrelevant.

The shipping team sees a product as a physical parcel. Weight, volume, packaging method, warehouse location. The display description isn’t needed, and discount status doesn’t matter.

Three teams are discussing products, but they’re actually talking about three different concepts. What happens when this confusion is reflected directly in code?

// The result of cramming every context's "product" info into one class
public class Product {
    // Catalog concerns
    private String name;
    private String description;
    private String imageUrl;
    private String category;

    // Ordering concerns
    private BigDecimal price;
    private BigDecimal discountRate;
    private int stockQuantity;

    // Shipping concerns
    private double weight;
    private double volume;
    private String warehouseCode;
    private PackagingType packagingType;

    // dozens of getters and setters...
}

This class is a god object. Fixing catalog logic breaks ordering tests, and adding a shipping field forces the catalog team to resolve merge conflicts. Since every team depends on a single Product, nobody can change it freely.

DDD solves this problem with two tools: Ubiquitous Language and Bounded Context.

Ubiquitous Language

Ubiquitous Language is a shared terminology that developers and domain experts use within a single project. Ubiquitous means present everywhere. It conveys that this language is used consistently in conversations, documents, and code alike.

There are three core principles.

First, domain language must be reflected in code. If the product manager calls it order confirmation, the code should have confirmOrder(). If the spec says order confirmation but the code reads updateStatus("CONFIRMED"), a translation cost arises between the two.

// Bad: technical expression. "Update status" is not domain language
fun updateOrderStatus(orderId: Long, status: String) { ... }

// Good: domain language. "Confirm the order"
fun confirmOrder(orderId: OrderId) { ... }

This difference might seem trivial, but it matters when you have hundreds of methods. Finding “where’s the order cancellation logic?” among twenty updateXxxStatus methods in a service class versus directly locating cancelOrder() is a completely different cognitive load.

Second, developers and domain experts must use the same terms in conversation. If the product manager says order confirmation and the developer responds, “Oh, you mean the status update?” — at that moment, they’re living in different worlds. Aligning terminology raises the quality of conversation.

Third, if the terms are ambiguous, the model is ambiguous. Think about the word process. Does processing an order mean confirming the order, initiating payment, or starting shipment? A single vague verb like this can become the root cause of bugs. Ubiquitous language doesn’t tolerate such ambiguity.

Building a ubiquitous language is not simply writing a glossary. It’s an ongoing activity where domain experts and developers talk together, sketch models, and agree: let's call this concept that. It naturally gets refined through modeling sessions at a whiteboard or workshops like Event Storming.

A Single Language Is Only Valid Within a Single Boundary

Ubiquitous language has an important limitation. A single ubiquitous language is not universal across the entire system; it’s only valid within a specific boundary.

Recall the product example from earlier. A product in the catalog and a product in shipping share only the name — their attributes and behaviors are completely different. Trying to unify these two concepts under one term only increases confusion. If you talk to the catalog team about a product's weight, they’d be puzzled, and if you ask the shipping team about a product's category, the answer would be “that’s not our concern.”

This boundary is precisely the Bounded Context.

Bounded Context

A Bounded Context is an explicit boundary within which a single ubiquitous language applies consistently. Within this boundary, every term has exactly one meaning.

Dividing an e-commerce system into bounded contexts looks like this:

flowchart TB
    subgraph catalog["Catalog Context"]
        CP["Product<br/>name, description, image, category"]
        CC["Category<br/>hierarchy, display order"]
    end

    subgraph ordering["Ordering Context"]
        OO["Order<br/>order items, total, status"]
        OI["OrderItem<br/>productID, quantity, unit price"]
    end

    subgraph shipping["Shipping Context"]
        SS["Shipment<br/>shipping status, tracking number"]
        SP["Parcel<br/>weight, volume, packaging method"]
    end

    subgraph payment["Payment Context"]
        PP["Payment<br/>payment method, amount, status"]
        PR["Receipt<br/>receipt, approval number"]
    end

    catalog -.->|"Product ID reference"| ordering
    ordering -.->|"Order confirmed event"| shipping
    ordering -.->|"Payment request"| payment

Within each context, product exists in a different form. The Product in the catalog and the product concept within the ordering context’s OrderItem refer to the same physical product, but are modeled completely differently. And that’s the way it should be.

Let’s see this relationship more clearly. The following diagram shows how the same word product differs depending on the context:

flowchart LR
    subgraph real["Real World"]
        R["Product = one physical thing"]
    end

    subgraph cat["Catalog Context"]
        C["Product<br/>name, description, image<br/>category, display status"]
    end

    subgraph ord["Ordering Context"]
        O["OrderItem<br/>productID, quantity<br/>unit price, discount rate"]
    end

    subgraph ship["Shipping Context"]
        S["Parcel<br/>weight, volume<br/>packaging method, warehouse location"]
    end

    R --> C
    R --> O
    R --> S

A single thing in the real world is represented as different models in software depending on the context. The attempt to force all of these into a single Product class was where the problem began.

Criteria for Drawing Context Boundaries

“So where exactly should you draw the boundaries?” is one of the hardest questions in DDD. There’s no definitive answer, but there are some practical criteria.

The boundary is where language mismatch occurs. When people start using the same word but imagining different attributes, that’s likely a context boundary. A team where product means images and a team where product means weight are in different contexts.

Look for natural breakpoints in business processes. In e-commerce, order confirmation -> payment processing -> shipping initiation are each different processes. Confirming an order doesn’t immediately start shipping, and a failed payment doesn’t make the order itself disappear. These breakpoints between processes often coincide with context boundaries.

Reference team structure. According to Conway’s Law, software architecture follows the communication structure of the organization. If there are separate catalog, ordering, and shipping teams, it’s natural for bounded contexts to align accordingly. Of course, having a single team doesn’t mean you can’t separate contexts, but team boundaries are a useful starting point.

If the reasons for change differ, question the boundary. Changing how the catalog is displayed shouldn’t affect shipping logic. If the two areas change for independent reasons, there’s no reason for them to be in the same context.

Practical Example: E-Commerce

Let’s look more concretely at what models each bounded context holds in an e-commerce system, with code.

The product in the catalog context focuses on display information.

// Catalog Context: display concerns
package com.shop.catalog

data class Product(
    val id: ProductId,
    val name: String,
    val description: String,
    val imageUrls: List<String>,
    val category: Category,
    val displayStatus: DisplayStatus,
) {
    fun publish() {
        // Change display status to "published"
    }

    fun hide() {
        // Change display status to "hidden"
    }
}

enum class DisplayStatus { DRAFT, PUBLISHED, HIDDEN }

In the ordering context, the same product looks completely different.

// Ordering Context: ordering concerns
package com.shop.ordering

class Order private constructor(
    val id: OrderId,
    val customerId: CustomerId,
    private val lines: MutableList<OrderLine>,
    private var status: OrderStatus,
) {
    fun addLine(productId: ProductId, productName: String, price: Money, quantity: Int) {
        require(status == OrderStatus.DRAFT) { "Items can only be added in draft status" }
        lines.add(OrderLine(productId, productName, price, quantity))
    }

    fun totalAmount(): Money =
        lines.fold(Money.ZERO) { acc, line -> acc + line.subtotal() }
}

data class OrderLine(
    val productId: ProductId,     // Does not directly reference the catalog's Product
    val productName: String,      // Snapshot at the time of ordering
    val unitPrice: Money,
    val quantity: Int,
) {
    fun subtotal(): Money = unitPrice * quantity
}

The key thing to notice here is that OrderLine doesn’t directly reference the catalog’s Product object. It connects only via productId and stores the product name and price as a snapshot at the time of ordering. Why?

Because even if the product name changes in the catalog, the product name on an already-placed order shouldn’t change. Even if the product price increases after order confirmation, the order amount should stay the same. The two contexts change independently. That’s why we use ID references and snapshots instead of object references.

The shipping context offers yet another perspective.

// Shipping Context: logistics concerns
package com.shop.shipping

class Shipment(
    val id: ShipmentId,
    val orderId: OrderId,         // Connected to the ordering context by ID only
    val parcels: List<Parcel>,
    private var status: ShipmentStatus,
    private var trackingNumber: String? = null,
) {
    fun dispatch(trackingNumber: String) {
        require(status == ShipmentStatus.READY) { "Can only dispatch when status is ready" }
        this.trackingNumber = trackingNumber
        this.status = ShipmentStatus.DISPATCHED
    }
}

data class Parcel(
    val weight: Weight,
    val dimensions: Dimensions,
    val packagingType: PackagingType,
)

The shipping context doesn’t even have the concept of Product. Instead, it has Parcel, and uses logistics-specific value objects like Weight and Dimensions. It has no need to know what the product image looks like or what the discount rate is.

This is the power of bounded contexts. Each context focuses on its own concerns and is unaffected by changes in other contexts.

Package Structure as Boundaries

The most direct way to reflect bounded contexts in code is through package structure.

com.shop
├── catalog/            <- Catalog Context
│   ├── domain/
│   │   ├── Product.kt
│   │   ├── Category.kt
│   │   └── ProductRepository.kt
│   └── application/
│       └── ProductService.kt

├── ordering/           <- Ordering Context
│   ├── domain/
│   │   ├── Order.kt
│   │   ├── OrderLine.kt
│   │   └── OrderRepository.kt
│   └── application/
│       └── OrderService.kt

├── shipping/           <- Shipping Context
│   ├── domain/
│   │   ├── Shipment.kt
│   │   ├── Parcel.kt
│   │   └── ShipmentRepository.kt
│   └── application/
│       └── ShipmentService.kt

└── payment/            <- Payment Context
    ├── domain/
    │   ├── Payment.kt
    │   └── PaymentRepository.kt
    └── application/
        └── PaymentService.kt

Even in a monolithic application, you can express context boundaries through packages. catalog.domain.Product and ordering.domain.OrderLine run on the same JVM, but they don’t directly reference each other. They connect only by ID, and communicate through events when necessary.

This structure can also serve as the starting point for a microservices migration. When package boundaries are clear, the unit to extract when separating a context into its own service is already defined. Starting with a monolith and splitting when needed is a healthy strategy.

Common Mistake: Trying to Represent Everything with a Single Model

The most commonly seen structure in systems that don’t apply DDD is the unified model. One Product, one User, one Order to cover the entire system.

This approach is convenient at first. Fewer classes, so the structure looks simple. But as the system grows, problems emerge.

Bounded contexts solve this problem at its root. Splitting a single real-world concept of product into multiple models might initially feel like duplication, but in reality each model contains only what’s needed for its own context, making it more cohesive.

Key Takeaways

Here’s a summary of what this article covered.


The next article covers Context Mapping. We’ll explore how to define the relationships between bounded contexts and what collaboration patterns are available.

-> Part 3: Context Mapping — Patterns for Defining Relationships Between Contexts


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
DDD Part 1 — Why Domain-Centric Design Matters
Next Post
DDD Part 3 — Context Mapping