When a project is small, a single developer keeps everything in their head. But as soon as the team grows and the system gets more complex, confusion sets in: some people call an entity a "request", others a "query", and others an "order". One module pulls data from another directly, and nobody really knows where "sales" ends and "warehouse" begins.
Strategic DDD patterns are tools for bringing order at the level of the whole system: how to split it into parts, how to call things by their proper names, and how to organize the interaction between the parts.
Ubiquitous Language
Imagine: the business says "payment dispute", the analyst writes "complaint", and in the code there's PaymentProblem, Claim and Dispute all mixed together. Every "translation" is a potential error. The developer implements something other than what the business intended, because they misunderstood the term.
Ubiquitous language is a shared vocabulary of terms used by both the business and the developers everywhere: in conversations, in documentation and in code.
Once you agree to call a "payment dispute" a Dispute, that word shows up everywhere:
// Bad: the term comes from the developer's head
type PaymentProblem struct{}
func (p *PaymentProblem) Resolve() { /* ... */ }
// Good: the term comes from the business vocabulary
type Dispute struct {
status DisputeStatus
}
func (d *Dispute) Accept() {
d.status = DisputeStatusAccepted
}
func (d *Dispute) Reject(reason string) {
d.status = DisputeStatusRejected
}
When the code reads like a description of the business process, new people get up to speed quickly, and on review it's immediately visible if something doesn't match the agreements.
How to build a ubiquitous language:
- put together a glossary of key terms together with the business;
- if a term is ambiguous — pick one option and lock it down (in a wiki, an ADR or a README);
- on review, check that new code uses the words from the glossary;
- update the glossary as new concepts appear.
An important point: a ubiquitous language holds only within a single context. In different parts of the system the same word can mean different things — and that's fine. That's exactly why you need bounded contexts.
Bounded Context — the boundary within which the model doesn't lie
The word "client" in billing means a legal entity with a tax ID and a billing address. In customer support it's a user with open tickets. In marketing it's a member of the loyalty program with points.
If you try to describe all of this with a single Client struct, it grows fields from every context at once:
// Anti-pattern: one struct for all contexts
type Client struct {
inn string // needed by billing
billingAddress string // needed by billing
email string // needed by support
openTickets int // needed by support
loyaltyLevel string // needed by marketing
bonusPoints int // needed by marketing
}
Such a struct is a "God object". Any change in it can break something unexpected.
Bounded context is an explicit boundary within which the model and the ubiquitous language have a clear, consistent meaning. Beyond that boundary the same word can mean something else.
// Billing Context: Client is a payer
package billing
type Client struct {
id ClientID
inn string
billingAddress Address
preferredPayment PaymentMethod
}
// Support Context: Client is a user with tickets
package support
type Client struct {
id ClientID
email string
openTickets []Ticket
}
func (c *Client) IsVIP() bool {
for _, t := range c.openTickets {
if t.Priority() == PriorityCritical {
return true
}
}
return false
}
Two different types with the same name are not duplication — they're normal DDD. Each type carries only the fields that matter in its context.
How to tell you need a boundary:
- different teams mean different things when they say the same word;
- one part of the system changes often, another is stable;
- different teams work on different parts.
A bounded context is not a microservice. It's a logical boundary of the model, whereas a microservice is a physical deployment boundary. At the start of a project a single microservice can contain several contexts separated by packages.
Subdomains: where to invest your effort
Not all parts of the system are equally important. DDD divides the problem domain into three types of subdomains — so the team distributes its effort correctly.
Core domain — what gives the business its competitive advantage. This is where the money is made. This is exactly where you need the most sophisticated and carefully built domain model, the best developers, the highest test coverage.
Supporting subdomain — important for operations, but not unique. You can't do without it, but competitors do the same thing. You can use simpler approaches: CRUD with validation is enough.
Generic subdomain — routine tasks: authorization, notifications, invoicing, file storage. It's usually more cost-effective to buy a ready-made solution or plug in an open-source library than to write it yourself.
An example for a marketplace:
- Core: the catalog and ranking, order checkout, settlements with sellers, dynamic pricing. This is where you need a strong domain model with invariants.
- Supporting: the buyer's account, the seller's account, reviews and ratings, order disputes. Needed, but simplified models are enough.
- Generic: SMS/e-mail notifications, fraud checks, storage for product media files. Buy as a service or plug in something ready-made.
Context Map — a map of dependencies between teams
Once the contexts are defined, you need to lock down how they interact. A context map is a diagram of all the bounded contexts and the links between them. Not a class diagram, but a map of responsibility: who supplies data to whom and on what terms.
On this map you can see:
- Orders provides data to Payments through an Open Host Service — a public API with a stable contract.
- Payments protects itself from the external payment gateway with an anti-corruption layer — translating the foreign model into its own.
- Accounting accepts the Payments model as-is (Conformist) — it adapts to the supplier.
- Shipping receives data from Orders under a Customer–Supplier arrangement — Orders takes Shipping's needs into account when making changes.
The map helps you spot problematic dependencies before they make it into the code.
Strategic distillation
Distillation is the process of isolating what matters most in the domain. The goal: understand where the real business value is, and direct effort there.
Without distillation, the team spends just as much time on the mailing module as on the key ranking algorithm. After distillation, priorities become explicit.
Domain Vision Statement — a short document (1–2 paragraphs): what the core domain is, what value it delivers, how it differs from competitors.
An example for a trip-booking service: "The core domain is intelligent route selection that accounts for transfers, prices and passenger preferences. This is what sets us apart from competitors: the optimal route in seconds, including transfer options that others don't offer. Everything else — payment, notifications, reporting — is necessary but not unique."
Highlighted Core — an explicit indication of which packages make up the core domain. The directory structure is one way to lock in this decision:
internal/
├── core/ ← Core Domain: maximum attention
│ ├── routing/ ← route selection — the key value
│ └── pricing/ ← dynamic pricing
├── supporting/ ← Supporting: needed, but not unique
│ ├── crm/
│ └── reporting/
└── generic/ ← Generic: ready-made solutions
├── notification/
└── auth/
Big Ball of Mud — what happens without a strategy
A Big Ball of Mud is a system with no explicit boundaries: models are mixed together, any module reaches into another's tables directly, and a change in one place breaks unpredictable things elsewhere.
Signs:
- a single
UserorOrdertype is used across the whole system; - there are no clear boundaries of responsibility between teams;
- nobody knows what effect a change will have.
Completely rewriting such a system is usually impossible. The practical path is gradual decomposition:
- Find the core domain — the most valuable part.
- Carve out a bounded context for it — move it into a separate package or module.
- Put an anti-corruption layer in place — protect the new clean context from the rest of the code.
- Repeat for the next most important context.
A Big Ball of Mud is not a death sentence — it's a starting point.
In short
- Ubiquitous language — a shared vocabulary of terms for the business and the developers. One concept — one word everywhere: in conversations, documentation and code.
- Bounded context — a boundary within which the model is unambiguous. Beyond the boundary the same word can mean something else.
- A bounded context is a logical boundary, not a physical one. A single service can contain several contexts.
- Core domain — where the business makes money; this is where you put the sophisticated model and the best resources. Supporting — needed, but with no unique requirements. Generic — buy it or take something ready-made.
- Context map — a map of all the contexts and the types of links between them. It helps you see problematic dependencies.
- Strategic distillation — a deliberate decision about where to invest the most effort.
- A Big Ball of Mud is fixed gradually: starting with the core domain and building an anti-corruption layer around the new clean context.
What to read next
- What DDD is and why you need it — if you haven't read it: where domain-driven design begins.
- Tactical DDD Patterns — Entity, Value Object, Aggregate, Domain Event: how to build the model inside a bounded context.
- Integration DDD Patterns — ACL, OHS, Shared Kernel and other ways to link contexts.