Advanced Track
Domain-Driven Design
By the end of this lesson you'll be able to model a business in code using the domain experts' own language — building value objects that make invalid states impossible, entities with identity, and aggregates whose roots guard the rules that must always hold. This is how large C# systems stay correct as they grow.
What You'll Learn
- Speak the ubiquitous language so code matches how the business talks
- Tell value objects (no identity, compared by value) from entities (have identity)
- Design aggregates with a root that protects invariants — rules that must always hold
- Use the repository pattern to load and save whole aggregates
- Raise domain events to decouple side effects from business logic
- Split a large model into bounded contexts with their own language
💡 Real-World Analogy
Imagine you're hired to build software for a warehouse, but you've never run one. So you sit with the warehouse manager and listen. They say "an order has line items", "you can't ship an order that hasn't been picked", "stock is reserved when an order is confirmed." Domain-Driven Design is simply this: you model the business in code using their words and their rules — not "rows" and "records", but Orders, Shipments and Stock. When the manager says "you can't confirm an empty order", that sentence becomes a method that literally refuses to do it. The code reads like a description of the business, so the experts can almost read it too.
📊 The Building Blocks at a Glance
| Pattern | Has identity? | Compared by | Use it for |
|---|---|---|---|
| Value Object | No | Its values | Money, Email, Address, Distance |
| Entity | Yes (an Id) | Identity (Id) | Customer, Product — things tracked over time |
| Aggregate | Yes (the root) | Root's identity | Order + its lines — a consistency boundary |
| Repository | — | — | Loading & saving whole aggregates |
The first question to ask about any concept: "do I care which one it is, or only what it is?" If you care which one (this exact customer), it's an entity. If you only care about the value (this amount of money), it's a value object.
1. The Ubiquitous Language
The ubiquitous language is a shared vocabulary that the developers and the domain experts both use — in conversation, in documents, and in the code itself. If the business says "confirm an order", you write order.Confirm(), not order.SetStatus(2). The payoff is that misunderstandings surface early and the code becomes a living description of the business. When the language in your classes drifts from the language in the meeting room, bugs hide in the gap.
Naming that matches the business
Compare two versions of the same idea. The first is technically fine but says nothing about the domain; the second is the domain, written down.
// ❌ Anaemic & technical — the rule is invisible order.Status = 2; order.Items.Add(item); // ✅ Ubiquitous language — the rule is the method order.Confirm(); // refuses to confirm an empty order order.AddItem(product, price, qty);
Read the second version aloud — it sounds like the warehouse manager describing the process. That is the goal of every name you choose.
2. Value Objects
A value object has no identity — it's defined entirely by its values, so two with the same values are interchangeable (10 GBP is 10 GBP). They're immutable (never change after creation) and self-validating (an invalid one can't exist). In C# a record is ideal: it's immutable by default and gives you value equality for free, so == compares contents, not references. Read this worked example and run it, then you'll build your own.
Worked example: a Money value object
Run it and notice two separate Money objects are equal when their values match.
using System;
// A VALUE OBJECT has no identity — it is defined entirely by its values.
// Two banknotes both worth 10 GBP are interchangeable: you don't care WHICH
// note you hold, only how much it is worth. So 10 GBP == 10 GBP, full stop.
//
// A C# 'record' is the perfect tool: it is immutable (no setters) and gives
// you VALUE EQUALITY for free — two records with the same values are equal.
public record Money(decimal Amount, string Currency)
{
// The domain rules live INSIDE the type,
...Your turn. The program below builds a Distance value object and compares two of them — it just needs the record keyword and the equality operator. Fill in the two ___ blanks, then run it.
🎯 Your turn: build & compare a value object
Make Distance a record and compare two by value, then check the output.
using System;
// 🎯 YOUR TURN — build a Distance VALUE OBJECT, then compare two of them.
// A value object is immutable and compared BY VALUE, so 5 km == 5 km.
// 1) Make this a record so you get immutability + value equality for free.
public ___ Distance(decimal Kilometres) // 👉 the keyword is record
{
public override string ToString() => $"{Kilometres} km";
}
class Program
{
static void Main()
{
var trip1 = new Distance(5m);
var trip2 = new Distance(5m);
...3. Entities & Identity
An entity is the opposite of a value object: it has identity. Two customers called "Sam Lee" are different people, each with their own Id, and you compare entities by that identity — never by their attributes. An entity's data changes over time (a customer earns points), but its identity never does. Crucially, an entity should change its own state only through methods that enforce its invariants — the rules that must always be true.
Worked example: a Customer entity
Two customers with the same name are not equal — identity, not attributes.
using System;
// An ENTITY is the opposite of a value object: it HAS identity.
// Two customers named "Sam Lee" are NOT the same person — each has its own Id,
// and we compare entities by that Id, never by their attributes.
// An entity's data can change over time, but its identity never does.
public class Customer
{
public Guid Id { get; } // identity — set once, never changes
public string Name { get; private set; }
public int LoyaltyPoints { get; private set; }
...Now you try. Finish the Thermostat entity so it has its own identity and a method that refuses any temperature outside 5–30. Fill in the two ___ blanks:
🎯 Your turn: an entity that enforces an invariant
Give it an Id and make SetTo reject out-of-range values, then run it.
using System;
// 🎯 YOUR TURN — finish the Thermostat ENTITY.
// It has identity (an Id) and an invariant: temperature stays within 5-30.
public class Thermostat
{
public Guid Id { get; }
public int Temperature { get; private set; }
public Thermostat()
{
// 1) Give this entity its own unique identity.
Id = ___; // 👉 a brand-new id: Guid.NewGuid()
Temperature = 20;
}
// SetTo must ENFORCE the invariant: reject anything outsi
...4. Aggregates, Roots & Invariants
An aggregate is a cluster of related objects you treat as a single unit whenever you change them — for example an Order together with its line items. One entity is the aggregate root: it's the only way in. Outside code never touches the children directly, so the root can guarantee its invariants always hold (you can't confirm an empty order; you can't change a confirmed one). The list of children stays private and is exposed only as a read-only view, so nobody can sneak a change past the rules. Read this and run it.
Worked example: an Order aggregate root
Confirming an empty order is refused; once confirmed, the order is locked.
using System;
using System.Collections.Generic;
using System.Linq;
// An AGGREGATE is a cluster of related objects treated as one unit for changes.
// The AGGREGATE ROOT (Order) is the ONLY entry point: outside code never edits
// the line items directly, so the root can guarantee its invariants always hold.
public class Order
{
public Guid Id { get; } = Guid.NewGuid();
public bool IsConfirmed { get; private set; }
// The list is PRIVATE; outsiders only get a read-only view. They c
...🔎 Deep Dive: where does an aggregate end?
The boundary of an aggregate is a consistency boundary: everything inside it must be valid together in a single transaction. Ask "if I change A, must B change in the same breath to stay correct?" If yes, they belong in the same aggregate. If no, they're separate aggregates that reference each other by Id, not by object reference.
So an Order owns its OrderLines (they're meaningless apart from the order), but it references the Customer by CustomerId — a customer lives its own life and changes on its own schedule. This keeps aggregates small and transactions fast.
public class Order
{
public Guid CustomerId { get; } // ✅ reference another aggregate by Id
private List<OrderLine> _lines; // ✅ own your children directly
// public Customer Customer { get; } // ❌ don't hold the whole customer object
}5. Repositories
A repository gives the domain the illusion of an in-memory collection of aggregates: you ask for an Order by Id and get the whole thing back, with no SQL or Entity Framework leaking into your business logic. There's one repository per aggregate, and it always loads and saves the aggregate as a unit. The application code depends only on the interface — a domain concept — so the domain layer stays pure and testable. This example needs a database to run, so read it rather than running it.
// A REPOSITORY gives you the illusion of an in-memory collection of aggregates.
// It hides the database: the domain asks for an Order by Id and gets one back,
// with no SQL or EF Core leaking into the business logic. One repo per AGGREGATE.
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id); // load a whole aggregate
Task AddAsync(Order order); // persist a new aggregate
Task SaveChangesAsync(); // commit the unit of work
}
// The application layer depends only on the INTERFACE (a domain concept),
// never on the concrete data-access class. That keeps the domain pure.
public class PlaceOrderHandler
{
private readonly IOrderRepository _orders;
public PlaceOrderHandler(IOrderRepository orders) => _orders = orders;
public async Task HandleAsync(Guid orderId)
{
var order = await _orders.GetByIdAsync(orderId)
?? throw new InvalidOperationException("Order not found");
order.Confirm(); // pure domain behaviour
await _orders.SaveChangesAsync(); // infrastructure does the rest
}
}6. Domain Events
A domain event records something that happened in the domain, named in the past tense — OrderConfirmed, FundsWithdrawn. The aggregate raises the event to announce a fact, without knowing who reacts to it. That decouples side effects — sending an email, writing an audit log, reserving stock — from the core rules. The one discipline: dispatch events only after the change is saved, or a failed save will have already fired emails for an order that doesn't exist. This needs a dispatcher to run, so read it.
// A DOMAIN EVENT records something that HAPPENED in the domain, in the past
// tense (OrderConfirmed, FundsWithdrawn). It lets the aggregate announce a fact
// without knowing — or caring — who reacts to it. That decouples side effects
// (emails, audit logs, stock updates) from the core business rules.
public interface IDomainEvent { DateTime OccurredAt { get; } }
public record OrderConfirmed(Guid OrderId) : IDomainEvent
{
public DateTime OccurredAt { get; } = DateTime.UtcNow;
}
public class Order
{
private readonly List<IDomainEvent> _events = new();
public IReadOnlyList<IDomainEvent> Events => _events.AsReadOnly();
public bool IsConfirmed { get; private set; }
public void Confirm()
{
if (IsConfirmed) return;
IsConfirmed = true;
// The aggregate RAISES the event; it does not send the email itself.
_events.Add(new OrderConfirmed(Id: Guid.NewGuid()));
}
}
// A separate handler reacts AFTER the aggregate is saved. Dispatch events only
// once the change is committed — otherwise a failed save fires emails for nothing.
public interface IHandle<T> where T : IDomainEvent { Task On(T e); }
public class SendReceiptEmail : IHandle<OrderConfirmed>
{
public Task On(OrderConfirmed e) => Task.CompletedTask; // ... send the email
}7. Bounded Contexts
In a big system, one word means different things to different teams. To Sales, a "Customer" is a lead with a phone number and a deal size; to Support, a "Customer" is a ticket history; to Billing, it's a payment method and an invoice address. A bounded context is an explicit boundary — usually one module, service, or microservice — inside which a model and its ubiquitous language are consistent. Trying to make one giant Customer class serve every team produces a bloated mess that pleases no one. Instead, each context keeps its own focused model and they integrate at the edges (by Id, events, or APIs).
🔎 Deep Dive: one word, three models
Don't fight this — embrace it. The same Guid identifies the customer across contexts, but each context models only what it needs.
// Sales context
public class Customer { public string Name; public decimal DealSize; }
// Support context — same person, different concerns
public class Customer { public List<Ticket> Tickets; public string Tier; }
// Billing context
public class Customer { public PaymentMethod Card; public Address Invoice; }This is exactly why DDD pairs so naturally with the next lesson, microservices: a well-drawn bounded context is often a service boundary too.
Pro Tips
- 💡 Push behaviour into the model: a rich domain type with methods like
order.Confirm()beats a bag of public setters edited by a service. If your classes are all data and no behaviour, that's an anaemic model. - 💡 Keep aggregates small: if two things don't need to be consistent in the same transaction, split them into separate aggregates linked by Id, not object reference.
- 💡 Make invariants impossible to break: private setters, private collections exposed as
IReadOnlyList, and factory methods that validate — so an invalid object can never be constructed. - 💡 Use value objects to kill primitive obsession: a
MoneyorEmailtype carries its own rules; a baredecimalorstringcarries none. - 💡 One repository per aggregate root — never a repository for a child entity. You load and save the whole aggregate together.
Common Mistakes (and the fix)
- The anaemic domain model: classes with only public getters/setters and zero behaviour, with all the logic dumped in "service" classes. The fix: move the rules into the entity as methods, so the object can never enter an invalid state.
- Leaking invariants out of the aggregate: exposing the children as a mutable
Listso callers doorder.Lines.Add(...), bypassing every check. The fix: keep the listprivate, exposeIReadOnlyList, and add viaorder.AddItem(...). - Giant aggregates: one root that owns hundreds of entities, so every change locks a huge graph and transactions crawl. The fix: split along consistency boundaries and reference other aggregates by Id.
- Primitive obsession: passing
string emailanddecimal amounteverywhere, so nothing validates them and the same bug is re-checked in ten places. The fix: introduceEmailandMoneyvalue objects that validate once, on creation. - Dispatching events before saving: handlers send emails, then the database save fails and rolls back. The fix: raise events on the aggregate, but only dispatch them after the transaction commits.
📋 Quick Reference
| Concept | In C# | Notes |
|---|---|---|
| Value object | public record Money(decimal Amount, string Ccy); | Immutable + value equality |
| Entity identity | public Guid Id { get; } | Set once, compare by Id |
| Protected child list | _lines.AsReadOnly() | No edits behind the root |
| Enforce an invariant | if (empty) throw ...; | Guard inside a method |
| Reference another aggregate | public Guid CustomerId { get; } | By Id, not by object |
| Repository | Task<Order?> GetByIdAsync(Guid id); | One per aggregate root |
| Domain event | record OrderConfirmed(Guid Id) : IDomainEvent; | Past tense, dispatch after save |
Frequently Asked Questions
Q: How do I decide if something is an entity or a value object?
Ask whether you care which one it is. If you need to track this specific thing over time (this customer, this order), it has identity — it's an entity. If you only care about its value and any two with the same value are interchangeable (this amount of money, this email address), it's a value object.
Q: Isn't putting all this logic in the model just overkill for a CRUD app?
For a simple forms-over-data app, yes — DDD's tactical patterns shine when the domain has real rules and complexity. Use the full toolkit where the business logic is rich; for plain CRUD a thin model is fine. The skill is knowing which part of your system is which.
Q: Why can't an aggregate just reference another aggregate as a full object?
Because that blurs consistency boundaries: you'd load and lock a huge object graph for every change, and it's unclear which aggregate's invariants apply. Referencing by Id keeps each aggregate independently loadable and its transaction small.
Q: Are domain events the same as integration events / messages?
Not quite. A domain event is internal to one bounded context and handled in-process after the save. An integration event is published across contexts (often over a message bus) to tell other services something happened. They're related, but live at different layers.
Q: Where do repositories and the database fit with Clean Architecture?
The repository interface lives in the domain (it's a domain concept), and the concrete implementation that talks to Entity Framework lives in the infrastructure layer. The domain depends on the interface only — exactly the dependency rule you learned in Clean Architecture.
Mini-Challenge: a ShoppingCart Aggregate
No blanks this time — just a brief and an outline. Build a ShoppingCart aggregate whose AddItem and Checkout enforce a rule: you can't check out an empty cart, and you can't add items after checkout. Keep the item list private and expose it read-only. Run it and check your output against the expected lines in the comments.
🎯 Mini-Challenge: a ShoppingCart aggregate
Protect the invariant — an empty cart can't be checked out.
using System;
using System.Collections.Generic;
using System.Linq;
// 🎯 MINI-CHALLENGE: Build a ShoppingCart AGGREGATE
// 1. Field: a PRIVATE List of CartItem, exposed only as IReadOnlyList.
// 2. AddItem(string name, decimal price): refuse it once the cart is checked out.
// 3. A bool IsCheckedOut property with a private setter.
// 4. Checkout(): throw if the cart is EMPTY, otherwise set IsCheckedOut = true.
// (This is the invariant — you can't check out an empty cart.)
// 5. A Total prop
...🎉 Lesson Complete
- ✅ The ubiquitous language makes code read like the business describes itself
- ✅ Value objects (records) have no identity, are immutable, and compare by value
- ✅ Entities have identity (an Id) and change state only through invariant-enforcing methods
- ✅ An aggregate root is the only entry point and guards the cluster's invariants
- ✅ Repositories load and save whole aggregates — one per aggregate root
- ✅ Domain events decouple side effects; dispatch them only after saving
- ✅ Bounded contexts let one word mean different things in different parts of the system
- ✅ Next lesson: Microservices — turning bounded contexts into independently deployable services
Sign up for free to track which lessons you've completed and get learning reminders.