Domain-Driven Design (DDD)
Write code in the language of the business. Split a big system into small parts with clear borders. Keep the important rules inside the model itself.
Author: bezzad
The big picture
DDD has two parts. One part looks at the whole system. The other part looks at the code inside each part.
Strategic design
How do we split the system?
- Shared language (Ubiquitous Language)
- Bounded Context
- Context Map
Tactical design
Inside each part, how do we write the code?
- Entity and Value Object
- Aggregate
- Domain Event
- Repository
The problem: one big model for everyone
In an online shop, three teams work with “product”: sales, warehouse and shipping. The easy way is to build one Product class and let everyone use it.
This class has some problems:
- The class gets bigger every day. Each team adds a few fields.
- Teams break each other’s work. The warehouse team changes a field and the sales page shows an error.
- Words do not have an exact meaning. When someone says “stock”, do they mean the number on the shelf or the number that can be sold?
DDD is for solving these problems.
Shared language (Ubiquitous Language)
The developer and the business expert must talk in one language. The same word that people say in the meeting must also be in the code.
The sales expert says: “The customer places the order. After it is placed, the order does not change anymore.”
BadCode in technical language
order.Status = 3;
order.IsLocked = true;
db.SaveChanges();What does the number 3 mean? Nobody knows. The rule “no changes” is also nowhere in the code.
GoodCode in business language
order.Place();The method name is the same word the expert uses. The rules are inside this method.
Bounded Context: each word, one border
One word has different meanings in different parts of the business. So instead of one big model, we build several small models. Each model has a border. We call this border a Bounded Context.
Click each part to see what “product” means there:
Each part has its own model. The class name can be different too.
Each Bounded Context:
- Has its own model. Only the fields it needs.
- Has its own language. “Product” has its own exact meaning in each part.
- Usually has one team that owns it. The team can change its model without fear.
- Usually has its own database, or at least its own tables. Other parts do not touch its tables directly.
How do the parts talk to each other?
The parts are separate, but they must work together. The best way is usually an event. Each part tells what happened. Other parts react to it if they need to.
We call the map that shows how the parts connect a Context Map. Two important points in this map:
- Each part translates the model of the other part. The warehouse does not use the sales model directly. It only takes the product id and the quantity from the event. We call this translation layer an Anti-Corruption Layer.
- An event is in the past tense. “Order placed”, not “reserve the stock”. The sender does not know who is listening.
Entity and Value Object
Now we go inside one part. The first question about each thing is: does it have an identity?
Entity
- It has an identifier (Id).
- It changes over time, but stays the same thing. A customer changes their name, but is still the same customer.
- We compare two Entities by their identifier.
- Examples: customer, order, bank account.
Value Object
- It has no identifier.
- It does not change (Immutable). To change it, we make a new value.
- We compare two Value Objects by their value.
- Examples: an amount of money, an address, a date range.
In C#, the record type is great for a Value Object. It does the comparison by value for you:
public sealed record Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (other.Currency != Currency)
throw new InvalidOperationException("Currencies are different.");
return this with { Amount = Amount + other.Amount };
}
}
var a = new Money(100_000, "IRT");
var b = new Money(100_000, "IRT");
Console.WriteLine(a == b); // True: same value, same money
And a simple Entity:
public sealed class Customer(Guid id, string name)
{
public Guid Id { get; } = id;
public string Name { get; private set; } = name;
public void Rename(string newName) => Name = newName;
}
// Two customers named "Ali" are two different people.
// We compare customers by Id, never by Name.
Aggregate: the guard of the rules
An order has several lines (OrderLine). We have one rule: the order total must not be more than 20 million tomans. This rule is about the whole order, not about one line.
If any part of the code can add a line directly, sooner or later someone forgets the rule. So we make the order and its lines one unit. We call this unit an Aggregate. It has only one entry door: the Aggregate Root.
Order rules: the total is at most 20 million tomans. After the order is placed, no change is allowed.
Order 1042 Draft
The code of the order Aggregate. All rules are inside the class itself, and the list of lines cannot be changed from outside:
public sealed class Order
{
private const decimal MaxTotal = 20_000_000;
private readonly List<OrderLine> _lines = [];
public Guid Id { get; } = Guid.NewGuid();
public Guid CustomerId { get; }
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public IReadOnlyList<OrderLine> Lines => _lines;
public decimal Total => _lines.Sum(line => line.Price * line.Quantity);
public Order(Guid customerId) => CustomerId = customerId;
public void AddLine(Guid productId, decimal price, int quantity)
{
if (Status != OrderStatus.Draft)
throw new DomainException("A placed order cannot change.");
if (quantity <= 0)
throw new DomainException("Quantity must be positive.");
if (Total + price * quantity > MaxTotal)
throw new DomainException("Order total is over the limit.");
_lines.Add(new OrderLine(productId, price, quantity));
}
public void Place()
{
if (_lines.Count == 0)
throw new DomainException("An empty order cannot be placed.");
Status = OrderStatus.Placed;
}
}
Four golden rules of an Aggregate
- Only through the Root. Outside code never changes an OrderLine directly.
- Keep it small. Put only the things inside the Aggregate that must stay correct together. The customer is not inside the order Aggregate.
- Point to another Aggregate only by its identifier. The order has only the CustomerId, not the whole customer object.
- In each transaction, only one Aggregate changes. If another Aggregate must change too, do it with a Domain Event.
Domain Event: telling others that something happened
When an order is placed, a few other jobs must also happen. But the order must not know all these jobs itself. The order only sends one piece of news: “I was placed”.
public sealed record OrderPlaced(Guid OrderId, Guid CustomerId, decimal Total);
public sealed class Order
{
private readonly List<object> _events = [];
public IReadOnlyList<object> Events => _events;
public void Place()
{
// ... the same checks as before ...
Status = OrderStatus.Placed;
_events.Add(new OrderPlaced(Id, CustomerId, Total));
}
}
public sealed class ReserveStockHandler(IWarehouse warehouse)
: IDomainEventHandler<OrderPlaced>
{
public Task HandleAsync(OrderPlaced e, CancellationToken ct)
=> warehouse.ReserveAsync(e.OrderId, ct);
}
Repository: load a full Aggregate and save it
A Repository acts like an in-memory “collection” for each Aggregate. It loads the whole Aggregate and saves the whole Aggregate.
- One Repository for each Aggregate Root. We do not build a separate Repository for OrderLine.
- Methods in the domain language. There is no method like “update line”. Changes happen only through the methods of the order itself.
public interface IOrderRepository
{
Task<Order?> GetAsync(Guid id, CancellationToken ct);
void Add(Order order);
}
// Application layer: load, call the domain, save.
var order = await orders.GetAsync(command.OrderId, ct)
?? throw new NotFoundException();
order.AddLine(command.ProductId, command.Price, command.Quantity);
await unitOfWork.SaveChangesAsync(ct);
Look at the order of these three lines: load, let the domain decide, save. The Application layer does not check any rule itself.
Common mistakes
| Mistake | Why is it bad? | The right way |
|---|---|---|
| Anemic model: the class has only properties | Rules spread across different services and get repeated or forgotten. | Put the rule inside a method of the Entity itself. |
| A very big Aggregate: all orders inside the customer | For each small change, a lot of data is loaded. Changes at the same time conflict with each other. | Customer and order are two separate Aggregates. They connect only by identifier. |
| A direct reference to another Aggregate | The borders disappear. One transaction locks several Aggregates. | Keep only the identifier. |
| One shared database for all parts | Each part reads the tables of other parts. The same big model gets built again. | Each part has its own data. They talk with events or an API. |
| Technical names like Manager and Helper | Nobody understands what the class does in the business. | Use the words of the business expert. |
| DDD for a simple CRUD | The code gets complex, with no benefit. | For a simple part, write simple code. |
When to use DDD?
Good fit
- There are many complex business rules.
- The system lives for years and changes all the time.
- Several teams work on one system.
- A business expert is available.
Bad fit
- The app is mostly forms and tables (CRUD).
- The project is small or short-term.
- The main complexity is technical, not business. For example, a file conversion service.
Summary in six lines
- Write the code with the words of the business expert.
- Split the system into several Bounded Contexts. Each one has its own model and language.
- Parts talk with events, not with a shared database.
- Something with an identity is an Entity. Something with only a value is a Value Object.
- Keep the Aggregate small. All changes go only through the Root.
- To tell others that something happened, send a Domain Event.
Related questions: 24. Data that lives in another service · 36. Aggregate and domain rules · 37. Anemic or rich model · 38. Bounded Context and shared language