Domain-Driven Design in Practice: From Theory to Implementation
Practical guidance on implementing Domain-Driven Design patterns in modern .NET applications, with real-world examples and lessons learned.
Domain-Driven Design (DDD) is often misunderstood as overly academic or only suitable for complex enterprise systems. In reality, DDD provides practical patterns and principles that help teams build software that truly models the business domain and evolves with changing requirements.
This guide covers tactical DDD patterns, strategic design, and real-world implementation strategies. We'll explore entities, aggregates, domain events, bounded contexts, and common pitfalls to avoid.
Why DDD Matters
The core insight of DDD is that software should reflect the business domain it serves. When your code structure mirrors business concepts, communication improves, maintenance becomes easier, and new features align naturally with existing patterns.
The heart of software is its ability to solve domain-related problems for its users. When code speaks the language of the business, everything becomes clearer.
Core Building Blocks
1. Entities and Value Objects
Entities have identity that persists over time, while value objects are defined by their attributes. This distinction is crucial for modeling your domain correctly.
- Entity
- Has a unique identity that persists over time. Two entities with the same attributes are still different if they have different IDs. Example: Order, Customer, Product.
- Value Object
- Defined entirely by its attributes. Two value objects with identical attributes are considered equal. Always immutable. Example: Money, Address, DateRange.
Entity vs Value Object Implementation
// Entity: Has identity, mutable
public class Order
{
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
public Money TotalAmount { get; private set; }
public void Confirm()
{
if (Status != OrderStatus.Pending)
throw new InvalidOperationException("Only pending orders can be confirmed");
Status = OrderStatus.Confirmed;
}
}
// Value Object: Immutable, defined by attributes
public record Money(decimal Amount, Currency Currency)
{
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies");
return new Money(Amount + other.Amount, Currency);
}
} 2. Aggregates and Aggregate Roots
Aggregates enforce consistency boundaries. All changes to entities within an aggregate go through the aggregate root, ensuring business rules are never violated.
Large aggregates lead to performance issues and concurrency problems. If an aggregate grows too large, it's often a sign you need to split it into multiple bounded contexts or use eventual consistency between aggregates.
Aggregate Pattern with Consistency Boundary
public class Order // Aggregate Root
{
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public void AddLine(Product product, int quantity)
{
// Business rule enforced at aggregate boundary
if (Status != OrderStatus.Pending)
throw new InvalidOperationException("Cannot modify confirmed order");
var existingLine = _lines.FirstOrDefault(l => l.ProductId == product.Id);
if (existingLine != null)
{
existingLine.IncreaseQuantity(quantity);
}
else
{
_lines.Add(new OrderLine(product.Id, quantity, product.Price));
}
RecalculateTotal();
}
private void RecalculateTotal()
{
TotalAmount = _lines
.Select(l => l.LineTotal)
.Aggregate(Money.Zero, (acc, total) => acc.Add(total));
}
}
public class OrderLine // Part of Order aggregate, not accessible directly
{
internal Guid ProductId { get; private set; }
internal int Quantity { get; private set; }
internal Money UnitPrice { get; private set; }
internal Money LineTotal => new Money(UnitPrice.Amount * Quantity, UnitPrice.Currency);
internal void IncreaseQuantity(int amount)
{
Quantity += amount;
}
} 3. Domain Services
When an operation doesn't naturally belong to a single entity, domain services coordinate behavior across multiple aggregates.
Domain Service Example
public class PricingService
{
private readonly IDiscountPolicy _discountPolicy;
public Money CalculateOrderTotal(Order order, Customer customer)
{
var baseTotal = order.TotalAmount;
var discount = _discountPolicy.CalculateDiscount(customer, baseTotal);
return baseTotal.Subtract(discount);
}
} Strategic Design Patterns
Bounded Contexts
Different parts of your system have different models of the same concepts. A "Customer" in Sales is different from a "Customer" in Billing. Bounded contexts make these differences explicit.
- Sales Context - Customer has purchase history, preferences, shopping cart
- Billing Context - Customer has payment methods, invoices, credit limit
- Shipping Context - Customer has delivery addresses, shipping preferences
Make implicit concepts explicit and create a ubiquitous language for your domain.
Context Mapping
Define how bounded contexts integrate. Common patterns include:
- Customer/Supplier
- Downstream context depends on upstream context. The upstream team must consider downstream needs.
- Conformist
- Downstream accepts the upstream model completely, even if it's not ideal for their needs.
- Anti-Corruption Layer
- Translate between contexts to protect the domain model from external influences and legacy systems.
- Published Language
- Shared integration schema for events, contracts, or APIs that multiple contexts consume.
Anti-Corruption Layer Pattern
// Anti-Corruption Layer example
public class LegacyOrderAdapter
{
public async Task<Order> ConvertToModernOrder(LegacyOrder legacyOrder)
{
// Translate legacy model to clean domain model
var order = Order.Create(
customerId: new CustomerId(legacyOrder.CustNo),
orderDate: DateTime.Parse(legacyOrder.OrdDt)
);
foreach (var legacyItem in legacyOrder.Items)
{
var product = await _productRepository.GetBySku(legacyItem.PartNum);
order.AddLine(product, legacyItem.Qty);
}
return order;
}
} Use anti-corruption layers to shield your clean domain model from messy external systems. This prevents technical debt from spreading across context boundaries.
Practical Implementation Patterns
Repository Pattern
Repositories provide collection-like interfaces for aggregate roots, hiding persistence details.
Repository Pattern Implementation
public interface IOrderRepository
{
Task<Order?> GetById(Guid orderId);
Task<List<Order>> GetByCustomer(Guid customerId);
Task Add(Order order);
Task Update(Order order);
}
// Implementation uses EF Core but interface is pure domain
public class OrderRepository : IOrderRepository
{
private readonly OrdersDbContext _context;
public async Task<Order?> GetById(Guid orderId)
{
return await _context.Orders
.Include(o => o.Lines) // Load full aggregate
.FirstOrDefaultAsync(o => o.Id == orderId);
}
} Domain Events
Capture important business events as first-class domain concepts. This enables reactive behavior and helps maintain consistency across aggregates.
Domain Events Pattern
public record OrderConfirmedEvent(
Guid OrderId,
Guid CustomerId,
Money TotalAmount,
DateTime ConfirmedAt
) : IDomainEvent;
// In Order aggregate
public class Order
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
public void Confirm()
{
// Business logic
Status = OrderStatus.Confirmed;
ConfirmedAt = DateTime.UtcNow;
// Record domain event
_domainEvents.Add(new OrderConfirmedEvent(
OrderId: Id,
CustomerId: CustomerId,
TotalAmount: TotalAmount,
ConfirmedAt: ConfirmedAt.Value
));
}
} Domain events enable loose coupling between aggregates, make implicit business processes explicit, and provide a natural audit trail of what happened in your system.
Common Pitfalls and Solutions
1. Anemic Domain Model
Don't create entities that are just data bags with getters and setters. Put business logic where it belongs - in the domain model.
Anemic Domain Model
// Entities are just data bags
public class Order
{
public decimal Total { get; set; }
public string Status { get; set; }
}
// Business logic in service layer
public class OrderService
{
public void ConfirmOrder(Order order)
{
if (order.Status == "Pending")
order.Status = "Confirmed";
}
} Rich Domain Model
// Entities encapsulate behavior
public class Order
{
public Money Total { get; private set; }
public OrderStatus Status { get; private set; }
public void Confirm()
{
if (Status != OrderStatus.Pending)
throw new InvalidOperationException();
Status = OrderStatus.Confirmed;
RaiseDomainEvent(new OrderConfirmedEvent(Id));
}
} 2. Large Aggregates
Keep aggregates small. Large aggregates lead to performance issues and concurrency problems. If an aggregate grows too large, it's often a sign that you need to split it.
As a rule of thumb, if your aggregate has more than 3-4 child entities, or if you're frequently experiencing concurrency conflicts, consider splitting it or using eventual consistency.
3. Missing Ubiquitous Language
The code should use the same terms that domain experts use. If your database has a "usr_acct" table but business talks about "customers", you have a translation problem.
- Work closely with domain experts to discover the language
- Use domain terms in code, database, and discussions
- Create a glossary of domain terms and their meanings
- Refactor code when you discover better terminology
Real-World DDD: E-Commerce Example
In building an e-commerce platform, we identified these bounded contexts:
- Catalog - Product information, categories, pricing rules
- Shopping - Carts, wishlists, product recommendations
- Ordering - Order placement, payment processing, fulfillment
- Inventory - Stock levels, reservations, warehouse management
- Shipping - Delivery methods, tracking, address validation
Each context had its own model. A "Product" in Catalog had rich descriptions and images. In Inventory, it was just SKU and quantity. In Ordering, it was SKU, price, and name. This separation prevented a single bloated Product entity.
Different contexts need different models. Don't force a single model across your entire system.
Getting Started with DDD
Start small. You don't need to apply every DDD pattern to every project:
- Identify your core domain - the area that provides competitive advantage
- Model that core domain carefully using DDD tactical patterns
- Use simpler approaches (CRUD, transaction script) for supporting domains
- Collaborate with domain experts to discover ubiquitous language
- Iterate - your domain model will evolve as you learn
Apply DDD to your core domain where complexity and business value are highest. Use simpler patterns for generic subdomains like user authentication or email notifications.
Key Takeaways
- DDD is about deep understanding of the business domain, not just patterns
- Start with ubiquitous language - shared vocabulary between developers and domain experts
- Keep aggregates small and focused on consistency boundaries
- Use bounded contexts to manage complexity in large systems
- Rich domain models prevent anemic, data-centric designs
- Domain events enable loose coupling and make business processes explicit
Conclusion
DDD is not about using specific patterns or technologies - it's about creating software that reflects deep understanding of the business domain. The patterns are tools, not goals.
Start by talking to domain experts. Build a ubiquitous language. Model the core domain carefully. Use tactical patterns where they add value. Keep it simple where complexity isn't warranted.
The result is software that makes sense to both developers and business stakeholders, that evolves with the business, and captures complexity only where it exists.
Want to Work Together?
I help engineering teams deliver scalable systems through technical leadership, architecture guidance, and hands on mentoring. Let's discuss how I can help your team.