Code that speaks the business's own language, so the awkward bugs stop living in the gap between the two.
Every feature request arrives in the founder's language and has to be translated before it reaches the code. The business says quote; the code says OrderDraft. Sales talks about closing a deal, accounting talks about raising an invoice, and the data model quietly decides which word wins. Details get lost or misimplemented in that gap, and the awkward bugs tend to live in the space between the two vocabularies.
In plain terms: Domain-driven design is the practice of shaping code around the business it serves, in the words the business actually uses. Named by Eric Evans in 2003, it treats the language of the domain as part of the design.
Two ideas carry most of the weight. A ubiquitous language means developers and the business use the same terms for the same things, so nothing needs translating on the way into the code. A bounded context marks where one of those meanings stops and another begins, which starts to matter once an order, a quote, and an invoice cross sales, fulfilment, and accounting. For a small team, the value lives in that shared language and those boundaries, not in the architectural ceremony often sold alongside them.
The naive approach: modelling the database, not the domain
The default failure mode is quieter than bad architecture. It begins at the database schema, and the models take their names from the screens that read them. A Quote model is not the concept a salesperson carries in their head; it is the fields on the new quote form plus a status column. Business rules never get a home of their own, so they settle wherever a request happens to touch them: a controller here, a job there, an Eloquent observer nobody remembers writing.
The third symptom is the language itself, and it is the one this page is about.
The endpoint is what Foote and Yoder named the Big Ball of Mud in 1999: a system with no discernible shape, where every change risks a part you had forgotten was wired in. The opposite reflex, pulling all logic into services and leaving the model as bare getters and setters, is what Martin Fowler called an anaemic domain model. That trade is not automatically wrong. For a system that mostly moves records between states, a mildly anaemic model with logic in a few named services reads more clearly than a clever one. The failure is not where the logic sits; it is that nobody decided.
Ubiquitous language: one set of words from the founder's head to the schema
Deciding starts with the words. A ubiquitous language means the term a salesperson uses in a corridor is the same term that appears in the ticket, the conversation with the developer, the method name, and the column in the schema. You do not invent that vocabulary. You take the business's own nouns and verbs and refuse to translate them: a quote is raised and then accepted, an order is picked, an invoice is raised and then settled. When the code's accept() and the founder's "accepted" are the same word, there is no translation step left in which a detail can go missing.
The language is discovered, not decreed. The work is a negotiation with a domain expert, and it usually turns on the moment two people use one word to mean two things. Sit sales and accounting in the same room and both will say "quote" without hesitation. Push on it. Sales means a document sent to win the work; accounting means nothing at all until the customer says yes, because until then there is no figure they will report against. Pinning the word down forces the question of when a quote becomes a financial record, and the answer, that it does so on acceptance, was a rule nobody had ever written down.
Rule of thumb: The moment two people define a word differently, you have found a business rule.
Every one of those disputes is worth more than the glossary entry it produces. The ubiquitous language is the by-product; the buried rules it drags into the open are the point.
Bounded contexts are not microservices
The same word legitimately means different things in different parts of the business. To sales, an order is a won deal. To fulfilment, it is a pick-and-pack job. To accounting, it is a set of invoice lines. A bounded context is the area within which one of those definitions holds without contradiction.
| Term | Sales | Fulfilment | Accounting |
|---|---|---|---|
| Order | A won deal | A pick-and-pack job | A set of invoice lines |
| Quote | A live negotiation | Not visible yet | A financial record once accepted |
| Customer | An account with a pipeline | A delivery address and contact | A ledger with a credit limit and terms |
Three signals mark where one context ends and the next begins: the language changes, a different team owns the work, or the lifecycle changes. Any one of them is usually enough. When sales stops saying quote and accounting starts saying invoice line, you have crossed a boundary, even where the same database row sits underneath both.
Note: A bounded context maps to a module in a monolith, not necessarily a separate service. Every boundary on this page can live inside one modular monolith with no second process deployed. Microservices are a deployment choice; domain-driven design is a modelling one, and you do not need the former to practise the latter.
Contexts talk to each other through a defined contract, and the record of who talks to whom is the context map. Of the classic context-mapping patterns, the anti-corruption layer is the one that earns its keep in practice: a translation layer that wraps a messy third-party or legacy model so its concepts never leak into your clean one. When you wire in a payment gateway or an ageing accounting package, keeping an external system's model from leaking into yours is the difference between one contained adapter and their vocabulary spreading through your domain.
Strategic design beats tactical: where to spend the effort
Everything so far (the shared language, the context boundaries) is strategic design. It costs meetings and arguments, not framework code, and for a small team it returns most of what domain-driven design has to offer. The tactical patterns (aggregates, repositories, domain events) are the part most writing treats as mandatory. They are optional, and on a modest business system usually premature. The decision that pays off first is classifying your subdomains, because it tells you where careful work belongs.
Core domain
What makes this business different, and the only place worth building with real care. In our example that is order management and the quoting rules that win the work: price breaks, approval thresholds, the pricing logic no competitor has. Model it slowly and get it right.
Supporting subdomain
Necessary but not a differentiator. Customer records sit here: they must be correct, but nothing about how you store a name and an address sets the business apart. Build them plainly and spend the saved effort elsewhere.
Generic subdomain
Every business needs it and nothing about yours is special. Notifications, PDF generation, authentication. Buy a package or use a service, and resist the urge to gold-plate code no customer will ever notice.
Classification is what stops a team building the wrong thing carefully: a hand-rolled PDF generator polished for a month while the quoting rules that actually win business stay tangled across three screens. Vaughn Vernon's Implementing Domain-Driven Design draws the same split, and the practical step is small: decide out loud which bucket each part of the system falls into, then let that decide how much effort each one earns.
When domain-driven design is overkill
Domain-driven design earns its cost only when the domain is complicated enough to punish you for ignoring it. A CRUD app with thin rules gains nothing from aggregates, repositories, or a dedicated domain layer; the ceremony adds indirection and returns nothing. Read the signals honestly before committing to the discipline.
Those two show up in the code. The next two show up in the people around it.
The opposite signals are just as concrete, and any two of them together suggest the ceremony would cost more than it returns.
The last two are about the team and the territory rather than the code.
Strategic naming discipline costs nothing even in a CRUD app, so keep the ubiquitous language regardless of whether the tactical patterns ever arrive.
The tactical toolkit: entities, value objects, and aggregates
When a rule keeps getting broken, three tactical patterns earn their place. Reach for them then, not before.
An invoice line is an entity: it has identity and changes over time. Its quantity can be corrected and its description edited, and it is still the same line. Two lines with identical values are not the same line, because identity distinguishes them, not content.
Money and Address are value objects: no identity, immutable, compared by value. A Money is an amount plus a currency, never a bare float. Model it that way once and a whole class of currency-mismatch and rounding bugs stops being possible.
The Order is an aggregate. Its aggregate root enforces the invariant that the total equals the sum of its line amounts. The aggregate is the consistency boundary: every change goes through the root, so no caller can add a line and leave the total stale. That is the point of the pattern, not object coupling for its own sake.
The mechanics of turning this model into a database schema sit on another page; this one owns the conceptual model, not the tables.
Rule of thumb: Reach for an aggregate the third time a rule gets broken, not the first time you imagine it might. The invariant tells you where the boundary goes.
Lightweight DDD in Laravel: a worked example
This is a worked domain-driven design example in Laravel, and it starts with a decision most teams step around. Eloquent is an Active Record ORM, so an Eloquent model is never a pure domain object: it knows about its own table. For a small team that is an acceptable trade rather than a failure. Keep Eloquent, push the rules into the model and into value objects, and skip a full repository abstraction until it earns its keep by letting you swap storage engines or carry heavy contract testing. Do not build hexagonal-architecture cosplay around a framework that is designed to fight it.
Give each bounded context its own folder, a module you can reason about without loading the rest of the system into your head. Model the domain concepts as value objects and PHP enums, so an OrderStatus enum replaces a status string that any caller could misspell. Put each use case in a single-purpose action class: PlaceOrder and AcceptQuote do one job each and read like the language the business uses. Spatie Laravel Data covers the value object and DTO plumbing, and nWidart Laravel Modules enforces the folder layout if you want it policed. None of this is mandatory on day one.
| Concern | Rules in controllers | Rules in the model and value objects |
|---|---|---|
| Order total | Recomputed in each controller, drifts by a rounding step | Enforced once in the aggregate, cannot drift |
| Mixed currency | Caught only if someone remembers to check | Rejected by Money on construction and on add() |
| Changing a rule | Hunt every controller that touched it | One method, one place to change |
The pattern that holds it together is small. Money carries an amount in minor units and a currency, and refuses to add across currencies. The Order aggregate exposes addLine(), which appends a line and recalculates the total from the lines themselves, so no caller can leave the total out of step with its lines: the invariant holds because the total is only ever derived through the root.
final class Money
{
public function __construct(
public readonly int $minorUnits,
public readonly string $currency,
) {}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new DomainException('Cannot add Money across currencies');
}
return new self($this->minorUnits + $other->minorUnits, $this->currency);
}
}
class Order extends Model // Eloquent stays; the rules live in the model
{
public function addLine(OrderLine $line): void
{
$this->lines->push($line);
// The total is derived from the lines, never set by a caller,
// and Money::add() refuses to mix currencies along the way.
$this->total = $this->lines->reduce(
fn (Money $sum, OrderLine $l) => $sum->add($l->amount),
new Money(0, $this->currency),
);
}
}
When an order is placed, fulfilment and accounting react to an OrderPlaced event rather than being called inline, which makes cross-context consistency eventual rather than immediate and keeps the orchestration of downstream processes in one place.
The payoff: modelling sessions as requirements discovery
Run a modelling session before any code exists and it doubles as requirements discovery. Put the people who run the business around a wall, capture domain events on a timeline (the technique Alberto Brandolini named event storming), and watch for the moment two of them use the same word to mean different things. That disagreement is the real deliverable, because that ambiguity is what produces a bug, or an awkward conversation, six months after go-live. Modelling is a communication practice first and an architecture technique second, which is why it de-risks a build before a line of code is written. It is the ubiquitous language work from the other end: the language names the concepts, the session watches them collide.
That is the argument. The value sits in shared language and honest boundaries, not the ceremony: build the core carefully, buy the generic, keep the whole thing a modular monolith, keep Eloquent, and drop the patterns that do not earn their place. Structured discovery work that de-risks a build is where most of it starts.
A system that stays legible
If you want the order, the quote, and the invoice to still mean the same thing to your team as the business changes, a modelling session is a sensible place to begin.
Start a conversation →