Domain-Driven Design

Reviewed & published by Brayan K

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 worked examples and the output they print, 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

PatternHas identity?Compared byUse it for
Value ObjectNoIts valuesMoney, Email, Address, Distance
EntityYes (an Id)Identity (Id)Customer, Product — things tracked over time
AggregateYes (the root)Root's identityOrder + 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.

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, so an invalid Money cannot exist.
    public static Money Of(decimal amount, string currency)
    {
        if (amount < 0)
            throw new ArgumentException("Money cannot be negative");
        if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
            throw new ArgumentException("Currency must be a 3-letter code");
        return new Money(amount, currency.ToUpperInvariant());
    }

    // Behaviour returns a NEW Money — the original is never mutated.
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new ArgumentException("Cannot add different currencies");
        return new Money(Amount + other.Amount, Currency);
    }

    public override string ToString() => $"{Amount:F2} {Currency}";
}

class Program
{
    static void Main()
    {
        var a = Money.Of(10m, "GBP");
        var b = Money.Of(10m, "gbp");   // normalised to "GBP" by the factory

        // Value equality: the records match because their VALUES match,
        // even though they are two separate objects in memory.
        Console.WriteLine(a == b);              // True
        Console.WriteLine(a.Equals(b));         // True

        var different = Money.Of(25m, "GBP");
        Console.WriteLine(a == different);      // False

        // Adding returns a brand-new Money; 'a' is unchanged (immutable).
        var sum = a.Add(different);
        Console.WriteLine(sum);                 // 35.00 GBP
        Console.WriteLine(a);                   // 10.00 GBP  (still the same)
    }
}

// ✅ Expected output:
//    True
//    True
//    False
//    35.00 GBP
//    10.00 GBP

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.

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);     // same value, separate object
        var trip3 = new Distance(8m);

        // 2) Compare trip1 and trip2 by VALUE (records support ==).
        bool sameDistance = trip1 ___ trip2;   // 👉 the equality operator  ==

        Console.WriteLine($"trip1 == trip2? {sameDistance}");
        Console.WriteLine($"trip1 == trip3? {trip1 == trip3}");

        // ✅ Expected output:
        //    trip1 == trip2? True
        //    trip1 == trip3? False
    }
}

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.

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; }

    public Customer(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Customer needs a name");
        Id = Guid.NewGuid();                // a fresh, unique identity
        Name = name;
        LoyaltyPoints = 0;
    }

    // A method that ENFORCES AN INVARIANT (a rule that must always hold):
    // loyalty points can never go negative. State only changes through here.
    public void Redeem(int points)
    {
        if (points <= 0)
            throw new ArgumentException("Redeem a positive amount");
        if (points > LoyaltyPoints)
            throw new InvalidOperationException("Not enough points");
        LoyaltyPoints -= points;            // safe: the rule was checked first
    }

    public void Earn(int points) => LoyaltyPoints += points;
}

class Program
{
    static void Main()
    {
        var sam1 = new Customer("Sam Lee");
        var sam2 = new Customer("Sam Lee");   // same name, DIFFERENT person

        // Identity, not attributes: same name but not equal (different Ids).
        Console.WriteLine(sam1.Id == sam2.Id);   // False

        sam1.Earn(100);
        sam1.Redeem(30);
        Console.WriteLine($"Points: {sam1.LoyaltyPoints}");   // Points: 70

        // The invariant protects you: this is rejected, not silently allowed.
        try { sam1.Redeem(999); }
        catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); } // Not enough points
    }
}

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:

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 outside 5-30.
    public void SetTo(int degrees)
    {
        // 2) If degrees is below 5 OR above 30, refuse it.
        if (degrees < 5 ___ degrees > 30)   // 👉 the logical OR operator  ||
            throw new ArgumentException("Temperature must be 5-30");
        Temperature = degrees;
    }
}

class Program
{
    static void Main()
    {
        var t = new Thermostat();
        t.SetTo(22);
        Console.WriteLine($"Now: {t.Temperature}");   // Now: 22

        try { t.SetTo(99); }
        catch (ArgumentException ex) { Console.WriteLine(ex.Message); }

        // ✅ Expected output:
        //    Now: 22
        //    Temperature must be 5-30
    }
}

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.

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 cannot
    // add a line item behind the root's back, so rules can't be bypassed.
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();

    public decimal Total => _lines.Sum(l => l.Price * l.Quantity);

    // All changes go THROUGH the root, which enforces the rules.
    public void AddItem(string product, decimal price, int quantity)
    {
        if (IsConfirmed)
            throw new InvalidOperationException("Cannot change a confirmed order");
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive");
        _lines.Add(new OrderLine(product, price, quantity));
    }

    // INVARIANT: you cannot confirm an empty order.
    public void Confirm()
    {
        if (_lines.Count == 0)
            throw new InvalidOperationException("Cannot confirm an empty order");
        IsConfirmed = true;
    }
}

// A line item is a value object owned by the aggregate.
public record OrderLine(string Product, decimal Price, int Quantity);

class Program
{
    static void Main()
    {
        var order = new Order();

        // Confirming an EMPTY order is blocked by the invariant.
        try { order.Confirm(); }
        catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); } // Cannot confirm an empty order

        order.AddItem("Keyboard", 45m, 1);
        order.AddItem("Mouse", 20m, 2);
        Console.WriteLine($"Total: {order.Total}");   // Total: 85

        order.Confirm();
        Console.WriteLine($"Confirmed: {order.IsConfirmed}");   // Confirmed: True

        // After confirmation the aggregate is locked — changes are refused.
        try { order.AddItem("Late add", 5m, 1); }
        catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); } // Cannot change a confirmed order
    }
}

🔎 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

Common Mistakes (and the fix)

📋 Quick Reference

ConceptIn C#Notes
Value objectpublic record Money(decimal Amount, string Ccy);Immutable + value equality
Entity identitypublic Guid Id { get; }Set once, compare by Id
Protected child list_lines.AsReadOnly()No edits behind the root
Enforce an invariantif (empty) throw ...;Guard inside a method
Reference another aggregatepublic Guid CustomerId { get; }By Id, not by object
RepositoryTask<Order?> GetByIdAsync(Guid id);One per aggregate root
Domain eventrecord 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.

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 property that sums the item prices.
//
// In Main: try Checkout() on an empty cart (expect a refusal), then add two
// items, print the total, Checkout(), and print IsCheckedOut.
//
// ✅ Expected output (e.g. items at 30 and 20):
//    Cannot checkout an empty cart
//    Total: 50
//    Checked out: True

public record CartItem(string Name, decimal Price);

public class ShoppingCart
{
    // your private list, properties and methods here
}

class Program
{
    static void Main()
    {
        // your code here
    }
}

🎉 Lesson Complete

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