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.