Domain-Driven Design (DDD) is a way of building software that starts from the business problem, not the framework. You model what the business does, in the words it uses, and let that model decide where each piece of logic lives. It earns its keep when the rules are complicated and keep changing.
The mess DDD is a response to
Most codebases start organised by technical type: a controllers folder, a services folder, a utils folder. That feels tidy on day one. By month six, the rules for a single tuna order are spread across OrderController, PricingService, utils/discounts.py and a database trigger nobody remembers writing.
Two things go wrong. Nobody can answer 'where does the tuna logic live?', because the answer is 'a bit everywhere'. And everything depends on everything else, so a small change to how discounts work breaks delivery in a way no test predicted. People call this shape a 'big ball of mud'.
DDD's answer is to organise the code around the business instead of around the technology.
Start with the domain
The domain is the subject area the software exists for, such as selling tuna or booking flights. Its business rules are the things that must always be true, such as 'an order can't change once it is paid for' or 'we only deliver to postcodes we cover'.
DDD asks you to understand those rules before you design tables or endpoints. That means talking to the people who know the business, the domain experts, such as the shop owner and the warehouse team. The model you build from those conversations is a deliberate simplification. It keeps what matters for the problem and leaves out what doesn't. A tuna shop's model needs flavours and tin counts. It does not need the colour of the van.
Split the problem into subdomains
Ask 'what is this app really about?' and a tuna shop breaks down into a handful of areas: orders, payments, customers and delivery. The video calls these domains; DDD's own word for them is subdomains, parts of the larger business domain.
Each has its own rules. Ordering knows what makes a valid order. Payments knows how to take money and what 'refunded' means. Delivery knows about routes and time slots. None of them should need to know the others' internals.
Not every subdomain deserves the same effort:
- The core subdomain is what makes the business different. For a shop that promises same-day tuna, that might be delivery scheduling. Spend your best design time here.
- Supporting subdomains are needed but not special, like a customer's saved addresses.
- Generic subdomains are solved problems, like taking card payments. Buy or reuse these rather than build them.
Bounded contexts keep each model honest
One model for the whole business runs into a problem: the same word means different things in different places. To Ordering, a 'customer' is someone with a basket and an order history. To Delivery, a customer is an address and a time slot. To Payments, it's a billing account. A single Customer class that tries to serve all three grows dozens of fields, and every team is afraid to touch it.
A bounded context solves this by drawing a boundary around a model. Inside the boundary, every term has exactly one meaning. Outside it, the same word may mean something else, and that's fine. Ordering, Payments and Delivery each get their own context with their own small Customer, sharing only an ID.
Contexts talk to each other through clear contracts, such as an API call or an event. They don't reach into each other's tables or import each other's classes. That is the video's 'no random sharing' rule, and it's what stops everything depending on everything.
A shared, ubiquitous language
Within a context, DDD asks everyone to use the same words: developers, product managers, testers and domain experts. Eric Evans, who named the approach in his 2003 book, calls this the ubiquitous language. The words come from the business and appear in conversations, tickets and the code itself.
So the code says TunaOrder and PaymentStatus, not DataObject or OrderManagerHelper. If the shop owner says 'the customer places an order', the code has order.place(), not order.set_status(2).
Every time someone has to translate between what the business says and what the code says, there's room for a bug. When a domain expert can read a method name and say 'no, that's not how refunds work', you catch the mistake before it ships. When a new word appears in a meeting, it should appear in the code too, or the model is drifting away from reality.
The building blocks in code
DDD also comes with patterns for the code inside a context. These are called the tactical patterns; the subdomains, contexts and language above are the strategic part.
Entities and value objects
An entity has an identity that lasts while its details change. An order is still order 1042 after you add a tin. A value object is defined only by its values and never changes. Two amounts of £4.50 are interchangeable, so money is a value object:
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __add__(self, other):
if other.currency != self.currency:
raise ValueError("Currencies differ")
return Money(
self.amount + other.amount, self.currency
)frozen=True makes it immutable, and the currency check lives with the data it protects instead of in a helper somewhere else.
Aggregates
An aggregate is a cluster of objects that change together, with one entity in charge, called the aggregate root. Everything outside talks to the root, and the root enforces the rules. Here the order is the root and its lines live inside it:
class TunaOrder:
MAX_TINS = 50
def __init__(self, order_id, customer_id):
self.id = order_id
self.customer_id = customer_id
self.lines = []
self.status = "draft"
def add_tins(self, flavour, qty, price):
if self.status != "draft":
raise ValueError("Order already placed")
if self.tin_count() + qty > self.MAX_TINS:
raise ValueError("Too many tins")
self.lines.append((flavour, qty, price))
def tin_count(self):
return sum(q for _, q, _ in self.lines)
def place(self):
if not self.lines:
raise ValueError("Order is empty")
self.status = "placed"
return OrderPlaced(self.id)Compare that with the 'services everywhere' version, where TunaOrder is a bag of fields and an OrderService checks the status. Sooner or later a second service updates the lines directly and skips the check. With the rule on the order itself, every caller goes through the same door. Each aggregate is also loaded and saved as a whole, usually through a repository, so the domain code never writes SQL. The repository is often passed in with dependency injection.
Domain events
place() returns an OrderPlaced event: a record that something the business cares about has happened, named in the past tense. It carries only what other contexts need to know:
@dataclass(frozen=True)
class OrderPlaced:
order_id: strPayments listens for it and takes the money, then publishes PaymentTaken. Delivery listens for that and books a slot. Ordering never imports a line of payment or delivery code, so each context can change without breaking the others.
When DDD is worth it
DDD isn't free. It takes hours with domain experts, and it adds more classes than a quick script would need.
It fits well when:
- the business rules are complex and change often;
- several teams work on the same product and keep tripping over each other;
- the software is the business, so getting the model right is a competitive edge.
It's overkill when the app is mostly forms over a database (create, read, update, delete with little logic in between), a short-lived prototype, or a technical problem with no business rules to model, such as a file converter.
The strategic half is cheap and helps almost everywhere: agreeing on words, and drawing boundaries between areas. The tactical patterns are worth reaching for only where the rules are rich enough to need them.
Common mistakes
- Patterns without the conversations: folders called
entitiesandrepositoriesdon't make a codebase domain-driven. The model has to come from talking to people who know the business. - One model for everything: a single shared
CustomerorProductused by every context recreates the mess with nicer names. - Anaemic models: classes with only fields, getters and setters, and every rule in a service. That loses the main benefit, rules living next to the data they protect.
- Treating contexts as microservices by default: a bounded context is a model boundary, and it can be a module inside one app. Splitting it into separate services brings the costs covered in Distributed Systems, so do that only when you need to.
- Huge aggregates: an aggregate that pulls in the customer, every past order and the stock levels is slow to load and clashes on every save. Keep them small, and refer to other aggregates by ID.
Key takeaways
- DDD means modelling the business problem first, then writing code that mirrors that model.
- Split the business into subdomains, and give each model a bounded context where every word has one meaning.
- Use the business's own words in the code, the ubiquitous language, so developers and domain experts can check each other.
- Keep each rule inside the entity or aggregate that owns the data, and let contexts talk through events and clear contracts.
- Use the strategic ideas almost everywhere; save the full tactical patterns for complex, changing domains.