Domain-Driven Design (DDD) with C# and .NET: A Comprehensive Guide

Domain-Driven Design (DDD) with C# and .NET: A Comprehensive Guide

Domain-Driven Design (DDD) is a powerful approach to building complex software systems by focusing on the core domain and its logic. It emphasizes collaboration between technical teams and domain experts to create a shared understanding of the problem space. In this blog, we’ll explore how to apply DDD principles in a C# and .NET environment, with practical examples.

We’ll cover:

  1. Identifying Bounded Contexts

  2. Defining Aggregate Roots, Entities, and Value Objects

  3. Incorporating Value Objects with Entity Framework (EF) Code First

  4. Domain Events

  5. Examples of Domain Modeling

Let’s dive in!

1. Identifying Bounded Contexts

A Bounded Context is a boundary within which a particular domain model is defined and applicable. It’s a way to divide a large system into smaller, more manageable parts. Each bounded context has its own ubiquitous language, which is a shared vocabulary between developers and domain experts.

Example: E-Commerce System

Imagine you’re building an e-commerce system. You might identify the following bounded contexts:

  • Order Management: Handles order creation, payment, and fulfillment.

  • Inventory Management: Tracks product stock levels.

  • Customer Management: Manages customer profiles and preferences.

Each of these contexts has its own rules, models, and language. For example, in the Order Management context, an "Order" might have a status like "Pending" or "Shipped," while in the Inventory Management context, a "Product" might have a "Stock Level."

2. Defining Aggregate Roots, Entities, and Value Objects

Aggregate Root

An Aggregate Root is the main entity that controls access to a group of related objects (entities and value objects). It ensures consistency and enforces business rules within the aggregate.

Entity

An Entity is an object with a unique identity. It has attributes that can change over time, but its identity remains the same.

Value Object

A Value Object is an immutable object with no conceptual identity. It’s defined by its attributes and is used to describe characteristics of an entity.

Example: Order Management Context

Let’s model an Order in the Order Management context.

public class Order : IAggregateRoot
{
    public Guid Id { get; private set; } // Unique identity (Entity)
    public DateTime OrderDate { get; private set; }
    public Address ShippingAddress { get; private set; } // Value Object
    private readonly List<OrderItem> _orderItems = new();
    public IReadOnlyCollection<OrderItem> OrderItems => _orderItems.AsReadOnly();

    public Order(Guid id, DateTime orderDate, Address shippingAddress)
    {
        Id = id;
        OrderDate = orderDate;
        ShippingAddress = shippingAddress;
    }

    public void AddOrderItem(Guid productId, int quantity, decimal price)
    {
        // Business rule: Cannot add an item with zero or negative quantity
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be greater than zero.");

        var orderItem = new OrderItem(productId, quantity, price);
        _orderItems.Add(orderItem);
    }

    public void RemoveOrderItem(Guid productId)
    {
        var item = _orderItems.FirstOrDefault(i => i.ProductId == productId);
        if (item != null)
            _orderItems.Remove(item);
    }
}

public class OrderItem : Entity
{
    public Guid ProductId { get; private set; }
    public int Quantity { get; private set; }
    public decimal Price { get; private set; }

    public OrderItem(Guid productId, int quantity, decimal price)
    {
        ProductId = productId;
        Quantity = quantity;
        Price = price;
    }
}

public class Address : ValueObject
{
    public string Street { get; private set; }
    public string City { get; private set; }
    public string ZipCode { get; private set; }

    public Address(string street, string city, string zipCode)
    {
        Street = street;
        City = city;
        ZipCode = zipCode;
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Street;
        yield return City;
        yield return ZipCode;
    }
}

In this example:

  • Order is the Aggregate Root.

  • OrderItem is an Entity.

  • Address is a Value Object.

3. Incorporating Value Objects with EF Code First

Entity Framework (EF) Code First allows us to map Value Objects to database tables. Since Value Objects are immutable and have no identity, they are typically stored as part of an Entity.

Example: Mapping Address Value Object

To store the Address Value Object in the database, we can use EF’s Owned Entity feature.

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);
        builder.OwnsOne(o => o.ShippingAddress, address =>
        {
            address.Property(a => a.Street).HasMaxLength(100);
            address.Property(a => a.City).HasMaxLength(50);
            address.Property(a => a.ZipCode).HasMaxLength(10);
        });
        builder.HasMany(o => o.OrderItems).WithOne().HasForeignKey("OrderId");
    }
}

In this configuration:

  • The ShippingAddress Value Object is stored as columns in the Order table.

  • EF treats Address as an owned entity, meaning it doesn’t have its own table.

4. Domain Events

A Domain Event is an event that represents something meaningful that happened in the domain. It’s used to decouple different parts of the system and enable eventual consistency.

Example: Order Placed Event

When an order is placed, we might raise an OrderPlaced event.

public class OrderPlaced : IDomainEvent
{
    public Guid OrderId { get; }
    public DateTime OrderDate { get; }
    public decimal TotalAmount { get; }

    public OrderPlaced(Guid orderId, DateTime orderDate, decimal totalAmount)
    {
        OrderId = orderId;
        OrderDate = orderDate;
        TotalAmount = totalAmount;
    }
}

public class Order : IAggregateRoot
{
    // Other properties and methods...

    public void PlaceOrder()
    {
        // Business logic to place the order...
        var orderPlacedEvent = new OrderPlaced(Id, OrderDate, CalculateTotal());
        AddDomainEvent(orderPlacedEvent);
    }

    private decimal CalculateTotal()
    {
        return _orderItems.Sum(item => item.Price * item.Quantity);
    }
}

The OrderPlaced event can be handled by other parts of the system, such as sending a confirmation email or updating inventory.

5. Examples of Domain Modeling

Example 1: Order Management System

In the Order Management context, an Order is the Aggregate Root. It controls access to related objects like OrderItems (entities) and Address (value object).

Domain Model:

public class Order : IAggregateRoot
{
    public Guid Id { get; private set; } // Unique identity (Entity)
    public DateTime OrderDate { get; private set; }
    public Address ShippingAddress { get; private set; } // Value Object
    private readonly List<OrderItem> _orderItems = new();
    public IReadOnlyCollection<OrderItem> OrderItems => _orderItems.AsReadOnly();

    public Order(Guid id, DateTime orderDate, Address shippingAddress)
    {
        Id = id;
        OrderDate = orderDate;
        ShippingAddress = shippingAddress;
    }

    public void AddOrderItem(Guid productId, int quantity, decimal price)
    {
        // Business rule: Cannot add an item with zero or negative quantity
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be greater than zero.");

        var orderItem = new OrderItem(productId, quantity, price);
        _orderItems.Add(orderItem);
    }

    public void RemoveOrderItem(Guid productId)
    {
        var item = _orderItems.FirstOrDefault(i => i.ProductId == productId);
        if (item != null)
            _orderItems.Remove(item);
    }
}

public class OrderItem : Entity
{
    public Guid ProductId { get; private set; }
    public int Quantity { get; private set; }
    public decimal Price { get; private set; }

    public OrderItem(Guid productId, int quantity, decimal price)
    {
        ProductId = productId;
        Quantity = quantity;
        Price = price;
    }
}

public class Address : ValueObject
{
    public string Street { get; private set; }
    public string City { get; private set; }
    public string ZipCode { get; private set; }

    public Address(string street, string city, string zipCode)
    {
        Street = street;
        City = city;
        ZipCode = zipCode;
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Street;
        yield return City;
        yield return ZipCode;
    }
}

Why is Order the Aggregate Root?

  • Single Entry Point: All interactions with OrderItems and Address go through the Order class.

  • Consistency: The Order class ensures that business rules (e.g., quantity must be greater than zero) are enforced.

  • Encapsulation: The internal list of OrderItems is hidden from the outside world. External code can only interact with it through the Order class.

Example 2: Inventory Management System

In the Inventory Management context, a Product might be the Aggregate Root. It controls access to related objects like StockLevel (value object) and Supplier (entity).

Domain Model:

public class Product : IAggregateRoot
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
    public StockLevel Stock { get; private set; } // Value Object
    private readonly List<Supplier> _suppliers = new();
    public IReadOnlyCollection<Supplier> Suppliers => _suppliers.AsReadOnly();

    public Product(Guid id, string name, StockLevel stock)
    {
        Id = id;
        Name = name;
        Stock = stock;
    }

    public void AddSupplier(Supplier supplier)
    {
        // Business rule: A supplier cannot be added more than once
        if (_suppliers.Any(s => s.Id == supplier.Id))
            throw new InvalidOperationException("Supplier already exists.");

        _suppliers.Add(supplier);
    }

    public void UpdateStock(int newQuantity)
    {
        // Business rule: Stock cannot be negative
        if (newQuantity < 0)
            throw new ArgumentException("Stock level cannot be negative.");

        Stock = new StockLevel(newQuantity);
    }
}

public class Supplier : Entity
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }

    public Supplier(Guid id, string name)
    {
        Id = id;
        Name = name;
    }
}

public class StockLevel : ValueObject
{
    public int Quantity { get; private set; }

    public StockLevel(int quantity)
    {
        Quantity = quantity;
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Quantity;
    }
}

Why is Product the Aggregate Root?

  • Single Entry Point: All interactions with StockLevel and Suppliers go through the Product class.

  • Consistency: The Product class ensures that business rules (e.g., stock cannot be negative) are enforced.

  • Encapsulation: The internal list of Suppliers is hidden from the outside world.

Why Aggregate Roots Are Essential in DDD

  1. Maintaining Consistency: Without an Aggregate Root, it’s easy for different parts of the system to modify related objects independently, leading to inconsistencies. The Aggregate Root ensures that all changes are coordinated and valid.

  2. Enforcing Business Rules: The Aggregate Root encapsulates the logic for enforcing business rules, ensuring that they are applied consistently across the system.

  3. Simplifying Complex Models: By grouping related objects into Aggregates, the system becomes easier to understand and maintain. Each Aggregate represents a clear boundary within the domain.

  4. Transactional Integrity: Changes to an Aggregate are treated as a single transaction. This ensures that the system remains in a valid state even in the event of failures.

Domain-Driven Design is a powerful approach for building complex systems that align closely with business needs. By identifying bounded contexts, defining aggregate roots, entities, and value objects, and using domain events, you can create a robust and maintainable system.

In this blog, we’ve explored how to apply DDD principles in C# and .NET, with practical examples using Entity Framework Code First. Whether you’re building an e-commerce system or any other domain-rich application, DDD can help you create a clear and effective model.

Happy coding! 🚀