Domain-Driven Design: Modeling the Business, Not the Database
DDD is not about repositories and aggregates first, it is about talking to domain experts until the code speaks their language. Here is how strategic and tactical DDD fit together, with a worked example.
Domain-Driven Design, coined by Eric Evans, has two halves that get confused constantly. Strategic DDD is about how you divide a large domain into manageable pieces. Tactical DDD is the set of patterns, entities, value objects, aggregates, you use to model inside one of those pieces. Most teams jump straight to the tactical patterns and skip the strategic work that makes them worth anything. The real point is neither: it is making the code speak the language of the business it serves.
What is Domain-Driven Design, in one paragraph?
Domain-Driven Design is an approach to software design where the structure and the vocabulary of the code match the business domain it serves. You model the concepts a domain expert would recognize, orders, invoices, subscriptions, shipments, and the rules that govern them, directly in the code, instead of modeling database tables or framework classes. It has a strategic side, how to split a large domain into parts, and a tactical side, patterns for modeling within one part, but the core is a shared language between the people writing the code and the people who understand the business.
Strategic DDD: bounded contexts
A bounded context is a boundary within which one model and its ubiquitous language apply consistently. The word Order means something different in the Sales context, a signed contract, than in the Shipping context, a package to route. Trying to build one unified Order model for the whole company is where DDD projects usually go wrong. A context map shows which contexts exist and how they relate, and an anticorruption layer protects your model when you integrate with a context you do not control.
- ✓Core subdomain: your competitive advantage. This is where the modelling effort belongs.
- ✓Supporting subdomain: necessary but not differentiating. Build it simply.
- ✓Generic subdomain: a solved problem, authentication, billing, notifications. Buy it or use something off the shelf.
Ubiquitous language
The vocabulary domain experts use in conversation should be the exact vocabulary in the code: class names, method names, even variable names. If the billing team says a subscription lapses, the method is lapse(), not setStatusInactive(). Where the code needs a translation layer between what the business calls something and what the code calls it, that gap is where bugs and misunderstandings accumulate. When code and conversation diverge, treat it as a signal that the model is drifting, and usually the code is the one that is wrong.
Tactical DDD: the building blocks
| Building block | What it is | Example | Key rule |
|---|---|---|---|
| Value object | Defined only by its attributes, no identity | Money, DateRange, Address | Immutable, compared by value |
| Entity | Has an identity that persists through change | Order, Customer, Subscription | Compared by ID, not attributes |
| Aggregate | A cluster kept consistent as one unit | An order with its lines | One entry point, the aggregate root |
| Aggregate root | The only member outside code may reference | Order, not OrderLine | Enforces the aggregate invariants |
| Domain event | Something meaningful that happened | OrderPlaced, PaymentFailed | Named in past tense, immutable |
| Repository | Collection-like access to aggregates | OrderRepository | One per aggregate root |
| Domain service | Logic that belongs to no single entity | PricingPolicy, FundsTransfer | Stateless, named in domain terms |
These are means, not ends. Reach for them when the domain rules justify the structure, and not before.
A worked example: a subscription domain
Take a small slice. A subscription has a plan, a billing period, and a status: active, past due, or cancelled. Model it the way the billing team talks about it, with the rules living inside the object rather than in a service that manipulates it from outside.
// Value object: no identity, defined entirely by its attributes, immutable
final readonly class Money
{
public function __construct(
public int $amountInCents,
public string $currency,
) {}
public function equals(Money $other): bool
{
return $this->amountInCents === $other->amountInCents
&& $this->currency === $other->currency;
}
}// Aggregate root: state is private, the rules live in the methods
final class Subscription
{
private SubscriptionStatus $status = SubscriptionStatus::Active;
private array $raisedEvents = [];
public function __construct(
private readonly SubscriptionId $id,
private Plan $plan,
private BillingPeriod $currentPeriod,
) {}
public function renew(BillingPeriod $next): void
{
if ($this->status === SubscriptionStatus::Cancelled) {
throw new DomainException('A cancelled subscription cannot be renewed.');
}
$this->currentPeriod = $next;
$this->status = SubscriptionStatus::Active;
}
public function markPastDue(): void
{
if ($this->status !== SubscriptionStatus::Active) {
return;
}
$this->status = SubscriptionStatus::PastDue;
}
public function cancel(): void
{
$this->status = SubscriptionStatus::Cancelled;
$this->raisedEvents[] = new SubscriptionCancelled($this->id, now());
}
}// Domain event: past tense, immutable, carries what listeners need
final readonly class SubscriptionCancelled
{
public function __construct(
public SubscriptionId $subscriptionId,
public DateTimeImmutable $occurredAt,
) {}
}The rules are impossible to skip. There is no setStatus(), so a subscription cannot jump from cancelled back to active by accident. The state is private, the vocabulary matches the billing team, and cancelling raises an event that other parts of the system, dunning, analytics, the customer email, can react to without the subscription knowing about them.
Aggregates are consistency boundaries, not object graphs
An aggregate groups what must stay consistent together and exposes exactly one entry point, the aggregate root. Keep aggregates small: reference other aggregates by ID, not by object, and take one aggregate per transaction as the default. A large aggregate that pulls in half the database becomes a loading cost and a source of lock contention, and it usually means the consistency boundary was drawn too wide.
The most common DDD mistake is the anemic domain model: classes that are just data bags with getters and setters, while all the logic sits in a separate service layer. That is procedural code with objects used as structs, and it gives up almost everything DDD offers. If a rule can be broken by calling a setter, the model is not doing its job.
DDD and Clean Architecture
They fit together. Clean Architecture gives you the layering that keeps the domain free of the framework and the database. DDD gives you what to put in that domain layer: the entities, value objects and aggregates that carry the rules. You can do one without the other, but a complex domain usually wants both, the layering to protect the model and the modelling to make the protected layer worth protecting.
When DDD is worth it
- ✓Business rules are complex and change often, not simple CRUD.
- ✓Several teams work on different subdomains and need clear ownership boundaries.
- ✓Miscommunication with the business is expensive, in a regulated or financial domain for instance.
- ✓The system is expected to live and evolve for years.
When to skip it
- ✓A CRUD app that is mostly forms over a database.
- ✓A prototype or a project with a short shelf life.
- ✓A small team where everyone already shares the domain vocabulary.
- ✓Partial adoption is valid: keep the ubiquitous language, skip the tactical patterns until the rules earn them.
FAQ
What is DDD in simple terms?
It is a way of building software so the code speaks the language of the business. You model real domain concepts and their rules, not database tables, and you keep the words developers use identical to the words domain experts use.
What is a bounded context?
The scope within which one model and its terms mean exactly one thing. Two contexts can hold different models for what looks like the same concept: a Customer in Sales is not the same object as a Customer in Support, and that is intentional.
Is DDD the same as Clean Architecture?
No, they are complementary. Clean Architecture is about the direction of dependencies between layers. DDD is about how you model the business inside the domain layer. Many teams use both.
Do you need microservices to do DDD?
No. Bounded contexts can be modules in a single codebase. Microservices are one way to enforce a context boundary with deployment, not a requirement for DDD.
Where do you start with DDD?
With a conversation. Sit with a domain expert, write down the terms they use and what each one means, and make the code match. The tactical patterns, aggregates, repositories, domain events, come later, and only where the rules justify them.
DDD's biggest return is not the patterns. It is the shared vocabulary it forces between engineers and the business. Start with the conversation, model what you hear, and add structure only where the rules earn it.
Need help with this topic? Full Stack Development
Discover this service →