Clean Architecture and DDD in .NET microservices
Most .NET services start as a controller that talks to a DbContext. That is fine until the rules stop fitting in if statements around SaveChanges. At that point you need two things that are often mixed up: a model of the business, and a rule for where infrastructure is allowed to live.
Domain-Driven Design is the model: bounded context, ubiquitous language, entities, value objects, aggregates. Clean Architecture is the dependency rule: the domain does not reference ASP.NET Core, EF Core, or a message bus. Used together, they produce a service that other teams can consume without inheriting your persistence or your HTTP shape.
This study walks through that design in C#, layer by layer, on a checkout bounded context.
Solution structure
Four projects. References only point inward.
src/
Orders.Domain entities, value objects, aggregates, domain services
Orders.Application application services, commands, repository interfaces
Orders.Infrastructure EF Core, repository implementations, integrations
Orders.Api ASP.NET CoreOrders.Domain has no NuGet package for the web stack or the ORM. Orders.Application depends on Domain and on abstractions (interfaces). Orders.Infrastructure implements those interfaces. Orders.Api composes the graph in Program.cs and exposes HTTP.
If Domain starts using DbSet<T>, the architecture is already inverted.
Domain: entity
An entity has identity. Two orders with the same items are still two orders; OrderId is what makes them distinct. The entity holds state and the operations that are allowed to change that state.
Rules live in methods, not in the controller and not in the repository. Status is not a public setter for the API to flip.
public sealed class Order
{
private readonly List<OrderItem> _items = [];
public OrderId Id { get; }
public OrderStatus Status { get; private set; }
public IReadOnlyList<OrderItem> Items => _items;
private Order(OrderId id, IEnumerable<OrderItem> items)
{
Id = id;
_items.AddRange(items);
Status = OrderStatus.Placed;
}
public static Order Place(OrderId id, IReadOnlyList<OrderItem> items)
{
if (items.Count == 0)
throw new InvalidOperationException("An order must contain at least one item.");
return new Order(id, items);
}
public IReadOnlyList<IDomainEvent> ConfirmPayment(PaymentId paymentId)
{
if (Status != OrderStatus.Placed)
throw new InvalidOperationException("Only a placed order can be confirmed.");
Status = OrderStatus.Paid;
return [new OrderPaid(Id, paymentId)];
}
}An entity is not an EF mapped class reused as a JSON contract. Persistence classes and HTTP contracts are separate models. Mapping is Infrastructure and Api work.
Domain: value object
A value object has no identity. Equality is structural. Instances are immutable; an operation returns a new value.
Money, SKU, e-mail and address are the usual cases. Putting them in decimal + string fields on the entity spreads invariants across the codebase.
public readonly record struct Money(decimal Amount, string Currency)
{
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add amounts in different currencies.");
return new Money(Amount + other.Amount, Currency);
}
}Domain: aggregate
The aggregate is the consistency boundary. Order is the root. OrderItem is not loaded, saved, or validated on its own.
EF Core will happily expose every table. That is not the domain. One unit of work commits one aggregate. If two objects must always change together, they belong in the same aggregate. If they belong to different contexts (order vs payment), they integrate through a domain event, not through a shared transaction.
Domain: domain service
Some rules do not sit naturally on one entity: they need two aggregates, or they are a policy with no owner. That is a domain service — still pure domain, still no DbContext.
Pricing that reads catalog policy and the current order is an example. Unique-SKU checks against another aggregate are another.
If the code only changes one aggregate, it belongs on the entity. If the code only loads, saves, and calls the domain, it is an application service.
Application: repository contract
The repository is how the application reconstitutes and persists an aggregate. The contract belongs next to the domain (or in Application, depending on team convention). The implementation does not.
Responsibilities of IOrderRepository:
GetByIdAsync: load the root and its children, return a domainOrder(or null).SaveAsync: persist the aggregate as a unit — inserts, updates and deletes of line items included.
- whether payment may be confirmed (entity);
- HTTP mapping;
IQueryableleaked to the controller;- publishing messages.
IOrderItemRepository.public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken cancellationToken);
Task SaveAsync(Order order, CancellationToken cancellationToken);
}Application: application service
The application service is the use case. It is the only type the API should need for this operation.
Flow:
1. Load the aggregate through IOrderRepository.
2. Call the domain (ConfirmPayment or a domain service).
3. Save through the same repository.
4. Dispatch the domain events that the aggregate recorded.
public sealed class OrderApplicationService
{
private readonly IOrderRepository _orders;
private readonly IDomainEventDispatcher _dispatcher;
public OrderApplicationService(
IOrderRepository orders,
IDomainEventDispatcher dispatcher)
{
_orders = orders;
_dispatcher = dispatcher;
}
public async Task ConfirmPaymentAsync(
Guid orderId,
Guid paymentId,
CancellationToken cancellationToken)
{
var order = await _orders.GetByIdAsync(new OrderId(orderId), cancellationToken)
?? throw new InvalidOperationException($"Order {orderId} was not found.");
var events = order.ConfirmPayment(new PaymentId(paymentId));
await _orders.SaveAsync(order, cancellationToken);
await _dispatcher.DispatchAsync(events, cancellationToken);
}
}This class has no HttpContext, no OrdersDbContext, and no SQL. Replacing EF Core with another store is an Infrastructure change.
Infrastructure: EF Core repository
Infrastructure translates between tables and the aggregate. EF entities can match the tables 1:1. The domain Order does not have to.
public sealed class EfOrderRepository : IOrderRepository
{
private readonly OrdersDbContext _db;
public EfOrderRepository(OrdersDbContext db) => _db = db;
public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken cancellationToken)
{
var row = await _db.Orders
.Include(order => order.Items)
.SingleOrDefaultAsync(order => order.Id == id.Value, cancellationToken);
return row is null ? null : OrderMapper.ToDomain(row);
}
public async Task SaveAsync(Order order, CancellationToken cancellationToken)
{
var row = await _db.Orders
.Include(existing => existing.Items)
.SingleOrDefaultAsync(existing => existing.Id == order.Id.Value, cancellationToken);
if (row is null)
_db.Orders.Add(OrderMapper.ToPersistence(order));
else
OrderMapper.Apply(order, row);
await _db.SaveChangesAsync(cancellationToken);
}
}Register the implementation in Program.cs. Domain and Application never mention OrdersDbContext.
Presentation: ASP.NET Core
The controller binds HTTP and delegates. Status codes and request DTOs stay here. Domain types are not the public contract.
[ApiController]
[Route("orders")]
public sealed class OrdersController : ControllerBase
{
private readonly OrderApplicationService _orders;
public OrdersController(OrderApplicationService orders) => _orders = orders;
[HttpPost("{id:guid}/payments")]
public async Task<IActionResult> ConfirmPayment(
Guid id,
ConfirmPaymentRequest request,
CancellationToken cancellationToken)
{
await _orders.ConfirmPaymentAsync(id, request.PaymentId, cancellationToken);
return NoContent();
}
}Cross-cutting concerns — authentication, logging, Activity/OpenTelemetry — are registered in the host. They wrap the pipeline. They do not become fields on Order.
Bounded context and other services
A microservice is one bounded context with its own model and its own database. Checkout does not share the Order class with Payment. Payment has Payment, with its own invariants.
When Order.ConfirmPayment succeeds, the dispatcher (Infrastructure) can turn OrderPaid into an integration event. The other context maps that payload into its own command. That mapping is an anti-corruption layer — not a shared Domain project.
Closing
Clean Architecture in .NET is a constraint on references. DDD is the content of the inner circle: language, entities, aggregates, and where each rule belongs.
- Entity: who is this, and what may happen to it?
- Value object: what is this value, regardless of identity?
- Aggregate: what must stay consistent in one commit?
- Domain service: where does a rule live if not on one entity?
- Repository: how is the aggregate loaded and saved?
- Application service: what is the use case, end to end?
- Controller: how does HTTP enter the use case?