Domain-Driven Design (DDD) helps you model software that reflects the business intent. Instead of scattering rules across controllers and helpers, DDD encourages putting behavior next to the data it governs so the code is easier to read, test, and evolve.
This article covers the essential DDD building blocks in C# - entities, value objects, aggregates, domain events, factories, repositories, and application services - with focused examples and explanations to help you apply them in real projects.
DDD Mindset: Business First, Technology Second
DDD starts with a shift in mindset. Instead of focusing on database tables or CRUD operations, you focus on business concepts and rules. This means using a ubiquitous language-a consistent set of terms used by both developers and business stakeholders.
For example, if the business talks about "activating a customer," your code should have a method called Activate()
, not SetStatus(true)
. This alignment
makes your code easier to understand and maintain.
public class Customer
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public bool IsActive { get; private set; }
public void Activate() => IsActive = true;
public void Deactivate() => IsActive = false;
public void ChangeName(string newName) => Name = newName;
}
This class models a customer with behavior that matches business actions. Instead of setting flags manually, you
express intent through methods like Activate()
and ChangeName()
.
Entities: Objects with Identity and Behavior
Entities are objects that have a unique identity and encapsulate behavior. Two entities are considered equal if their IDs match, regardless of their other properties.
public class Customer
{
public Guid Id { get; private set; }
private string _name;
private bool _isActive;
public Customer(Guid id, string name)
{
Id = id;
_name = name;
_isActive = false;
}
public void Activate()
{
if (_isActive)
throw new InvalidOperationException("Customer is already active.");
_isActive = true;
}
public void ChangeName(string newName)
{
if (string.IsNullOrWhiteSpace(newName))
throw new ArgumentException("Name cannot be empty.");
_name = newName;
}
public override bool Equals(object obj)
{
if (obj is not Customer other) return false;
return Id == other.Id;
}
public override int GetHashCode() => Id.GetHashCode();
}
The constructor sets up identity and initial state. Private backing fields (like
_name
and _isActive
) prevent external code from putting the entity into an invalid
state. The Activate
method guards against invalid transitions by throwing when the customer is
already active. Overriding Equals
and GetHashCode
makes equality based on the
Id
only - two Customer objects with the same ID are considered the same real-world customer, even if
other properties differ.
This entity ensures that business rules are enforced within the class. For example, you can't activate an already active customer or set an empty name. This makes your code more robust and easier to test.
Value Objects: Immutable Business Values
Value objects are defined by their values, not identity. They are immutable and self-validating, making them ideal for representing things like email addresses, money, or addresses.
public class EmailAddress
{
public string Value { get; }
public EmailAddress(string value)
{
if (string.IsNullOrWhiteSpace(value) || !value.Contains("@"))
throw new ArgumentException("Invalid email address.");
Value = value;
}
public override bool Equals(object obj)
{
if (obj is not EmailAddress other) return false;
return Value == other.Value;
}
public override int GetHashCode() => Value.GetHashCode();
}
This class ensures that only valid email addresses are created. Because it's immutable, you can safely use it in comparisons and collections without worrying about unexpected changes.
Aggregates: Consistency Boundaries
Aggregates are clusters of entities and value objects that form a consistency boundary. The aggregate root is the only entry point for external code.
public class Order
{
public Guid Id { get; private set; }
private List<OrderItem> _items = new();
public decimal TotalAmount => _items.Sum(i => i.Total);
public void AddItem(string productName, int quantity, decimal price)
{
if (quantity <= 0) throw new ArgumentException("Quantity must be positive.");
var item = new OrderItem(productName, quantity, price);
_items.Add(item);
}
public void RemoveItem(Guid itemId)
{
_items.RemoveAll(i => i.Id == itemId);
}
}
public class OrderItem
{
public Guid Id { get; private set; }
public string ProductName { get; }
public int Quantity { get; }
public decimal Price { get; }
public decimal Total => Quantity * Price;
public OrderItem(string productName, int quantity, decimal price)
{
Id = Guid.NewGuid();
ProductName = productName;
Quantity = quantity;
Price = price;
}
}
The Order
class exposes only the behaviors needed by the rest of the
system (adding and removing items). The internal list _items
is encapsulated so callers can't modify
it
directly. TotalAmount
is computed on demand to avoid duplication of state. The AddItem
method enforces a simple invariant (quantity must be positive) so the aggregate never contains invalid items.
Domain Events: Decoupling Business Logic
Domain events signal that something happened in the domain. They help decouple side effects from core business logic.
public class OrderCreatedEvent
{
public Guid OrderId { get; }
public DateTime CreatedAt { get; }
public OrderCreatedEvent(Guid orderId)
{
OrderId = orderId;
CreatedAt = DateTime.UtcNow;
}
}
public class OrderCreatedHandler
{
public void Handle(OrderCreatedEvent evt)
{
// Send email, update inventory, etc.
}
}
OrderCreatedEvent
is a simple DTO that communicates a fact: an order
was
created. Handlers like OrderCreatedHandler
react to that fact (send email, update inventory). This
decouples side effects from the domain logic and makes adding new reactions safe without touching the aggregate.
Factories: Ensuring Valid Aggregate Creation
Factories encapsulate complex creation logic and ensure aggregates start in a valid state.
public static class OrderFactory
{
public static Order CreateNew(List<(string product, int qty, decimal price)> items)
{
var order = new Order();
foreach (var (product, qty, price) in items)
{
order.AddItem(product, qty, price);
}
return order;
}
}
The factory encapsulates creation steps that would otherwise leak into callers. By centralizing creation, you ensure every Order is constructed in a valid state and avoid code duplication across the codebase.
Repositories: Abstracting Data Access
Repositories abstract away data access, allowing your domain logic to remain clean and focused.
public interface IOrderRepository
{
Order GetById(Guid id);
void Save(Order order);
}
public class OrderRepository : IOrderRepository
{
private readonly DbContext _context;
public Order GetById(Guid id) =>
_context.Orders.Include(o => o.Items).FirstOrDefault(o => o.Id == id);
public void Save(Order order)
{
_context.Orders.Update(order);
_context.SaveChanges();
}
}
The IOrderRepository
interface provides a persistence contract for the
domain. The concrete OrderRepository
uses EF Core (a DbContext) but callers work against the
interface so persistence technology can be swapped. Note the repository returns domain objects with their internal
state (e.g., order items) already hydrated, keeping persistence details out of domain logic.
Application Services: Coordinating Use Cases
Application services coordinate use cases and handle cross-cutting concerns like transactions and logging.
public class OrderApplicationService
{
private readonly IOrderRepository _repo;
public void CreateOrder(List<(string product, int qty, decimal price)> items)
{
var order = OrderFactory.CreateNew(items);
_repo.Save(order);
}
public void ConfirmOrder(Guid orderId)
{
var order = _repo.GetById(orderId);
// order.Confirm(); // domain logic
_repo.Save(order);
}
}
Application services orchestrate use cases and handle cross-cutting concerns (transactions, logging). They don't contain business rules - instead they call factories, repositories, and aggregates. This keeps domain code focused on behavior, while application services coordinate flows.
Testing Rich Domain Models
DDD makes testing easier because business logic is encapsulated in domain classes. You can write meaningful unit tests without mocks or infrastructure.
[Fact]
public void AddItem_ShouldIncreaseTotal()
{
var order = new Order();
order.AddItem("Book", 2, 10m);
Assert.Equal(20m, order.TotalAmount);
}
[Fact]
public void EmailAddress_ShouldThrowOnInvalid()
{
Assert.Throws<ArgumentException>(() => new EmailAddress("invalid"));
}
The first unit test exercises domain behavior (adding items) and asserts the computed total. The second test asserts that creating an invalid EmailAddress throws an exception. Because the business logic lives in the domain types, tests are fast and don't require database access or complex setup.
Advanced Concepts & Best Practices
DDD includes advanced concepts like bounded contexts, context mapping, event sourcing, and CQRS. These help you scale your system and manage complexity.
Bounded contexts separate different parts of the domain, like billing and shipping. Event sourcing stores events instead of state, and CQRS separates reads and writes for better scalability.
Common DDD Mistakes
- Creating anemic domain models (no behavior).
- Making aggregates too large or unfocused.
- Ignoring domain events and coupling logic.
- Putting business rules in application services.
- Skipping collaboration with domain experts.
When to Use DDD (and When Not)
DDD is ideal for complex domains like finance, logistics, and healthcare. For simple CRUD applications, it might be overkill. Start small with entities and value objects, then introduce aggregates and events as needed.
Summary
DDD helps you build software that reflects the business. It puts rules in the domain layer, enforces consistency through aggregates, and decouples logic with events. Factories and repositories support maintainability.
If you're working in C#, DDD is a powerful tool. Use it thoughtfully, collaborate with domain experts, and model the business-not just the data. Your future self (and your team) will thank you.