What Is Domain-Driven Design? When It Pays Off and When It Doesn't
DDD accepts that complexity lives in the business rules rather than the technology and brings the model into code together with domain experts; it pays off in a core domain whose rules change often and is unnecessary for CRUD screens and short-lived MVPs. A campaign discount bug that shows the difference between a CRUD model and a domain model, strategic versus tactical design, eShop's Ordering.Domain, and the series roadmap.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore's school-season campaign has started. The campaign manager's rule fits in one sentence: a customer who buys 3 or more books in the same order gets 10% off. Ada puts Dune (20 EUR), Emma (10 EUR), and The Hobbit (15 EUR) in her cart; the subtotal is 45, and 40.50 EUR after the discount. The next day she changes her mind and removes Emma from the order.
The order screen is a classic CRUD (Create, Read, Update, Delete) screen: the UI puts the remaining lines, with their discounted unit prices, into a PUT /orders/1 request, and the backend saves whatever arrives. Two questions: what total does the system store for the remaining two books, and what is the correct total? And if someone in customer service clicks "Cancel" on an order that has already shipped, what happens? Keep your answer in mind; in a moment we will run both scenarios with two different models.
See It in Code: The CRUD Model and the Domain Model
First, today's version. The entities mirror the database tables and carry no business rules; the service writes whatever the client sends:
namespace Bookstore.Crud;
// The shape of the table, not of the business
public class Order
{
public int Id { get; set; }
public string Status { get; set; } = "Placed";
public List<OrderItem> Items { get; set; } = [];
public decimal Total { get; set; }
}
public class OrderItem
{
public string Title { get; set; } = "";
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
}
public record OrderDto(string Status, List<OrderItem> Items);
public class OrderService
{
// PUT /orders/{id}: whatever the client sends is written as-is
public void Update(Order order, OrderDto dto)
{
order.Status = dto.Status;
order.Items = dto.Items;
order.Total = dto.Items.Sum(i => i.UnitPrice * i.Quantity);
}
}
Now let's write the same order with a model that carries the rule itself. Later posts in this series take this model apart piece by piece; for now, just notice that the discount rule and the "a shipped order cannot be cancelled" rule both live inside the Order class.
namespace Bookstore.Ordering;
public enum OrderStatus { Placed, Paid, Shipped, Cancelled }
public sealed record OrderItem(string Title, decimal ListPrice, int Quantity);
public sealed class Order
{
// School-season campaign: 10% off when the order has 3 or more books
private const int CampaignMinBooks = 3;
private const decimal CampaignDiscount = 0.10m;
private readonly List<OrderItem> _items;
public Order(IEnumerable<OrderItem> items) => _items = [.. items];
public OrderStatus Status { get; private set; } = OrderStatus.Placed;
public IReadOnlyList<OrderItem> Items => _items;
public decimal Total
{
get
{
decimal subtotal = _items.Sum(i => i.ListPrice * i.Quantity);
int books = _items.Sum(i => i.Quantity);
return books >= CampaignMinBooks ? subtotal * (1 - CampaignDiscount) : subtotal;
}
}
public void MarkShipped() => Status = OrderStatus.Shipped;
public void CancelItem(string title)
{
if (Status is OrderStatus.Shipped)
throw new InvalidOperationException("A shipped order cannot be cancelled; request a return instead.");
_items.RemoveAll(i => i.Title == title);
if (_items.Count == 0)
Status = OrderStatus.Cancelled;
}
}
Let's run both models through the same scenario:
using System.Globalization;
using Crud = Bookstore.Crud;
using Bookstore.Ordering;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// 1) CRUD: the UI calculated the discounted prices, then removed Emma
Crud.Order crudOrder = new() { Id = 1 };
Crud.OrderService service = new();
service.Update(crudOrder, new("Placed",
[
new() { Title = "Dune", UnitPrice = 18.00m, Quantity = 1 },
new() { Title = "The Hobbit", UnitPrice = 13.50m, Quantity = 1 },
]));
Console.WriteLine($"CRUD total after cancelling Emma: {crudOrder.Total:0.00}");
service.Update(crudOrder, new("Shipped", crudOrder.Items));
service.Update(crudOrder, new("Cancelled", crudOrder.Items));
Console.WriteLine($"CRUD status of a shipped order: {crudOrder.Status}");
// 2) Domain model: the rule lives in Order
Order order = new(
[
new("Dune", 20.00m, 1),
new("Emma", 10.00m, 1),
new("The Hobbit", 15.00m, 1),
]);
Console.WriteLine($"Domain total with 3 books: {order.Total:0.00}");
order.CancelItem("Emma");
Console.WriteLine($"Domain total after cancelling Emma: {order.Total:0.00}");
order.MarkShipped();
try
{
order.CancelItem("Dune");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Domain shipped order: {ex.Message}");
}
CRUD total after cancelling Emma: 31.50
CRUD status of a shipped order: Cancelled
Domain total with 3 books: 40.50
Domain total after cancelling Emma: 35.00
Domain shipped order: A shipped order cannot be cancelled; request a return instead.
The CRUD side stored 31.50 EUR. The correct total is 35.00: with two books the campaign condition no longer holds, so the discount has to go too. Ada got 3.50 EUR of discount she was not entitled to, and nothing in the code noticed, because the rule was not in the code; it was in the head of the developer who wrote the UI. The shipped order also quietly became Cancelled, although the business does not call that a cancellation but a return. The book has left the warehouse, the carrier has been paid, and accounting will run a different process.
In the domain model both rules sit in one place, and every change to the order goes through them. The difference is not fewer lines of code; the difference is where the rule lives. That is exactly what Domain-Driven Design is about.
Evans's Starting Point: Complexity Lives in the Business Rules
The name DDD (Domain-Driven Design) comes from Eric Evans's book Domain-Driven Design: Tackling Complexity in the Heart of Software, published by Addison-Wesley in August 2003. The community calls it the "Blue Book" because of its cover. The subtitle sums up the thesis: in enterprise software the real complexity is not in the framework, the database, or the deployment, but in the business itself. Technical problems can be hard, but most of them have known solutions; how campaign, cancellation, return, and stock rules touch each other is different in every company, and getting it wrong costs money.
In 2015 Evans published the pattern summaries from the book as the DDD Reference under a Creative Commons license (CC BY 4.0). The Reference defines DDD in three points: focus on the core domain; explore models in a creative collaboration of domain practitioners and software practitioners; speak a ubiquitous language within an explicitly bounded context. None of the three mentions classes, repositories, or databases.
Two definitions in the Reference matter here. The domain is the sphere of knowledge, influence, or activity to which the user applies the program: for Bookstore, selling books online. A model is a system of abstractions that describes selected aspects of that domain and can be used to solve problems in it. A model is not a diagram, and it is not a database schema either. In Evans's Model-Driven Design pattern the code is an expression of the model: change the code and you change the model, change the model and you change the code. The Order.Total property above is part of the model; if the campaign rule changes, that is where it changes.
Strategic Design and Tactical Design
DDD works at two levels. Strategic design sets up the big picture: how much to invest in which part, where each model starts and ends, and how teams and systems talk to each other. Tactical design gives you the building blocks for turning a model into code inside one boundary.
| Question | Strategic design | Tactical design |
|---|---|---|
| What does it look at? | The whole system, the teams, external systems | The inside of one bounded context |
| Core concepts | Subdomain, ubiquitous language, bounded context, context map | Entity, value object, aggregate, domain service, domain event, repository, factory |
| Question it answers | Where do we invest, where do the boundaries go? | Which object owns this rule, where is consistency protected? |
| When you get it wrong | Your best team works on the wrong thing, models bleed into each other | Rules scatter across services, invariants leak |
| Posts in this series | 02–06 | 07–13 |

The split in the figure matters too. The problem space is the business itself: the domain, its subdomains, and the domain experts who carry that knowledge. You do not design these; you discover them. The solution space is the software's answer: the language, the bounded contexts, the context map, and the tactical model inside each boundary. These you design, and the new insight you gain while designing changes how you understand the problem.
The most common misconception is treating DDD as a pattern catalog: an Entity base class, a generic repository, a Domain folder, and you are done. The Reference groups its patterns into six parts, and the tactical building blocks are only one of them. A project without a single Repository class can be true to the essence of DDD, while a project where every class derives from AggregateRoot may still be calculating the campaign rule in the UI. Evans himself stresses in the Reference that tactics and strategy have to be combined.
Working with Domain Experts
The second point of DDD is discovering the model together with domain experts. Domain expert is a role, not a job title: the person who knows the business rules best and can say "no, that's not how it works." At Bookstore, the campaign manager knows the pricing and discount rules, customer service knows the cancellation and return processes, and the warehouse lead knows stock and reservations. None of them is a developer, and none of their knowledge fits into a ticket.
Evans calls this process knowledge crunching: developers and experts talk, try the model against example scenarios, find the missing or contradictory parts, and refine the model. The Hands-on Modelers pattern in the Reference adds that whoever shapes the model should also touch the code; a split where one person draws the model and someone else turns it into code loses what was learned along the way.
In practice this is a conversation like: "Ada bought three books, got the campaign discount, then cancelled one. What should she pay for the remaining two?" The campaign manager says "35 EUR, the discount goes away." Customer service adds: "If it has shipped, there is no cancellation; we open a return request." Those two sentences became the two rules of the Order class above. Bookstore's 3.50 EUR bug was not caught by tests but by customer service; the rule lived in conversations, not in code. In the sixth post of this series we will look at EventStorming, which turns these conversations into a systematic workshop.
An Example from the .NET World: eShop's Ordering.Domain
Microsoft's reference application dotnet/eShop is a good example of applying DDD selectively. It has services such as Catalog.API, Basket.API, Ordering.API, and Identity.API, but only the ordering side has a separate domain project: Ordering.Domain. Its AggregatesModel/OrderAggregate folder holds Order, OrderItem, Address, OrderStatus, and IOrderRepository. Order keeps its lines in a private List<OrderItem> field, and a comment in the source says outright that this is preferred for the aggregate's encapsulation. The catalog side makes do with a simple model and CRUD endpoints.
That is not an inconsistency but a decision: a rich model only where the rules are dense. Even eShop's order model has something worth noticing: its method names are like SetShippedStatus() and SetCancelledStatus(). The rule is protected inside the method, but the name reads like a setter; the business does not say "set the status to cancelled", it says "cancel the order." That difference is exactly the topic of the next post.
When Does It Pay Off?
DDD has a cost: the domain expert's time, hours spent on modeling, more types, and the team's learning curve. That cost pays back under these conditions:
- The business rules are complex and touch each other: campaign, cancellation, and return meet in the same order.
- The rules change often: every campaign season brings a new kind of discount.
- These rules are the company's competitive edge: what sets Bookstore apart is its campaign design and its flexible return policy.
- You have access to domain experts, and the product will live for years.
In Learning Domain-Driven Design (O'Reilly, 2021), Vlad Khononov ties the pattern for implementing business logic to the type of subdomain: for simple business logic a transaction script or an active record is enough, while complex, frequently changing core business logic calls for a domain model. At Bookstore that means writing Ordering and campaign pricing with a rich model and the rest in simpler ways. We will look at subdomain types in detail in the third post.
When Not to Use It
On screens that go straight from a form to the database, such as category management, tactical DDD only adds ceremony; a validation and a SaveChanges are enough. For read-heavy work such as sales reports, writing a query directly is both faster and more honest than loading aggregates; the approach in the Dapper series fits better there. For an MVP (Minimum Viable Product) that tries to validate an idea and will live for a few weeks, investing in a model is premature; first learn whether the business really is what you think it is.
Be careful if you have no access to a domain expert. The model you build then is the model in the developers' heads, not the expert's, and the most valuable part of DDD is missing. For things every company solves the same way, such as payment and authentication, using an existing solution instead of building your own model is usually the right call. One more point: even where you skip the tactical patterns, strategic thinking is cheap and useful. Knowing where the boundaries are and what each word means is valuable in a CRUD module too.
Bookstore's Domain and the Series Roadmap
This series continues with the Bookstore from the published C#, Entity Framework Core (EF Core), and Introduction to Architecture series: an online bookstore that sells its own stock. In the Introduction to Architecture series we named its parts Catalog, Ordering, Inventory, Payment, Shipping, and Notification. The domain covers the book catalog, cart and order, campaign pricing, cancellations and returns, stock reservation, shipping, and customer notifications. This series focuses on Ordering in particular, because that is where the rules concentrate.
The roadmap: in 02 we look at ubiquitous language and code that speaks the business language, in 03 at subdomains and where to invest, in 04 at bounded contexts and model boundaries, in 05 at relationships between boundaries with context mapping, and in 06 at discovering the domain with EventStorming. Posts 07–11 cover the tactical building blocks: entities and value objects, the always-valid model, aggregate design, persisting with EF Core 10, and domain services. In 12 we cover domain events, in 13 we refactor Ordering end to end from anemic CRUD to a rich model, and in 14 we look at how DDD has evolved and the most common mistakes.
What the series leaves out is clear as well. Where the domain project sits in the solution and which way dependencies point is the topic of the published Clean, Onion, and Hexagonal Architecture series. Event-sourced aggregates are in the upcoming CQRS (Command Query Responsibility Segregation) and Event Sourcing series, reliable publishing of integration events in the upcoming Messaging and Event-Driven Architecture series, and turning a bounded context into a module or a service in the upcoming Modular Monolith and Microservices series.
Common Pitfalls
Mistaking DDD for a folder structure. Creating Domain/Entities, Domain/Repositories, and Domain/Services folders is not DDD; if they are full of properties with public setters, the old CRUD model has simply moved into new folders.
Applying tactical DDD everywhere. Putting aggregates, value objects, and domain events on the category screen pulls the team's energy away from where the rules are really dense. A rich model is an investment, and you do not invest equally everywhere.
Modeling without a domain expert. A model the developers build among themselves cracks at the first real business rule. If the answer to "who knows the campaign rule?" is not a person's name, find that person first.
Drawing the model once and freezing it. The Refactoring Toward Deeper Insight pattern in the Reference expects the model to reshape as the business is understood better. The first model is always incomplete; DDD is a process that accepts this, not a big up-front design.
Equating DDD with microservices. Bounded contexts help you find service boundaries, but you can do DDD inside a single monolith. Bookstore is a single application today, and it stays that way in this series.
A Small Experiment
★☆☆ Count the five most changed files in your own project over the last six months from git log --name-only. Do they carry business rules or infrastructure? For the ones with business rules, who stated the rule first?
★★☆ Add the rule "the campaign only applies to orders placed between August 1 and September 30" to the Order in the example. Where do you get the order date from? Use TimeProvider instead of DateTime.UtcNow and test the rule by passing in dates.
★★★ Add the same two rules to OrderService.Update on the CRUD side with if statements. Then write a unit test for each version that checks "a shipped order cannot be cancelled." Which one needs less test setup, and in which one is it easier to open a second code path that skips the rule?
In this post we saw why DDD starts with business rules and where it is worth its cost. But before a rule turns into code, it turns into words: "cancel", "return", "status". In the next post we look at ubiquitous language: how the language of the meeting shows up in class and method names, why the Turkish word "iade" is really two concepts, and how a team that speaks Turkish and writes code in English builds its glossary.