How do you write Domain-Driven Design in C#?
Entity Framework Core maps the model from outside, and domain tests run without a database.

Elena has worked for three years at a company near Bergamo that makes industrial gaskets. The order system is hers: a .NET solution with a web project, a data access project and a Models folder full of classes with public properties and no methods. One Tuesday the sales manager asks for a simple rule: a confirmed order can no longer be edited, and a customer over their credit limit cannot confirm new orders. Elena opens the code and finds that the rule would have to live in seven places: two controllers, an Excel import, an invoicing service, an API for the sales app and two pages nobody remembers writing.
She forgets one. Three weeks later a confirmed order is changed by a nightly import, the invoice goes out with the wrong amount and the sales manager calls accounting. Elena did not write bad code. She wrote code where business rules have no home: every place that touches an order can do anything to an order.
Domain-Driven Design, DDD for short, exists to give those rules a home. If you want the concept first, the full explanation is in the guide to Domain Driven Design and how it works. This article takes the next step: it turns the ideas into C# code on .NET 10, with entities that protect their own rules, value objects written as record types, aggregates with a clear boundary, Entity Framework Core mapping, domain events and tests.
I have been writing software since 1999, and in the classroom I keep seeing the same pattern: people who apply DDD only in folder names end up with elegant folders and the same mess as before. People who apply it in the classes end up with a model the sales manager can read. Here you get the second path, step by step.
What is Domain-Driven Design in C# and when does it really pay off?
Domain-Driven Design in C# is a way of writing code where business rules live in domain classes with behavior, named with the same words the company's experts use. No NuGet package contains it: it is a design discipline described by Eric Evans in 2003, which in .NET takes the form of classes, records and boundaries between projects.
What changes in everyday code
In the code many teams still write, the Order class is a bag of properties: Status, Total, Lines, all with public get and set. The rules live elsewhere, in services that read the properties, decide and write them back. In DDD the opposite happens: the order exposes a Confirm() method and only that method can move it to the confirmed state, after checking that lines exist and the total is consistent. Whoever wants to change the state has to go through it.
The consequence shows on the first change. When the sales manager changes the rule, you touch one method in one class and you are done, because no other piece of code can bypass it. Elena's seven places become one.
The shared language is the part most often skipped
The heart of DDD sits in the vocabulary, before the code. If the warehouse says "dispatch" and the code says ProcessOrder, every conversation between the people who use the system and the people who write it needs a translation, and translations lose pieces. In DDD you seat the sales manager next to the developer, decide what "confirmed order" means and that term goes into the code unchanged: class Order, method Confirm, state Confirmed. Evans calls it the ubiquitous language, and it works like a contract: if the word changes in the business, it changes in the code.
When it pays off and when it does not
DDD costs time at the start and returns it when the domain is complicated: tiered discounts, stock constraints, approval workflows, rules that change every quarter. For a contact list with four fields, or an application that reads and writes tables without rules, it is overkill: an edit page and Entity Framework are enough. A rule of thumb I use with clients: if explaining the software to a new colleague takes more than ten rules said out loud, you need DDD. If two sentences are enough, save the time.
Strategic and tactical: two levels not to mix up
DDD has a strategic part, about bounded contexts and relationships between teams, and a tactical part, about classes: entities, value objects, aggregates, domain services and events. The examples in this article are mostly tactical, because that is the part you open in Visual Studio on Monday morning. The strategic part appears in the section on contexts, further down, where you decide how to split the solution.
How do you write a C# entity that protects its own rules?
An entity is an object with an identity that lasts over time: two orders with the same data are still different orders because they have different numbers. In C# you write it with private setters, a constructor that prevents impossible states and methods that describe the allowed actions using business names.
Constructor and initial state
An order is not born in a random state. It starts as a draft, with a customer and a number, and nobody outside can invent a different combination. The public constructor requires the mandatory data and throws if it is missing; the parameterless one, needed by Entity Framework, stays private.
public class Order
{
private readonly List<OrderLine> _lines = new();
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public IReadOnlyCollection<OrderLine> Lines => _lines;
private Order() { } // for Entity Framework
public Order(Guid customerId)
{
Id = Guid.NewGuid();
CustomerId = customerId;
Status = OrderStatus.Draft;
}
}Two details matter more than the rest. Lines are exposed as IReadOnlyCollection, so nobody outside can write order.Lines.Add(...) and skip the checks. And the public constructor generates the identifier itself, so no caller can create two orders with the same key.
Methods carry business names
Instead of setting Status = Confirmed from a controller, the order exposes Confirm(). The rules live inside that method, and only there: an empty order cannot be confirmed, an already confirmed order cannot be confirmed again, a cancelled order does not come back to life. If a rule is violated the method throws a domain exception with a readable message, for example DomainException("An order without lines cannot be confirmed").
The method name is a design decision. AddLine(item, quantity, price) says more than Lines.Add, because inside you can check that the order is still a draft, that the quantity is positive and that the item is not already present. The sales manager reads the name and understands the action without opening the body.
Identity and equality
For entities, equality is based on the identifier and not on the fields. Two Order objects with the same Id are the same order even if one was loaded before a change. When you compare entities in collections or in tests, remember to override Equals and GetHashCode on the identifier, or compare the Id values directly. Entity Framework Core already tracks identity inside its DbContext, so the problem shows up mostly outside persistence.
States and transitions
Many orders go from draft to confirmed, shipped, invoiced and cancelled. If the state is a plain editable enumeration, every jump is possible. In an object model you write the allowed transitions as methods: Confirm starts from draft, Ship starts from confirmed, Cancel is allowed only before shipping. The state diagram the sales manager draws on the whiteboard becomes the code, line by line, and whoever reads the code finds the whiteboard again.
What is a value object and how do you write one with C# records?
A value object is defined only by its data, has no identity of its own and is immutable: two amounts of 100 euros are the same amount. In C# record types were made for this, because they give value equality, immutability and a with copy syntax without extra code.
Why a decimal is not enough
In Elena's system the amount is a decimal scattered across twelve classes. When the company starts selling in dollars, nobody knows which decimal values are euros and which are not, and adding two amounts in different currencies compiles without errors. A Money value object with amount and currency fixes the problem at the root: the compiler stops accepting the sum of euros and dollars, and the Add method throws a clear exception if the currencies differ.
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new DomainException("Amount cannot be negative");
if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
throw new DomainException("Currency must have three letters");
Amount = decimal.Round(amount, 2);
Currency = currency.ToUpperInvariant();
}
public Money Add(Money other)
{
if (other.Currency != Currency) throw new DomainException("Different currencies");
return new Money(Amount + other.Amount, Currency);
}
}Validation lives in the constructor
A value object that is valid by construction removes a whole class of checks from the rest of the code. If you hold a Money, you already know the amount is not negative and the currency has three letters, and no later method has to check again. The same goes for Email, VatNumber, PostalCode, Address: once created, the object cannot be wrong. A string arriving from a web form, instead, is wrong until proven otherwise.
When a value object and when an entity
The question to ask is simple: do I need to tell apart two instances with the same data? A customer with the same name and VAT number as another may be a different record, because the order history differs: that is an entity. A delivery address equal to another is the same address: that is a value object. If two identical instances can be swapped without anyone noticing, you have a value object.
Records, record structs and sealed classes
With record you get value equality, a readable ToString and the with expression to create a modified copy. A record struct avoids heap allocation and suits small, frequent objects such as a coordinate. For an object with construction rules, like Money, I prefer a sealed record with an explicit constructor: short positional parameters are handy, but they bypass the check unless you pair them with validation.
What is an aggregate and how do you decide where its boundary goes?
An aggregate is a group of entities and value objects modified as a single unit, with a root that enforces the rules and one entry point from the outside. The order with its lines is the classic example: nobody changes a line without going through the order, because only the order knows the total, the status and the limits.
The root as the only entry point
From the outside you load the order, call order.AddLine(...) and save. There is no repository for lines, because a line makes no sense outside its order. This rule prevents the most common trouble in business systems: a line changed straight in the database by a service that did not know the order was already confirmed.
The root also protects the invariants, the conditions that must be true every time the aggregate is saved. In the order, the total must match the sum of the lines and confirmed lines no longer change. An invariant that holds only "usually" remains a wish.
A small aggregate is an aggregate that works
The typical mistake is to put everything related inside the aggregate: the order contains the customer, who contains all their orders, which contain all their lines, and every change loads half the database. My rule: inside the aggregate stay only the objects that must be consistent at the same instant. The order and its lines yes, because the total must add up immediately. The customer no: the order knows it through its Guid, not through an object reference.

References by identifier, not by object
Between one aggregate and another you use the identifier. The order has CustomerId, not a Customer object. The benefit is threefold: each aggregate loads and saves on its own, the boundaries stay visible in the code and, if the customer later moves to another service, the model does not break. When a screen needs the customer name, you get it with a dedicated read query, without loading the aggregate.
One transaction, one aggregate
As a rule a transaction modifies a single aggregate. If an operation needs two, for example confirming an order and reducing the customer's credit, the clean way is to confirm the order and publish the "order confirmed" event, which updates the credit a moment later. This is eventual consistency, and business systems accept it more readily than you would think: the sales manager does not notice if the credit drops after 40 milliseconds.
If you find that a rule requires changing two aggregates at the same instant, you have two options: the boundaries are wrong and the two objects should be merged, or the rule tolerates a short delay. The case should be discussed with the business before touching the code.
How do you map entities and value objects with Entity Framework Core?
Entity Framework Core 10 maps entities with private constructors and backing fields, and value objects with ComplexProperty or with OwnsOne. The domain model stays clean and the configuration lives in a separate class, in the data access project.
Configure without polluting the domain
The domain must not know Entity Framework: no [Key] or [Column] attributes on the order classes. The mapping sits in an OrderConfiguration class implementing IEntityTypeConfiguration<Order>. Here you say the key is Id, that the status is stored as a readable string and that the collection of lines uses the private _lines field. Entity Framework fills private fields and calls the private parameterless constructor, so the domain loses none of its protection.
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> b)
{
b.HasKey(o => o.Id);
b.Property(o => o.Status).HasConversion<string>();
b.HasMany(o => o.Lines).WithOne().HasForeignKey("OrderId");
b.Navigation(o => o.Lines).UsePropertyAccessMode(PropertyAccessMode.Field);
b.ComplexProperty(o => o.Total);
}
}Value objects: ComplexProperty and conversions
Since .NET 8 ComplexProperty is the most convenient way to map a value object, such as Money or Address, onto columns of the same table: Total_Amount and Total_Currency. It has no identity, is not tracked separately and compares by value, as the concept requires. For value objects with a single property, like Email, a value conversion (HasConversion) is enough: it saves the string and rebuilds the object on read.
Is the repository still useful?
It depends on what you ask of it. With Entity Framework Core the DbContext is already a unit of work and a set of repositories. A repository per aggregate still helps with two things: expressing in domain terms the searches the domain performs (OpenOrdersOfCustomer(customerId)) and hiding loading details, for example Include on the lines. A generic repository with GetAll() and Add() on every table, instead, adds a layer without adding meaning. The comparison between the lightest library and the full ORM is in Dapper versus Entity Framework.
Reads do not have to go through the domain
For screens with lists, filters and totals you do not need to load aggregates. A direct database query with a projection to a read model is faster and simpler. This split between writes, which go through the domain, and reads, which go straight to the data, is the core idea of CQRS, and I wrote about when it pays off and when it does not in CQRS: when it really pays off.
How do you use domain events in .NET to make the system react?
A domain event is an object that describes something that happened in the business, named in the past tense: OrderConfirmed, CreditLimitExceeded. The aggregate records it, and after the data is saved someone else reacts, for example by sending a confirmation or updating the credit.
Record the event inside the aggregate
When Confirm() succeeds, the order adds an OrderConfirmed(Id, CustomerId, Total) event to an internal list. It does not send it to anyone: it keeps it aside. The aggregate does not know who will listen, and should not, because the day someone adds an email notification the order does not change.
Publish after saving
The point where events leave is the save. In the overridden SaveChangesAsync of the DbContext you collect events from all tracked entities, save the data and only then pass the events to the handlers. If the save fails, the events do not fire: no confirmation email for an order that does not exist. For operations that cannot be lost, such as sending to an external system, the safest pattern is to store the event in the same transaction as the order, in an outbox table, and let a background service ship it.
With or without MediatR
Many projects use MediatR to distribute events, but it is not required. An IEventHandler<T> interface with handlers registered in the dependency container does the same job in twenty lines, without one more library to update. The registration mechanism is the one explained in dependency injection in C#: you ask the container for all handlers of the event type and run them one after the other.
Internal events and events sent outside
A domain event stays inside the application and can change shape freely. An event that leaves for other systems, an Azure Service Bus message for example, is a public contract with a different life: it must be versioned and cannot change without warning. Keep the two kinds apart. The handler of the internal OrderConfirmed event translates it into a public message with a stable format. If you want to go further and store only events as the source of truth, the approach has a name and a cost, discussed in Event Sourcing in .NET.
Where does the logic go: in the entity, a domain service or an application service?
A rule that concerns a single aggregate goes in the aggregate. A rule that involves several aggregates goes in a domain service. Technical coordination, such as loading data and saving, goes in an application service. Keeping the three levels apart avoids the two-thousand-line service where nobody can find anything.
Entity: the rules of one object
If the rule can be expressed by looking only at the order's data, the place is the order. "A confirmed order cannot be edited" and "the total is the sum of the lines minus the discount" live inside the class. When in doubt, ask yourself whether you need something the order does not own to check the rule: if not, it is the order's.
Domain service: rules across objects
"A customer cannot confirm an order beyond their credit limit" involves the order and the customer, which are different aggregates. A domain service such as CreditCheck receives both, applies the rule and returns the outcome. It has no technical dependencies: no database, no HTTP. It is a class you test with two in-memory objects. Name and content speak the business language, exactly like the entities.
Application service: the director without rules
The application service, or command handler, does the boring things: loads the order from the repository, loads the customer, calls the domain service, invokes order.Confirm(), saves, publishes the events. If a business if shows up inside, the rule is probably in the wrong place. A good application service reads like a recipe and contains no decision the company may want to change.
The anemic model warning sign
If all entities have only properties and all logic lives in classes called OrderService, CustomerManager, OrderHelper, you have an anemic model: objects that carry data and services that modify them. It works while the project is small. When the rules grow, every service can violate the invariants of any object, and Elena's mess comes back. The fix is gradual: move one rule at a time from the service to the entity, close the public setter that allowed bypassing the method and watch how many places in the code stop compiling. Those places were the ways the system could break.
This is the kind of reasoning we build in the architect track: where to put a rule, how to notice it sits in the wrong place, how to move it without stopping the project. For the broader picture of the layers these services fit into, see Clean Architecture in C#.
How do you test a domain model without a database?
A well-written domain model is tested with pure unit tests: you create the objects in memory, call the methods and check the state or the exception, with no database, no container and no mocks. It is the most immediate advantage of DDD, and the reason Giacomo's test in the article on dependency injection ran in a few milliseconds.
One test per rule, named after the rule
Every business rule becomes a test, and the test name is the rule written in plain language: An_order_without_lines_cannot_be_confirmed, A_confirmed_order_rejects_new_lines. When a test breaks six months from now, the name tells you which rule was violated without opening the code. With xUnit and FluentAssertions the test is three lines: you prepare the order, call the method, check that the exception has the expected message.
Test builders to avoid repetition
If every test rebuilds an order with a customer, three lines and a discount, the file becomes unreadable. A small OrderBuilder class with methods like WithLines(3) and Confirmed() gives you a ready order in one line and makes it obvious what changes from test to test. It stays in the test project, not in the domain.
Testing events
Verifying that Confirm() records the OrderConfirmed event with the right data is a trivial test when the event sits in a list on the aggregate. You need no handler, no bus and no database: you look at the list. Handlers are tested separately, each with its own input data.
The integration test for the mapping
One thing the unit test cannot see: whether Entity Framework can really save and reload the aggregate. That takes a small group of integration tests with a real database, usually SQL Server in a Docker container or in-memory SQLite for simple cases. You save an order with three lines, reload it with a new DbContext and compare. A few tests per aggregate are enough, and they rarely change. They are the ones that save you the day you update Entity Framework Core and a value conversion stops working.
The ratio I typically see in healthy projects is a hundred unit tests on the domain for every five integration tests. If your ratio is inverted, the domain is asking the database to do things it should know how to do on its own.
What is a bounded context and how do you organize a .NET solution?
A bounded context is a boundary inside which a term has a single meaning: "customer" for sales is not "customer" for accounting, and each context has its own model. In a .NET solution it translates into separate projects, each with its own domain, and precise rules about who can talk to whom.
Same name, two meanings
In Elena's company the sales "customer" has a price list, an agent and an area. The accounting "customer" has a VAT number, payment terms and a credit limit. Keeping them in the same class means every change by the sales office can break accounting. With two contexts, Sales and Accounting, each has its own Customer class and the two talk through the identifier and a few events. It is deliberate duplication, and it saves a lot of coordination work.
The project structure
In a layered solution, each context usually has four projects: Domain (pure classes, no dependencies), Application (use cases, depends on the domain), Infrastructure (Entity Framework, mail, files, depends on domain and application) and the entry point, an API or a site, which assembles them. The domain references nothing: if you open the project file and find a data access NuGet package, something is out of place. The overall layer design is the one in Clean Architecture in C#.
Modular monolith before separate services
Splitting into contexts does not mean splitting into microservices. In the vast majority of business systems I meet, the right choice is a single process with several modules, each with its own domain and its own database schema, communicating through public interfaces and internal events. If one day a module has to become a service of its own, the boundary already exists. The comparison between the two roads is in monolith or microservices.
How to find the boundary
The tables will not show it to you, the terms will. When two departments use the same word with different meanings, or when a change requested by one department makes another tremble, you have found a frontier. A two-hour session at a whiteboard with one person from each department usually produces a more reliable context map than a week of database analysis.
What mistakes show up most often in real DDD projects?
The most common mistakes are three: applying DDD to a problem that does not need it, building aggregates that are too large and using DDD names without moving the rules into the classes. All three produce the same result: more code to write and no benefit.
DDD as a ceremony
It happens when a team turns a system with four tables into a project with twelve folders, a factory for every class, three layers of interfaces and a repository for every table. There are two rules to protect, but the structure is built for twenty. The cost is paid by whoever arrives later and has to work out where a line of code went that used to sit in one file. The starting rule never changes: add a piece of structure when you feel the pain it solves, not before.
The giant aggregate
It is the order that contains the customer, the products, the shipments and the invoices. Every operation loads hundreds of objects, two users working on different parts block each other and Entity Framework's optimistic concurrency throws exceptions again and again. The remedy is the one seen in the aggregate section: split into smaller aggregates linked by identifier and accept eventual consistency where the business tolerates it.
Public properties with setters
An entity with public decimal Total { get; set; } protects nothing, even if it sits in a folder called Domain. The practical test: search the project for how many times order.Status = ... is written directly outside the class. If the number is greater than zero, the model is anemic and the rules are spread out. You fix it one setter at a time, so the compiler shows you every point to correct.
Exceptions used as normal flow
Throwing a domain exception for a violated rule is right, because it signals a programming error or invalid data. Using it to control an expected case, such as "the customer has exceeded the credit limit", costs performance and makes the code hard to follow. For cases the business foresees, return an explicit result (an Outcome type with success or error) and keep exceptions for impossible states.
Talking about DDD without talking to the business
The subtlest mistake: developers invent the vocabulary on their own. The result looks like a domain model and is really a developers' model. If nobody in the company recognizes the class names, the shared language does not exist and DDD stayed on paper. A simple check: let the sales manager read the list of methods of Order. If they understand what each one does, you are on the right path.
How do you introduce DDD into a .NET project that already exists?
You start from a single rule, the costliest one to get wrong, and move it into an entity. You close the public setter, let the compiler point out the places to fix and repeat with the next rule. No total rewrite, no project freeze.
Pick the rule that hurts most
Elena would have started with the rule "a confirmed order cannot be edited", because it caused the wrong invoice. Ask the business which rule, if violated, costs the most money or the most support time, and start there. A visible win in the first two weeks convinces the team far more than a quarterly plan.
The method in five steps
- Find every place in the code that writes the property in question.
- Create in the entity the method with the business name (
Confirm). - Move into the method the checks that are scattered today.
- Make the property setter private.
- Fix the compile errors by calling the new method.
For an average entity the work takes from half a day to two days, and at the end you have one rule in one place and a test protecting it. The technique is the same as the small-step modernization I describe in the Strangler Fig pattern: old and new live together, and the new grows at the edges.
Grow by area, not by layer
Once the order is in order, move to another area that hurts, not to the other layers of the same module. The goal is for the well-modeled zones to expand until the whole system is covered, and there is no hurry: one area modeled well each quarter is worth more than a "DDD" project left half done.
AI helps, but it does not decide the boundaries
Coding assistants are useful for generating a value object with its validation, or for writing the Entity Framework configuration of an aggregate. They do not know what "confirmed order" means in your company, and they will propose boundaries that are plausible and wrong. Questions about the domain stay with the team and the business, mechanical writing goes to the assistant, and every generated line goes through review like any other.
Elena started with the rule that ruined her Tuesday. She moved the check inside Order.Confirm(), made the setter of Status private and let the compiler show her the seven places that wrote the state from outside. She fixed six in one morning. The seventh was the nightly import, the one nobody remembered. The following Tuesday the sales manager changed the credit rule, and Elena touched one method.
If you want to design the boundaries of your own system with your own head, recognize an aggregate that is too large and choose where each rule belongs, in the Software Architect with AI course at Sviluppatore Migliore we work on real cases with my direct feedback, in lessons that fit around your job. Bring the class with the most public setters you have: we will make it safe together.
Frequently asked questions
You write entities with private setters and methods named after the business, value objects as records, and aggregates with a root and a clear boundary. Entity Framework Core maps everything with separate configurations, so the domain does not depend on data access.
Use one when the value has rules of its own, such as an amount with currency or a VAT number. A value object that is valid by construction removes checks from the rest of the code and prevents mistakes like adding euros to dollars.
An aggregate is a group of objects changed as a unit, with a root that enforces the rules. Keep it small: only objects that must be consistent at the same instant go inside, everything else is linked by identifier.
You need one repository per aggregate if you want to express searches in domain terms and hide loading details. A generic repository on every table adds a layer with no meaning, because the DbContext is already a unit of work.
With pure unit tests: create the objects in memory, call the methods and check the state or the exception, with no database. A few integration tests with a real database only confirm that the Entity Framework mapping saves and reloads the aggregate.
Start from the costliest rule to get wrong, move it into an entity method, make the setter private and fix the compile errors. Repeat area by area, without rewriting everything and without stopping the project.
