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.
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
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.
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 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.
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.
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).
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.
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.
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.
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.