
Learn Domain-Driven Design in C# with practical tactical patterns—entities, value objects, aggregates, and repositories. Start writing cleaner code today.
Domain-Driven Design in C# is one of the most valuable skills a .NET developer can master when building complex business applications. If your codebase has slowly turned into a tangle of anemic models, service classes thousands of lines long, and business rules scattered across controllers, this tutorial is for you. In this guide we'll walk through the core tactical patterns of domain-driven design in C#—entities, value objects, aggregates, domain events, and repositories—with practical, runnable code examples you can apply today.
Whether you're a beginner searching for a DDD tutorial, an intermediate developer looking for best practices, or a senior engineer architecting a new system, the patterns below will help you write code that reflects your business domain instead of fighting against it.
What Is Domain-Driven Design in C#?
Domain-Driven Design (DDD) is an approach to software development, introduced by Eric Evans, that places the business domain at the center of your design. Instead of starting with database tables or UI screens, you model the actual concepts, rules, and language of the business in code.
DDD is usually split into two halves. Strategic design deals with the big picture: bounded contexts, ubiquitous language, and how subsystems relate. Tactical design—the focus of this article—gives you the building blocks to implement a rich domain model in C#: entities, value objects, aggregates, domain services, domain events, and repositories.
The why matters here. Most enterprise bugs come not from broken algorithms but from invalid states: an order shipped without an address, a balance going negative, an email saved with no "@". Tactical DDD patterns push you to make invalid states unrepresentable, so the compiler and your domain model enforce the rules instead of relying on scattered validation.
Entities vs. Value Objects in C#
The first decision in any domain model is whether a concept is an entity or a value object. This C# entity vs value object distinction is foundational, and getting it right simplifies everything that follows.
Entities Have Identity
An entity is something defined by its identity, not its attributes. A Customer is the same customer even if they change their name and address. Two entities are equal only if they share the same ID.
public abstract class Entity
{
public Guid Id { get; protected set; }
public override bool Equals(object? obj)
{
if (obj is not Entity other) return false;
if (ReferenceEquals(this, other)) return true;
return Id == other.Id;
}
public override int GetHashCode() => Id.GetHashCode();
}
public class Customer : Entity
{
public string Name { get; private set; }
public Customer(Guid id, string name)
{
Id = id;
Name = name;
}
}
Value Objects Are Defined by Their Values
A value object has no identity—it's defined entirely by its attributes and is immutable. Money, a date range, or an email address are classic examples. Two value objects are equal if all their values match. Since C# 9, record types make C# value objects almost effortless because they give you value-based equality for free.
public sealed record Email
{
public string Value { get; }
public Email(string value)
{
if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
throw new ArgumentException("Invalid email address.", nameof(value));
Value = value.Trim().ToLowerInvariant();
}
public override string ToString() => Value;
}
Notice the validation lives inside the constructor. Once you hold an Email, it is guaranteed valid—you never have to check again anywhere else in the codebase. This is the single biggest payoff of value objects: they eliminate defensive validation everywhere downstream.
A richer example is a Money value object that protects against mixing currencies and going negative:
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentException("Amount cannot be negative.");
if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentException("Currency required.");
Amount = 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);
}
}
Aggregates and the Aggregate Root in C#
As your model grows, you need boundaries that keep related objects consistent. That's the job of the aggregate—a cluster of entities and value objects treated as a single unit for data changes. Every aggregate has one entry point: the aggregate root in C#.
The rules are simple but powerful: outside code may only hold a reference to the root, and all changes go through the root. This guarantees the aggregate enforces its own invariants—the business rules that must always be true. For an Order, an invariant might be "the total never exceeds the customer's credit limit" or "a confirmed order cannot be empty."
public class Order : Entity
{
private readonly List<OrderLine> _lines = new();
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public OrderStatus Status { get; private set; }
public Order(Guid id)
{
Id = id;
Status = OrderStatus.Draft;
}
public void AddLine(Guid productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot modify a confirmed order.");
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive.");
_lines.Add(new OrderLine(productId, quantity, unitPrice));
}
public void Confirm()
{
if (!_lines.Any())
throw new InvalidOperationException("Cannot confirm an empty order.");
Status = OrderStatus.Confirmed;
}
}
public class OrderLine : Entity
{
public Guid ProductId { get; }
public int Quantity { get; }
public Money UnitPrice { get; }
public OrderLine(Guid productId, int quantity, Money unitPrice)
{
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
}
public enum OrderStatus { Draft, Confirmed, Shipped }
The key detail is the private readonly List<OrderLine> exposed only as a read-only collection. Outside code cannot add lines directly with order.Lines.Add(...); it must call AddLine, which enforces the rules. This encapsulation is what separates a real domain model from an anemic one.
Keep Aggregates Small
A common pitfall is making aggregates too large—pulling the customer, products, and payments all into the Order aggregate. The best practice is to reference other aggregates by ID, not by object reference. Notice OrderLine stores a ProductId, not a full Product. Small aggregates reduce locking contention, keep transactions fast, and make your boundaries explicit.
Domain Events: Capturing What Happened
Domain events let your model announce that something meaningful occurred—"OrderConfirmed", "PaymentReceived"—without coupling the aggregate to whatever needs to react. This keeps side effects (sending email, updating read models) out of your core domain logic.
public interface IDomainEvent { }
public sealed record OrderConfirmed(Guid OrderId) : IDomainEvent;
public abstract class AggregateRoot : Entity
{
private readonly List<IDomainEvent> _events = new();
public IReadOnlyCollection<IDomainEvent> DomainEvents => _events.AsReadOnly();
protected void Raise(IDomainEvent domainEvent) => _events.Add(domainEvent);
public void ClearEvents() => _events.Clear();
}
Inside Order.Confirm() you would call Raise(new OrderConfirmed(Id)). After the aggregate is saved, an infrastructure component dispatches those events to handlers. The why: your domain stays pure and testable, while integrations stay loosely coupled and easy to add or remove.
The Repository Pattern in C#
Aggregates need to be persisted and reloaded, and that's where the repository pattern in C# comes in. A repository provides a collection-like interface for retrieving and storing aggregate roots, hiding the database behind a domain-friendly abstraction. Crucially, you define one repository per aggregate root—never one per table.
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id);
Task AddAsync(Order order);
Task SaveChangesAsync();
}
The interface lives in your domain layer, expressed purely in domain terms. The implementation—using Entity Framework Core, Dapper, or another data access tool—lives in the infrastructure layer. This dependency inversion is a best practice that keeps your domain free of database concerns and makes unit testing trivial: you swap in an in-memory fake repository with no database at all.
public class EfOrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public EfOrderRepository(AppDbContext db) => _db = db;
public async Task<Order?> GetByIdAsync(Guid id) =>
await _db.Orders.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == id);
public async Task AddAsync(Order order) =>
await _db.Orders.AddAsync(order);
public async Task SaveChangesAsync() =>
await _db.SaveChangesAsync();
}
Common Pitfalls in Domain-Driven Design with C#
- The anemic domain model. If your entities are just bags of public getters and setters with all logic in "service" classes, you're not doing DDD—you're doing procedural programming with extra steps. Push behavior into the model.
- Public setters everywhere. Use
private setand expressive methods (order.Confirm()) instead oforder.Status = OrderStatus.Confirmed. State changes should go through intention-revealing methods that enforce invariants. - Leaking infrastructure into the domain. Your entities should not reference EF Core attributes, DTOs, or HTTP concepts. Keep the domain layer free of framework dependencies.
- One giant aggregate. Reference other aggregates by ID and keep transactional boundaries small.
- Applying DDD everywhere. DDD shines in complex domains. For a simple CRUD admin screen, it's overkill. Reserve tactical patterns for the parts of your system with genuine business complexity.
Best Practices for Tactical DDD in C#
- Use C#
recordtypes for value objects to get immutability and value equality for free. - Make constructors validate so that invalid objects can never exist.
- Expose collections as
IReadOnlyCollectionand mutate only through methods. - Define repository interfaces in the domain layer, implementations in infrastructure.
- Speak the ubiquitous language—name your classes and methods exactly as the business does.
- Write unit tests against the domain model directly; rich models are highly testable without a database.
Conclusion: Key Takeaways
Mastering domain-driven design in C# transforms how you build complex software. Instead of validation logic scattered across controllers and services, your domain model itself guarantees correctness. Let's recap the tactical patterns:
- Value objects make invalid data unrepresentable through constructor validation and immutability.
- Entities model concepts with identity and encapsulate their own behavior.
- Aggregates and the aggregate root in C# define consistency boundaries and protect invariants.
- Domain events decouple side effects from core business logic.
- The repository pattern in C# abstracts persistence behind a clean, domain-friendly interface.
Start small: pick one part of your application with real business complexity and refactor it using value objects and a proper aggregate root. You'll immediately notice fewer bugs, clearer code, and a model that finally speaks the language of your business. That's the lasting promise of domain-driven design in C#—software that's as maintainable as it is correct.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment