Domain-Driven Design in .NET Applications

Domain-Driven Design in .NET Applications

January 22, 2024
Admin User

A practical guide to implementing Domain-Driven Design principles in .NET applications for more maintainable and business-aligned code.

Domain-Driven Design in .NET Applications

As a Technical Lead working extensively with .NET, I've found Domain-Driven Design (DDD) to be one of the most valuable approaches for managing complexity in enterprise applications. In this post, I'll share practical insights on implementing DDD within the .NET ecosystem.

Why Domain-Driven Design Matters

Before diving into implementation details, let's consider why DDD is worth the investment:

  1. Alignment with business: DDD creates a shared language between technical and business teams
  2. Managing complexity: It provides patterns for breaking down complex domains
  3. Maintainability: Well-designed domain models make code easier to change
  4. Strategic design: It offers tools for large-scale system organization

Core DDD Concepts in .NET

The Domain Model

In .NET, your domain model typically consists of classes that represent your business concepts. Unlike anemic data models, DDD domain models encapsulate both data and behavior:

// A rich domain model with encapsulated behavior
public class Order
{
    private readonly List<OrderLine> _orderLines = new();

    public Guid Id { get; private set; }
    public Customer Customer { get; private set; }
    public OrderStatus Status { get; private set; }
    public IReadOnlyCollection<OrderLine> OrderLines => _orderLines.AsReadOnly();

    private Order() { } // For ORM

    public Order(Customer customer)
    {
        Id = Guid.NewGuid();
        Customer = customer;
        Status = OrderStatus.Created;
    }

    public void AddProduct(Product product, int quantity)
    {
        if (Status != OrderStatus.Created)
            throw new InvalidOperationException("Cannot add products to a confirmed order");

        var existingLine = _orderLines.FirstOrDefault(l => l.ProductId == product.Id);

        if (existingLine != null)
            existingLine.IncreaseQuantity(quantity);
        else
            _orderLines.Add(new OrderLine(this.Id, product, quantity));
    }

    public void Confirm()
    {
        if (!_orderLines.Any())
            throw new InvalidOperationException("Cannot confirm an empty order");

        Status = OrderStatus.Confirmed;
    }

    // Other business methods...
}

Value Objects

Value objects represent concepts that are defined by their attributes rather than an identity:

public record Address(string Street, string City, string State, string PostalCode, string Country)
{
    public static Address Create(string street, string city, string state, string postalCode, string country)
    {
        // Validation logic here
        return new Address(street, city, state, postalCode, country);
    }
}

With C# 9.0's record types, implementing value objects is much easier than before.

Aggregates and Repositories

Aggregates define consistency boundaries. In EF Core, we can implement them as follows:

// Repository focused on aggregate roots
public class OrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;

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

    public async Task<Order> GetByIdAsync(Guid id)
    {
        return await _context.Orders
            .Include(o => o.Customer)
            .Include(o => o.OrderLines)
            .FirstOrDefaultAsync(o => o.Id == id);
    }

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

    public void Update(Order order)
    {
        _context.Orders.Update(order);
    }
}

Practical DDD Patterns in .NET

Entity Framework Core Configuration

With EF Core, we can configure our domain model while keeping it persistence-ignorant:

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);

        builder.Property(o => o.Status)
            .HasConversion<string>();

        builder.HasMany(o => o.OrderLines)
            .WithOne()
            .HasForeignKey("OrderId");

        builder.Metadata.FindNavigation(nameof(Order.OrderLines))
            .SetPropertyAccessMode(PropertyAccessMode.Field);
    }
}

Domain Events

Domain events allow for decoupling actions that happen as a result of domain changes:

public abstract class DomainEvent
{
    public DateTime OccurredOn { get; } = DateTime.UtcNow;
}

public class OrderConfirmedEvent : DomainEvent
{
    public Guid OrderId { get; }

    public OrderConfirmedEvent(Guid orderId)
    {
        OrderId = orderId;
    }
}

// In the Order aggregate:
public void Confirm()
{
    if (!_orderLines.Any())
        throw new InvalidOperationException("Cannot confirm an empty order");

    Status = OrderStatus.Confirmed;

    AddDomainEvent(new OrderConfirmedEvent(Id));
}

Dependency Injection and Mediator

.NET's built-in DI container works beautifully with DDD patterns:

// In Program.cs or Startup.cs
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<ICustomerRepository, CustomerRepository>();
services.AddScoped<IProductRepository, ProductRepository>();
services.AddScoped<IUnitOfWork, EfUnitOfWork>();

// Consider using MediatR for command/query separation
services.AddMediatR(typeof(Program).Assembly);

Real-World Lessons

After implementing DDD in several large .NET applications, here are key lessons:

  1. Start simple: Don't try to implement every DDD pattern at once
  2. Focus on boundaries: Getting bounded contexts right is more important than perfect domain models
  3. Iterative refinement: Your understanding of the domain will evolve
  4. Test behavior: Unit test the behavior, not the structure
  5. Shared language: Invest time in developing the ubiquitous language

Conclusion

Domain-Driven Design in .NET provides a powerful approach to managing complex business logic. While it requires investment in learning and careful design, the long-term benefits for maintainability and business alignment are substantial.

In my experience, DDD is particularly valuable for complex .NET applications where the business logic is expected to evolve over time. By focusing on the domain model rather than technical concerns, we build more adaptable and understandable systems.

What has your experience been with DDD in .NET? Share your thoughts in the comments below.

Tags:

Related Posts

Umbraco CMS Development Best Practices

Umbraco CMS Development Best Practices

over 1 year ago

A comprehensive guide to Umbraco CMS development best practices, covering archit...

DevOps CI/CD Pipeline Optimization

DevOps CI/CD Pipeline Optimization

over 1 year ago

Strategies and techniques for optimizing your CI/CD pipelines to improve develop...

Practical Machine Learning for .NET Developers

Practical Machine Learning for .NET Developers

over 1 year ago

A hands-on guide to implementing machine learning in .NET applications without b...