Domain-Driven Design (DDD) isn’t just about organizing your code; it’s about building software that truly reflects the complex realities of the business it serves.

Imagine a system for managing online orders. Here’s a glimpse of how it might look in C#, focusing on the core Order aggregate:

public class Order
{
    public Guid Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
    public OrderStatus Status { get; private set; }
    public DateTime OrderDate { get; private set; }

    private readonly List<OrderItem> _items = new List<OrderItem>();

    // Private constructor for ORM
    private Order() { }

    public Order(CustomerId customerId, DateTime orderDate)
    {
        Id = Guid.NewGuid();
        CustomerId = customerId;
        OrderDate = orderDate;
        Status = OrderStatus.Pending;
    }

    public void AddItem(ProductId productId, int quantity, decimal unitPrice)
    {
        if (Status != OrderStatus.Pending)
        {
            throw new InvalidOperationException("Cannot add items to a non-pending order.");
        }
        if (quantity <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
        }

        var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
        if (existingItem != null)
        {
            existingItem.IncreaseQuantity(quantity);
        }
        else
        {
            _items.Add(new OrderItem(productId, quantity, unitPrice));
        }
    }

    public void Confirm()
    {
        if (Status != OrderStatus.Pending)
        {
            throw new InvalidOperationException("Order can only be confirmed if it is pending.");
        }
        if (!_items.Any())
        {
            throw new InvalidOperationException("Cannot confirm an order with no items.");
        }
        Status = OrderStatus.Confirmed;
        // Domain Event: OrderConfirmedEvent raised here
    }

    public void Cancel()
    {
        if (Status == OrderStatus.Shipped || Status == OrderStatus.Delivered)
        {
            throw new InvalidOperationException("Cannot cancel an order that has already shipped or been delivered.");
        }
        Status = OrderStatus.Cancelled;
        // Domain Event: OrderCancelledEvent raised here
    }

    // Other methods like Ship(), Deliver(), etc.
}

public class OrderItem
{
    public ProductId ProductId { get; private set; }
    public int Quantity { get; private set; }
    public decimal UnitPrice { get; private set; }
    public decimal TotalPrice => Quantity * UnitPrice;

    private OrderItem() { } // For ORM

    public OrderItem(ProductId productId, int quantity, decimal unitPrice)
    {
        ProductId = productId;
        Quantity = quantity;
        UnitPrice = unitPrice;
    }

    public void IncreaseQuantity(int additionalQuantity)
    {
        if (additionalQuantity <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(additionalQuantity), "Additional quantity must be positive.");
        }
        Quantity += additionalQuantity;
    }
}

public enum OrderStatus
{
    Pending,
    Confirmed,
    Shipped,
    Delivered,
    Cancelled
}

// Value Objects
public record CustomerId(Guid Value);
public record ProductId(Guid Value);

DDD helps untangle complexity by creating a ubiquitous language, a shared vocabulary between domain experts and developers, and by enforcing clear boundaries between different parts of your system. The core idea is to model your software around the business domain, not around technical concerns like databases or UI frameworks. This leads to systems that are more adaptable to changing business needs, easier to understand, and less prone to bugs in complex areas.

The Order class above is an example of an Aggregate Root. An aggregate is a cluster of domain objects (entities and value objects) that can be treated as a single unit. The Order is the root; you can’t modify an OrderItem directly without going through an Order instance. This ensures that the invariants (business rules that must always hold true) of the aggregate are maintained. For instance, you can’t add an item to an order that’s already been confirmed or shipped, as enforced by the AddItem and Confirm methods.

Bounded Contexts are crucial. Imagine your e-commerce system has a "Sales" context and a "Shipping" context. The concept of an "Order" might exist in both, but their responsibilities and even their attributes could differ. "Sales" cares about order items, pricing, and customer details. "Shipping" cares about package dimensions, carrier information, and delivery addresses. DDD encourages defining clear boundaries between these contexts, often using separate code projects or even microservices. This prevents a single, monolithic "Order" entity from becoming a dumping ground for unrelated logic.

A key pattern is the Repository. Repositories abstract the data access layer, providing a collection-like interface for retrieving and persisting aggregates. Instead of _context.Orders.Add(order), you’d have _orderRepository.Add(order). This decouples your domain logic from the specific database technology.

// Example Repository Interface
public interface IOrderRepository
{
    Task<Order> GetByIdAsync(OrderId id);
    Task AddAsync(Order order);
    Task UpdateAsync(Order order);
    // ... other methods
}

// Example Implementation (using EF Core)
public class OrderRepository : IOrderRepository
{
    private readonly MyDbContext _context;

    public OrderRepository(MyDbContext context)
    {
        _context = context;
    }

    public async Task<Order> GetByIdAsync(OrderId id)
    {
        // Consider using a query object or specification pattern here for complex filtering
        return await _context.Orders.FindAsync(id.Value);
    }

    public async Task AddAsync(Order order)
    {
        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
    }

    public async Task UpdateAsync(Order order)
    {
        _context.Entry(order).State = EntityState.Modified;
        await _context.SaveChangesAsync();
    }
}

Value Objects are immutable objects that represent descriptive aspects of the domain, identified by their attributes, not their identity. CustomerId and ProductId are value objects. If two CustomerId objects have the same Guid value, they are considered equal. They are ideal for representing concepts like money, addresses, or dates where the value itself is what matters. They simplify equality checks and enforce immutability, preventing accidental state changes.

The most surprising truth about DDD is that your domain model should not be a direct reflection of your database schema. In fact, a well-designed domain model might dictate how your database is structured, rather than the other way around. This is because the domain model represents business concepts and rules, which are often more complex and nuanced than simple table structures. For example, a complex pricing calculation involving multiple discounts might be a single method call on an Order aggregate, abstracting away the database queries needed to fetch product prices and customer discount levels.

The next concept to explore is how to effectively manage Domain Events and integrate them with Background Processing for tasks like sending email notifications upon order confirmation.

Want structured learning?

Take the full Csharp course →