Table of contents
- Three Different
Products - Ubiquitous Language
- A Single Language Is Only Valid Within a Single Boundary
- Bounded Context
- Criteria for Drawing Context Boundaries
- Practical Example: E-Commerce
- Package Structure as Boundaries
- Common Mistake: Trying to Represent Everything with a Single Model
- Key Takeaways
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.
- A single class has dozens of fields. It’s hard to tell which context each field is used in
- Modifying one field has ripple effects across multiple features. A catalog change cascades to payments
- Different teams editing the same class makes merge conflicts routine
- Tables become massive too. A
productstable with 50 columns appears
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.
- Ubiquitous Language is shared terminology between developers and domain experts. It must be reflected in code as well
- The same word can carry different meanings depending on context, and forcing unification creates a bloated mixed model
- A Bounded Context is an explicit boundary within which a single ubiquitous language is consistently applied
- Between contexts, communication happens through ID references and events. Objects are not shared directly
- Package structure can express context boundaries in code, serving as a starting point for microservices separation
- Criteria for drawing boundaries: language mismatch, process breakpoints, team structure, independence of change reasons
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




Loading comments...