DDD Clean Architecture .NET C# Arquitetura

DDD avançado no .NET: Aggregates, Domain Events, Bounded Contexts e Value Objects na prática

Guia avançado de Domain-Driven Design em C#: como modelar Aggregates corretamente, usar Domain Events para desacoplar o domínio e definir Bounded Contexts que escalam com a equipe.

N
Neryx Digital Architects
15 de março de 2026
16 min de leitura
290 profissionais leram
Categoria: Arquitetura de Software Público: Arquitetos e backend engineers trabalhando modelagem de domínio Etapa: Aprendizado

Por que a maioria dos projetos "DDD" na verdade não é DDD

DDD virou moda. Muitos projetos têm pastas chamadas Domain, entidades com métodos, talvez até um ValueObject base. Mas na prática, o modelo de domínio é anêmico: as entidades são bags de propriedades com getters e setters públicos, e toda a lógica de negócio está espalhada em services e handlers.

Domain-Driven Design não é uma estrutura de pastas. É uma forma de modelar o software que espelha o negócio — com linguagem ubíqua, fronteiras bem definidas e regras de domínio que vivem onde deveriam: dentro do modelo.

Este artigo vai fundo nos quatro pilares que fazem a diferença na prática: Aggregates, Domain Events, Bounded Contexts e Value Objects.

Value Objects: imutabilidade e igualdade por valor

Um Value Object não tem identidade — é definido pelos seus atributos. Dois objetos com os mesmos atributos são iguais. São sempre imutáveis.

Exemplos clássicos: Money, Email, CPF, Endereco, Coordenadas.

public sealed class Money : ValueObject
{
    public decimal Amount { get; }
    public string Currency { get; }

    private Money(decimal amount, string currency)
    {
        if (amount < 0) throw new DomainException("Valor monetário não pode ser negativo.");
        if (string.IsNullOrWhiteSpace(currency)) throw new DomainException("Moeda inválida.");
        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }

    public static Money Of(decimal amount, string currency) => new(amount, currency);
    public static Money Zero => new(0, "BRL");

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new DomainException($"Não é possível somar {Currency} com {other.Currency}.");
        return new Money(Amount + other.Amount, Currency);
    }

    public Money Multiply(int factor) => new(Amount * factor, Currency);

    // ValueObject base implementa Equals e GetHashCode via GetEqualityComponents
    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Amount;
        yield return Currency;
    }
}

O benefício imediato: em vez de decimal Preco e string Moeda espalhados pelo código — dois campos que podem ser usados incorretamente —, você tem um único objeto que carrega as regras da moeda dentro de si.

Aggregates: consistência transacional e invariantes

Um Aggregate é um cluster de objetos (entidades e value objects) tratado como unidade transacional. Ele tem um Aggregate Root — a única entidade que o mundo externo pode referenciar e modificar.

Regra de ouro: uma transação = uma mudança em um aggregate. Se você precisa alterar dois aggregates na mesma transação, provavelmente seus limites estão errados.

// Aggregate Root: Pedido
public class Pedido : AggregateRoot<PedidoId>
{
    private readonly List<ItemPedido> _itens = new();
    public IReadOnlyList<ItemPedido> Itens => _itens.AsReadOnly();
    public ClienteId ClienteId { get; private set; }
    public StatusPedido Status { get; private set; }
    public Money ValorTotal { get; private set; }
    public Endereco? EnderecoEntrega { get; private set; }

    private Pedido() { }

    public static Pedido Criar(ClienteId clienteId, Endereco endereco)
    {
        var pedido = new Pedido
        {
            Id = new PedidoId(Guid.NewGuid()),
            ClienteId = clienteId,
            EnderecoEntrega = endereco,
            Status = StatusPedido.Rascunho,
            ValorTotal = Money.Zero
        };
        pedido.RaiseDomainEvent(new PedidoCriadoEvent(pedido.Id, clienteId));
        return pedido;
    }

    public void AdicionarItem(ProdutoId produtoId, int quantidade, Money precoUnitario)
    {
        GuardarRegra(new PedidoDeveEstarEmRascunhoSpec());

        var existente = _itens.FirstOrDefault(i => i.ProdutoId == produtoId);
        if (existente is not null)
        {
            existente.AjustarQuantidade(existente.Quantidade + quantidade);
        }
        else
        {
            _itens.Add(ItemPedido.Criar(Id, produtoId, quantidade, precoUnitario));
        }
        RecalcularTotal();
    }

    public void Confirmar()
    {
        GuardarRegra(new PedidoDeveEstarEmRascunhoSpec());
        GuardarRegra(new PedidoDeveConterItensSpec());

        Status = StatusPedido.Confirmado;
        RaiseDomainEvent(new PedidoConfirmadoEvent(Id, ClienteId, ValorTotal));
    }

    public void Cancelar(string motivo)
    {
        if (Status == StatusPedido.Entregue)
            throw new DomainException("Pedido entregue não pode ser cancelado.");

        Status = StatusPedido.Cancelado;
        RaiseDomainEvent(new PedidoCanceladoEvent(Id, motivo));
    }

    private void RecalcularTotal() =>
        ValorTotal = _itens.Aggregate(Money.Zero, (acc, i) => acc + i.Subtotal);

    private void GuardarRegra(IBusinessRule rule)
    {
        if (rule.IsBroken())
            throw new BusinessRuleValidationException(rule);
    }
}

Os Business Rules como objetos separados tornam as invariantes explícitas e testáveis:

public class PedidoDeveConterItensSpec : IBusinessRule
{
    private readonly IReadOnlyList<ItemPedido> _itens;

    public PedidoDeveConterItensSpec(IReadOnlyList<ItemPedido> itens) => _itens = itens;

    public bool IsBroken() => !_itens.Any();
    public string Message => "Um pedido deve ter ao menos um item antes de ser confirmado.";
}

Domain Events: desacoplando o que aconteceu do que deve acontecer

Domain Events representam algo que aconteceu no domínio e que outras partes do sistema podem precisar saber. São a cola entre Bounded Contexts sem criar acoplamento direto.

// Domain/Events/PedidoConfirmadoEvent.cs
public record PedidoConfirmadoEvent(
    PedidoId PedidoId,
    ClienteId ClienteId,
    Money ValorTotal) : IDomainEvent
{
    public DateTime OcorridoEm { get; } = DateTime.UtcNow;
}

A publicação acontece após o commit, na camada de Application:

// Application/Behaviors/DomainEventDispatcherBehavior.cs
public class DomainEventDispatcherBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IUnitOfWork _uow;
    private readonly IPublisher _publisher;
    private readonly IDomainEventCollector _collector;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var response = await next();
        await _uow.CommitAsync(ct);

        foreach (var domainEvent in _collector.GetAndClearEvents())
            await _publisher.Publish(domainEvent, ct);

        return response;
    }
}

Handlers de Domain Events ficam na camada Application e podem disparar side effects como envio de e-mail, notificação ao estoque, registro de auditoria:

// Application/EventHandlers/NotificarEstoqueHandler.cs
public class NotificarEstoqueHandler : INotificationHandler<PedidoConfirmadoEvent>
{
    private readonly IEstoqueService _estoque;

    public async Task Handle(PedidoConfirmadoEvent notification, CancellationToken ct)
    {
        await _estoque.ReservarItensAsync(notification.PedidoId, ct);
    }
}

Bounded Contexts: o mapa do território

Um Bounded Context é o limite dentro do qual um modelo de domínio específico é válido e consistente. O mesmo conceito (ex: "Cliente") pode ter significados completamente diferentes em contextos distintos.

No contexto de Vendas, "Cliente" tem: CPF, endereço de entrega, histórico de pedidos, limite de crédito.
No contexto de Marketing, "Cliente" tem: preferências, segmentos, histórico de campanhas.
No contexto de Suporte, "Cliente" tem: tickets abertos, nível de satisfação, SLA.

São modelos diferentes, em projetos diferentes, com bancos diferentes. Isso é saudável — e intencional.

A comunicação entre Bounded Contexts acontece de formas bem definidas:

1. Domain Events assíncronos (via mensageria):

// O contexto de Vendas publica
await _messageBus.PublishAsync(new PedidoConfirmadoIntegrationEvent
{
    PedidoId = pedido.Id.Value,
    ClienteId = pedido.ClienteId.Value,
    ItensJson = JsonSerializer.Serialize(pedido.Itens)
});

// O contexto de Estoque consome
public class ReservarEstoqueConsumer : IConsumer<PedidoConfirmadoIntegrationEvent>
{
    public async Task Consume(ConsumeContext<PedidoConfirmadoIntegrationEvent> ctx)
    {
        // modelo local de estoque — independente do modelo de vendas
        await _estoqueService.ReservarAsync(ctx.Message.PedidoId, ctx.Message.ItensJson);
    }
}

2. Anti-Corruption Layer (ACL): quando um contexto precisa consumir dados de outro com modelos incompatíveis, o ACL traduz o modelo externo para o interno, protegendo o domínio:

// Infrastructure/ExternalServices/ClienteAcl.cs
public class ClienteAcl : IClienteService
{
    private readonly IClienteLegadoApiClient _legadoApi;

    public async Task<ClienteInfo> ObterInfoAsync(Guid clienteId)
    {
        // API legada retorna formato diferente
        var legadoCliente = await _legadoApi.GetClienteAsync(clienteId);

        // ACL traduz para o modelo do contexto atual
        return new ClienteInfo(
            Id: new ClienteId(legadoCliente.CodigoCliente),
            Email: Email.Of(legadoCliente.EmailContato),
            LimiteCredito: Money.Of(legadoCliente.LimiteFinanceiro, "BRL")
        );
    }
}

Context Map: documentando as relações

Um Context Map documenta como os Bounded Contexts se relacionam. Padrões comuns:

Shared Kernel: dois contextos compartilham um subconjunto do modelo. Muda com acordo mútuo. Risco: alto acoplamento.

Customer-Supplier: contexto upstream fornece dados para downstream. Downstream adapta ao que upstream publica.

Conformist: downstream adota o modelo do upstream sem filtro. Simples, mas cria acoplamento forte.

ACL (Anti-Corruption Layer): downstream traduz ativamente o modelo upstream. Melhor para integrar legados.

Published Language: contexto publica uma linguagem formal (ex: JSON Schema, Protobuf) que outros consomem. Desacoplamento máximo.

Estrutura de projetos para múltiplos Bounded Contexts

src/
  Modules/
    Vendas/
      Vendas.Domain/
      Vendas.Application/
      Vendas.Infrastructure/
    Estoque/
      Estoque.Domain/
      Estoque.Application/
      Estoque.Infrastructure/
    Clientes/
      Clientes.Domain/
      Clientes.Application/
      Clientes.Infrastructure/
  SharedKernel/
    SharedKernel.Domain/         # ValueObject, Entity base, IDomainEvent
  Api/
    Program.cs                   # Composition Root de todos os módulos

Cada módulo é um bounded context com total autonomia de modelo. O SharedKernel contém apenas abstrações sem lógica de negócio específica.

Quando DDD vale o custo

DDD tem custo real: mais código, mais abstrações, curva de aprendizado. Vale a pena quando:

O domínio é complexo e muda com frequência. O negócio tem regras que especialistas do domínio precisam validar. A equipe cresce e precisa de fronteiras claras. Testabilidade é crítica. O sistema vai durar anos.

Não vale quando: CRUD simples, startup em fase de descoberta de produto, prazo muito curto, equipe sem maturidade no padrão.

A decisão não é binária. Você pode aplicar DDD nos módulos mais críticos (ex: pagamentos, pedidos) e usar abordagens mais simples nos módulos periféricos (ex: configurações, relatórios).

Conclusão

DDD avançado é sobre respeitar as fronteiras do conhecimento: cada Bounded Context tem seu próprio modelo, sua própria linguagem. Aggregates garantem consistência transacional. Domain Events desacoplam o que aconteceu do que precisa acontecer. Value Objects eliminam primitivos sem semântica.

A diferença entre um projeto que "usa DDD no nome das pastas" e um que realmente aplica DDD está na qualidade do modelo de domínio — e no quanto o código fala a linguagem do negócio.

Esse cenário pede clareza antes de executar

Quando a decisão é grande demais para adivinhar, o Discovery ajuda a mapear arquitetura, riscos e roadmap com mais segurança.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.