Domain-Driven Design
Modeling business complexity, not technical complexity
DDD, formalized by Eric Evans in his 2003 book, rarely talks about layers or dependencies. It tackles a different problem: how to build a software model that faithfully reflects a complex business, in close collaboration with the experts of that business.
Sales Context
Customer
- · purchase history
- · credit limit
- · pricing segment
Anticorruption Layer between the two models
Support Context
Customer
- · open tickets
- · satisfaction score
- · applicable SLA
The problem it solves
There are two kinds of complexity in software. Accidental complexity: the kind we inflict on ourselves (poor architecture, technical debt, badly chosen tools). And essential complexity: the kind that comes from the business itself - an insurer's pricing rules, a bank's regulatory constraints, a carrier's logistics.
Hexagonal architecture and Clean Architecture mostly tackle the first one. DDD tackles the second. Its core claim: in software that fails, the problem rarely comes from the chosen technology, but from a model that doesn't match the reality of the business - or worse, from the complete absence of an explicit model.
Evans' answer boils down to one strong idea: code should be written hand in hand with domain experts, to the point where the vocabulary of the code and that of the business experts become literally the same.
The Ubiquitous Language
The Ubiquitous Language is a precise vocabulary, shared by developers and domain experts, used without translation or approximation - in discussions, in documentation, and directly in class and method names.
If domain experts talk about "terminating a contract" and the code has a deleteContract() method, something is wrong: either the code lies about what it actually does, or the conversation with the experts never happened. The good signal is a terminate() method on a Contract entity, applying the real business rules of termination (notice period, penalties, and so on).
This language isn't fixed: it evolves through conversation, and the code evolves with it. It's one of the most underrated parts of DDD, because it requires no technical pattern at all - just discipline in the words you choose.
Bounded Context: a model is only valid within an explicit boundary
A word like "Customer" doesn't mean the same thing everywhere in a company. In the sales context, a customer has a purchase history and a credit limit. In the support context, that same customer has open tickets and a satisfaction score. Wanting a single Customer class that serves both uses almost always leads to a lopsided model, loaded with optional fields and special cases.
The Bounded Context is the answer: you accept that several models of "customer" exist, each valid and consistent inside its own boundary (the Sales context, the Support context), and you explicitly manage the translation between those boundaries when needed.
It's probably the most misunderstood concept in DDD: it's often tied to microservices, when a bounded context can perfectly well exist as a simple module inside a single monolith. The boundary is primarily conceptual, not necessarily a deployment boundary.
Context Mapping: organizing relationships between contexts
Once several bounded contexts are identified, you need to describe how they communicate. Evans proposes several relationship patterns, the most common being:
- Shared Kernel: two teams deliberately share a small portion of a common model, accepting the coordination cost that comes with it.
- Customer / Supplier: an upstream context (supplier) provides data or services to a downstream context (customer), which can influence its roadmap.
- Conformist: the downstream context accepts the upstream model as-is, with no room for negotiation (an external vendor's API, for instance).
- Anticorruption Layer (ACL): the downstream context builds a translation layer to protect itself from an external model it doesn't want polluting its own language.
- Open Host Service: a context exposes a well-defined public protocol, designed to be consumed by several other contexts without individual coordination.
Entities and Value Objects
An Entity is defined by its identity, which persists over time even as its attributes change. An Order stays the same order whether you add a line to it or change its status - it's the identifier that matters, not its state at a given instant.
A Value Object is defined solely by its attributes, with no identity of its own. Two instances of Money representing "10 EUR" are interchangeable and perfectly equal. Value Objects are immutable by construction: you don't modify one, you create a new one.
This simple distinction avoids a common trap: representing an address, a price, or a date range as a plain string or number, then scattering validation and comparison logic all over the codebase.
// Value Object: a "data class" gives value equality for free
data class Money private constructor(val amount: Int, val currency: String) {
companion object {
fun of(amount: Int, currency: String): Money {
if (amount < 0) throw NegativeAmountError()
return Money(amount, currency)
}
}
fun add(other: Money): Money {
assertSameCurrency(other)
return of(amount + other.amount, currency)
}
private fun assertSameCurrency(other: Money) {
if (currency != other.currency) throw CurrencyMismatchError()
}
}Aggregates and the Aggregate Root
An Aggregate groups one or more entities and value objects that must stay consistent together, at all times. An Order and its order lines form a natural aggregate: you can't validate a single line without going through the rules of the whole order (minimum amount, available stock, and so on).
The aggregate has an Aggregate Root: the only entity accessible from the outside. Any access to the aggregate's internal objects must go through the root, which is how invariants stay guaranteed at all times.
A very useful rule of thumb: a transaction should modify only one aggregate at a time. If an operation seems to require touching two aggregates strictly synchronously, that's often a sign the aggregate boundary is drawn wrong - or that a domain event, handled asynchronously, would be more appropriate.
Domain Events, Repositories, Services, Factories
- Domain Event: a fact that happened in the domain, named in the past tense (OrderPlaced, SubscriptionCancelled). It lets other parts of the system react without directly coupling the emitting aggregate to its consequences.
- Repository: gives the illusion of an in-memory collection for a given aggregate (save, findById). A repository always maps to an aggregate root, never to an internal entity.
- Domain Service: carries a business operation that doesn't naturally belong to any single entity - for example, transferring an amount between two distinct accounts.
- Factory: encapsulates complex or rule-bound creation logic, when a plain constructor is no longer enough to guarantee a valid object from the moment it's created.
How it fits with hexagonal and Clean Architecture
DDD, hexagonal architecture and Clean Architecture aren't competitors: they answer different questions. DDD answers "what model belongs in the core of the system, and how should it be split into coherent contexts?". Hexagonal and Clean Architecture answer "how do we protect that core from everything around it?".
In practice, DDD's entities, value objects and aggregates are exactly what lives in Clean Architecture's "Entities" circle, or in hexagonal architecture's core. Repositories defined by the domain are driven ports in the hexagonal sense. The two approaches fit together naturally, without friction.
Common pitfalls
DDD suffers from a reputation for complexity, largely because of these recurring mistakes:
- Applying tactical patterns (aggregates, repositories, value objects) to a genuinely simple domain with no real business complexity to model: tactical DDD has a cost, only justified against real essential complexity.
- Skipping the strategic part (Ubiquitous Language, Bounded Context) - the most valuable and least costly part - to keep only the tactical patterns, reducing DDD to "put entities and repositories everywhere".
- Building an anemic model: entities that are just data bags with getters and setters, with all business logic pushed out into external services. That's no longer DDD, even if the vocabulary (Entity, Repository) still looks like it.
- Wanting a single bounded context for the whole company: that's the exact opposite of the original idea, and it recreates the very problem the bounded context is meant to solve.