🏠 VisualStudioTutor.com  Β·  C# Tutorial Home  Β·  C# Lesson 37 of 40
Lesson 37 of 40 Architecture Expert ⏱ 40 min

Domain-Driven Design in C#

Model business rules inside a small ordering domain using an Aggregate Root, Value Objects, Domain Events, and a Repository boundary instead of spreading rules across UI and database code.

Part 1: What You Will Learn

Domain-Driven Design (DDD) is most useful when software must express important business rules clearly. In this lesson, the code itself becomes a model of an ordering domain rather than a collection of database tables and event handlers.

  • Distinguish an Entity from a Value Object.
  • Use an Aggregate Root to protect business invariants.
  • Record a Domain Event when an important business action happens.
  • Use a Repository as a persistence boundary.
  • Understand where a Bounded Context fits into a larger system.

Project setup: Create a .NET 10 Console App named OrderDomainDemo. No additional NuGet packages are required.

Part 2: Aggregate Root, Value Object & Domain Event

The example below models an order. Money is a value object, Order is the aggregate root, and OrderPlaced is a domain event.

public readonly record struct Money(decimal Amount, string Currency)
{
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Currencies must match.");

        return new Money(Amount + other.Amount, Currency);
    }

    public Money Multiply(int quantity) =>
        new(Amount * quantity, Currency);
}

public sealed record OrderLine(
    Guid ProductId,
    int Quantity,
    Money UnitPrice)
{
    public Money LineTotal => UnitPrice.Multiply(Quantity);
}

public sealed record OrderPlaced(
    Guid OrderId,
    decimal Total,
    string Currency,
    DateTimeOffset OccurredAt);

public sealed class Order
{
    private readonly List<OrderLine> _lines = [];
    private readonly List<object> _domainEvents = [];

    public Guid Id { get; } = Guid.NewGuid();
    public IReadOnlyList<OrderLine> Lines => _lines;
    public IReadOnlyCollection<object> DomainEvents => _domainEvents;

    public void AddItem(Guid productId, int quantity, Money unitPrice)
    {
        if (quantity <= 0)
            throw new ArgumentOutOfRangeException(nameof(quantity));

        _lines.Add(new OrderLine(productId, quantity, unitPrice));
    }

    public Money GetTotal()
    {
        if (_lines.Count == 0)
            return new Money(0, "MYR");

        Money total = new(0, _lines[0].UnitPrice.Currency);

        foreach (OrderLine line in _lines)
            total = total.Add(line.LineTotal);

        return total;
    }

    public void Place()
    {
        if (_lines.Count == 0)
            throw new InvalidOperationException("An empty order cannot be placed.");

        Money total = GetTotal();
        _domainEvents.Add(
            new OrderPlaced(Id, total.Amount, total.Currency, DateTimeOffset.UtcNow));
    }
}

var order = new Order();
order.AddItem(Guid.NewGuid(), 2, new Money(49.90m, "MYR"));
order.AddItem(Guid.NewGuid(), 1, new Money(120.00m, "MYR"));
order.Place();

Money total = order.GetTotal();
Console.WriteLine($"Order {order.Id}");
Console.WriteLine($"Total: {total.Currency} {total.Amount:N2}");
Console.WriteLine($"Domain events: {order.DomainEvents.Count}");

Part 3: Adding a Repository Boundary

The domain model should not know whether orders are stored in SQL Server, a file, memory, or a web service. A repository interface keeps persistence outside the aggregate.

public interface IOrderRepository
{
    Task<Order?> GetAsync(Guid id);
    Task SaveAsync(Order order);
}

public sealed class InMemoryOrderRepository : IOrderRepository
{
    private readonly Dictionary<Guid, Order> _orders = [];

    public Task<Order?> GetAsync(Guid id) =>
        Task.FromResult(_orders.GetValueOrDefault(id));

    public Task SaveAsync(Order order)
    {
        _orders[order.Id] = order;
        return Task.CompletedTask;
    }
}
  • Order owns the rules for adding items and placing an order.
  • Money compares and combines values by amount and currency instead of by object identity.
  • The domain event records that the order was placed; another layer can later react by sending email or updating inventory.
  • The repository hides storage details from the domain model.

A Bounded Context is the larger boundary around a model and its language. For example, the word β€œOrder” may have one meaning in Sales and another in Shipping. DDD encourages those models to remain explicit rather than forcing one giant model on the entire organization.

Part 4: Mini Project & Practice

Mini project: extend the order aggregate with an OrderStatus value, prevent items from being changed after the order is placed, and add an OrderCancelled domain event.

Then replace InMemoryOrderRepository with an EF Core repository without changing the business rules inside Order.

Key idea: DDD is not about creating many folders. It is about putting important domain rules in a model whose names and behavior match the business.

When you are comfortable with this lesson, continue to Lesson 38.

C# in Visual Studio 2026

πŸ“˜ This lesson is part of the book C# in Visual Studio 2026 by Dr. Liew Voon Kiong.

View on Amazon Kindle Edition