Clean Architecture e DDD em microsserviços .NET
A maior parte dos serviços .NET começa com um controller falando com um DbContext. Funciona até as regras não caberem mais em if em volta de SaveChanges. Aí faltam duas coisas que costumam ser misturadas: um modelo do negócio e uma regra para onde a infraestrutura pode existir.
Domain-Driven Design é o modelo: bounded context, linguagem ubíqua, entidades, value objects, agregados. Clean Architecture é a regra de dependência: o domínio não referencia ASP.NET Core, EF Core nem barramento de mensagens. Juntos, produzem um serviço que outros times consomem sem herdar a sua persistência nem o seu contrato HTTP.
Este estudo percorre esse desenho em C#, camada por camada, num bounded context de checkout.
Estrutura da solução
Quatro projetos. Referência só para dentro.
src/
Orders.Domain entidades, value objects, agregados, domain services
Orders.Application application services, commands, interfaces de repository
Orders.Infrastructure EF Core, implementações de repository, integrações
Orders.Api ASP.NET CoreOrders.Domain não tem pacote do stack web nem do ORM. Orders.Application depende de Domain e de abstrações (interfaces). Orders.Infrastructure implementa essas interfaces. Orders.Api monta o grafo no Program.cs e expõe HTTP.
Se o Domain passa a usar DbSet<T>, a arquitetura já inverteu.
Domínio: entidade
Uma entidade tem identidade. Dois pedidos com os mesmos itens continuam dois pedidos; o que os distingue é o OrderId. A entidade guarda o estado e as operações que podem alterá-lo.
A regra vive no método, não no controller e não no repository. Status não é setter público para a API inverter.
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)];
}
}Entidade não é classe mapeada do EF reaproveitada como JSON. Persistência e contrato HTTP são modelos separados. O mapeamento é trabalho de Infrastructure e de Api.
Domínio: value object
Value object não tem identidade. Igualdade é estrutural. Instâncias são imutáveis; a operação devolve um valor novo.
Dinheiro, SKU, e-mail e endereço são os casos típicos. Deixar isso como decimal + string na entidade espalha invariante pelo código.
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);
}
}Domínio: agregado
O agregado é a fronteira de consistência. Order é o root. OrderItem não é carregado, gravado nem validado sozinho.
O EF Core expõe tabela com facilidade. Isso não é o domínio. Uma unit of work confirma um agregado. Se dois objetos precisam mudar juntos, estão no mesmo agregado. Se pertencem a contextos diferentes (pedido vs pagamento), a integração é evento de domínio, não transação compartilhada.
Domínio: domain service
Algumas regras não cabem numa entidade: envolvem dois agregados, ou são política sem dono. Isso é domain service — ainda domínio puro, ainda sem DbContext.
Precificação que combina política de catálogo e o pedido atual é um exemplo. Checagem de SKU único contra outro agregado é outro.
Se o código só altera um agregado, vai para a entidade. Se só carrega, grava e chama o domínio, é application service.
Aplicação: contrato do repository
O repository é como a aplicação reconstitui e persiste o agregado. O contrato fica junto do domínio (ou em Application, conforme a convenção do time). A implementação não.
Responsabilidades de IOrderRepository:
GetByIdAsync: carrega o root e os filhos, devolve umOrderde domínio (ou null).SaveAsync: persiste o agregado como unidade — insert, update e delete de itens incluídos.
- se o pagamento pode ser confirmado (entidade);
- mapeamento HTTP;
IQueryablevazando para o controller;- publicação de mensagem.
IOrderItemRepository.public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken cancellationToken);
Task SaveAsync(Order order, CancellationToken cancellationToken);
}Aplicação: application service
O application service é o caso de uso. É o único tipo que a API precisa para esta operação.
Fluxo:
1. Carrega o agregado por IOrderRepository.
2. Chama o domínio (ConfirmPayment ou um domain service).
3. Salva pelo mesmo repository.
4. Despacha os eventos de domínio que o agregado registrou.
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);
}
}Essa classe não tem HttpContext, não tem OrdersDbContext e não tem SQL. Trocar EF Core por outro store é mudança de Infrastructure.
Infraestrutura: repository com EF Core
Infrastructure traduz tabela em agregado. As classes do EF podem espelhar as tabelas. O Order de domínio não precisa.
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);
}
}A implementação é registrada no Program.cs. Domain e Application não mencionam OrdersDbContext.
Apresentação: ASP.NET Core
O controller faz bind HTTP e delega. Status code e DTO de request ficam aqui. Tipo de domínio não é o contrato público.
[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();
}
}Preocupações transversais — autenticação, log, Activity/OpenTelemetry — entram no host. Envolvem o pipeline. Não viram campo em Order.
Bounded context e outros serviços
Um microsserviço é um bounded context com modelo próprio e banco próprio. Checkout não compartilha a classe Order com Payment. Payment tem Payment, com os invariantes dele.
Quando Order.ConfirmPayment conclui, o dispatcher (Infrastructure) pode transformar OrderPaid em evento de integração. O outro contexto mapeia o payload para um comando seu. Esse mapeamento é anti-corruption layer — não um projeto Domain compartilhado.
Encerramento
Clean Architecture em .NET é uma restrição de referência. DDD é o conteúdo do círculo interno: linguagem, entidades, agregados e o lugar de cada regra.
- Entidade: quem é este objeto e o que pode acontecer com ele.
- Value object: qual é este valor, independente de identidade.
- Agregado: o que precisa permanecer consistente num commit.
- Domain service: onde fica a regra que não cabe numa entidade.
- Repository: como o agregado é carregado e gravado.
- Application service: qual é o caso de uso, ponta a ponta.
- Controller: como o HTTP entra no caso de uso.