Ddd
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.
Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
💡 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.
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.
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.
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.
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:
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.
🔎 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 OrderLine s (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.
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.
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.
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.
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 Money or Email type carries its own rules; a bare decimal or string carries 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 List so callers do order.Lines.Add(...) , bypassing every check. The fix: keep the list private , expose IReadOnlyList , and add via order.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 email and decimal amount everywhere, so nothing validates them and the same bug is re-checked in ten places. The fix: introduce Email and Money value 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
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.
🎉 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
Practice quiz
What is the 'ubiquitous language' in DDD?
- A programming language used by all teams
- The English language only
- A shared vocabulary that developers and domain experts both use, in conversation and in the code
- A universal database schema
Answer: A shared vocabulary that developers and domain experts both use, in conversation and in the code. The ubiquitous language is a shared vocabulary so that, for example, 'confirm an order' becomes order.Confirm() in code rather than order.SetStatus(2).
How is a value object compared?
- By its values — two with the same values are equal and interchangeable
- By a unique identity (Id)
- By memory reference only
- Value objects cannot be compared
Answer: By its values — two with the same values are equal and interchangeable. A value object has no identity; it's defined entirely by its values, so 10 GBP equals 10 GBP.
Which C# feature is ideal for a value object because it is immutable and gives value equality for free?
- class
- struct with public fields
- interface
- record
Answer: record. A C# record is immutable by default and provides value equality, so == compares contents — perfect for value objects like Money.
How is an entity compared, and what distinguishes it from a value object?
- By its attributes; it has no identity
- By its identity (an Id); its data can change over time but its identity never does
- By memory address; entities are immutable
- Entities and value objects are compared identically
Answer: By its identity (an Id); its data can change over time but its identity never does. An entity has identity (an Id) and is compared by it — two customers named 'Sam Lee' are different people. Its attributes may change; its identity does not.
What is an invariant in DDD?
- A rule that must always hold true, enforced by the entity's methods
- A variable that never changes name
- A type that cannot be instantiated
- A database constraint only
Answer: A rule that must always hold true, enforced by the entity's methods. An invariant is a rule that must always be true (e.g. loyalty points can't go negative). Entities enforce invariants inside their methods.
What is an aggregate root?
- The database table that stores aggregates
- A value object inside the aggregate
- The single entity that is the only entry point into an aggregate and guards its invariants
- The repository class
Answer: The single entity that is the only entry point into an aggregate and guards its invariants. The aggregate root is the only way into the cluster; outside code never touches the children directly, so the root can guarantee its invariants.
Why is an aggregate's child collection kept private and exposed only as IReadOnlyList?
- To save memory
- So outsiders cannot add or change children behind the root's back and bypass its rules
- Because IReadOnlyList is faster
- It is a C# compiler requirement
Answer: So outsiders cannot add or change children behind the root's back and bypass its rules. Keeping the list private and exposing a read-only view stops callers sneaking a change past the root's invariant checks (e.g. order.Lines.Add).
How should one aggregate reference another aggregate?
- By holding the whole object as a field
- Through a static global variable
- It must never reference another aggregate
- By Id, not by object reference
Answer: By Id, not by object reference. Aggregates reference each other by Id to keep consistency boundaries clear and transactions small — e.g. Order holds a CustomerId, not a Customer object.
What is the rule for the number of repositories per aggregate?
- One repository per child entity
- One repository per aggregate root, loading and saving the whole aggregate as a unit
- One global repository for everything
- No repositories are used in DDD
Answer: One repository per aggregate root, loading and saving the whole aggregate as a unit. There's one repository per aggregate root; it always loads and saves the aggregate as a unit, hiding the database from the domain.
When should a domain event be dispatched?
- Before the change is saved, so handlers run first
- At application startup
- Only after the change is committed, so a failed save doesn't fire side effects for nothing
- Domain events are never dispatched
Answer: Only after the change is committed, so a failed save doesn't fire side effects for nothing. Dispatch domain events only after the change is saved; otherwise a failed save would have already fired emails for an order that doesn't exist.
Continue this course
- Previous: Clean Architecture
- Next: Microservices