Motivação
Em algum momento, “fazer DDD” virou sinônimo de “criar quatro projetos (Domain, Application,
Infrastructure, Presentation) com a regra de dependência apontando pra dentro”. É um mal-entendido
compreensível — grande parte dos tutoriais e templates de .NET que citam DDD usam Clean/Onion Architecture
como veículo, então as duas coisas colaram na cabeça de muita gente. Mas são coisas de naturezas
diferentes, e este repositório existe pra desfazer essa confusão na prática, com código que roda, não só
com teoria.
O problema com “só Clean Architecture”
- Clean/Onion Architecture é uma decisão de organização física de código: em quantos projetos/assemblies o código é dividido, e que direção as referências entre eles podem apontar. É uma resposta pra “como eu desacoplo camadas técnicas”.
- Domain-Driven Design é uma disciplina de modelagem, com duas partes:
- Tático: os padrões usados pra modelar o domínio dentro do código — entidades, objetos de valor, agregados (com uma raiz que protege invariantes), eventos de domínio, repositórios (abstrações voltadas à linguagem do domínio, não wrappers de banco de dados) e a linguagem ubíqua.
- Estratégico: como delimitar e relacionar diferentes partes do domínio entre si — bounded contexts, context mapping, core domain vs. domínios de apoio.
Nada nessa lista menciona pastas ou .csproj. Um agregado com suas invariantes bem protegidas continua
sendo um agregado se ele mora em Pedidos.Domain/Entidades/Pedido.cs (Clean Architecture), em
Features/ColocarPedido/ ao lado do caso de uso que o usa (Vertical Slice) ou em Modules/Ordering/Domain/
(monólito modular). A organização física é ortogonal ao DDD tático. Dá pra fazer DDD rigoroso dentro de
um único projeto, e dá pra ter quatro projetos impecavelmente separados sem nenhuma modelagem de domínio
de verdade lá dentro — o clássico anemic domain model com camadas bonitas em volta, que não é DDD, é só
arquitetura em camadas com nomes emprestados.
O domínio de exemplo
Um recorte pequeno, mas com uma invariante de negócio real — o suficiente pra não ser só um CRUD disfarçado: colocar um pedido, que precisa ter pelo menos um item e cujo valor total não pode exceder o limite de crédito do cliente.
Pedido— o agregado raiz. Contém a lista de itens, calcula o valor total e é o único ponto de entrada pra alterar o pedido.ItemPedido— entidade interna do agregado; não existe fora dele, não tem repositório próprio.Cliente— carrega o limite de crédito usado na validação.- Duas invariantes protegidas dentro do método
Pedido.Confirmar(decimal limiteCreditoCliente):PedidoSemItensException— pedido sem itens não pode ser confirmado.LimiteCreditoExcedidoException— valor total maior que o limite de crédito não pode ser confirmado.
IPedidoRepository— abstração voltada ao domínio (Adicionar,ObterPorId), implementada em cada exemplo por um dicionário em memória — sem banco de dados real em nenhum dos três, de propósito.PedidoConfirmadoEvento— evento de domínio disparado quando o pedido é confirmado.
Esse modelo tático se repete quase linha por linha nos três exemplos. O que muda é só onde os arquivos moram e como as peças se descobrem.
As organizações comparadas
Exemplo 1 — Clean/Onion Architecture
A organização “padrão”: um projeto por camada técnica, com a regra de dependência sempre apontando pra
dentro (Domain não depende de nada; todo o resto depende, direta ou indiretamente, dele).
flowchart TB
subgraph Presentation["Pedidos.Console (Presentation)"]
Program["Program.cs — composition root"]
end
subgraph Application["Pedidos.Application"]
CasoDeUso["ColocarPedidoCasoDeUso"]
DTO["ItemPedidoDto"]
end
subgraph Domain["Pedidos.Domain (não depende de nada)"]
Pedido["Pedido (agregado raiz)"]
Item["ItemPedido (entidade interna)"]
Cliente["Cliente"]
Repo["IPedidoRepository / IClienteRepository"]
Excecoes["PedidoSemItensException / LimiteCreditoExcedidoException"]
end
subgraph Infrastructure["Pedidos.Infrastructure"]
RepoImpl["PedidoRepositorioEmMemoria / ClienteRepositorioEmMemoria"]
end
Program --> CasoDeUso
Program --> RepoImpl
CasoDeUso --> Pedido
CasoDeUso --> Repo
RepoImpl -.implementa.-> Repo
Pedido --> Excecoes
Quatro .csproj separados, com referências de projeto que fisicamente impedem Domain de referenciar
Infrastructure — o compilador barra a violação da regra de dependência, não só a revisão de código. Todo
o DDD tático (agregado, entidades, objetos de valor, abstrações de repositório) mora inteiramente em
Pedidos.Domain, isolado de qualquer preocupação técnica.
Exemplo 2 — Vertical Slice Architecture
Em vez de fatiar por camada técnica, fatia por caso de uso. Cada feature ganha uma pasta com tudo que precisa pra existir — comando, validação e handler —, e todas as features compartilham o mesmo modelo de domínio.
flowchart TB
subgraph Domain2["Domain/ (compartilhado entre as features)"]
Pedido2["Pedido (agregado raiz)"]
Item2["ItemPedido"]
Cliente2["Cliente"]
Repo2["IPedidoRepository / IClienteRepository"]
end
subgraph FeatureColocar["Features/ColocarPedido/"]
Command["ColocarPedidoCommand"]
Validator["ColocarPedidoValidator"]
Handler["ColocarPedidoHandler"]
end
subgraph FeatureConsultar["Features/ConsultarPedido/"]
Query["ConsultarPedidoHandler"]
end
subgraph Infra2["Infraestrutura/"]
RepoImpl2["PedidoRepositorioEmMemoria / ClienteRepositorioEmMemoria"]
end
Handler --> Command
Handler --> Validator
Handler --> Pedido2
Handler --> Repo2
Query --> Repo2
RepoImpl2 -.implementa.-> Repo2
Um único projeto (Pedidos.VerticalSlice), sem fronteiras de assembly entre “camadas” — a separação vira
convenção de pastas, não regra imposta pelo compilador. ColocarPedidoHandler e ConsultarPedidoHandler
cada um só carrega o que a própria feature precisa; não existe uma camada Application genérica
compartilhada e inchada por casos de uso sem relação entre si. Domain/ continua existindo como pasta
separada, com o mesmo agregado Pedido protegendo as mesmas duas invariantes através do mesmo método
Confirmar — a diferença é que a camada de aplicação fica ao lado do que ela resolve, não centralizada
num projeto à parte.
Exemplo 3 — Package-by-feature / Modular Monolith (bounded contexts)
Aqui a fronteira física deixa de ser “camada técnica” ou “caso de uso” e passa a ser bounded context —
o padrão estratégico do DDD. O domínio é dividido em módulos autocontidos, cada um com seu próprio
modelo, sua própria persistência e sua própria linguagem ubíqua. Cliente e o limite de crédito passam a
pertencer ao módulo Billing, não a Ordering: Ordering não conhece a entidade Cliente de Billing,
só enxerga um port (IVerificadorLimiteCredito) que ele mesmo define, e que Billing implementa através
de um adaptador.
flowchart TB
subgraph Ordering["Modules/Ordering"]
direction TB
subgraph OrderingDomain["Domain/"]
Pedido3["Pedido (agregado raiz)"]
Item3["ItemPedido"]
Port["IVerificadorLimiteCredito (port definido por Ordering)"]
RepoPedido["IPedidoRepository"]
end
Aplicacao["Aplicacao/ColocarPedidoService"]
InfraOrdering["Infraestrutura/PedidoRepositorioEmMemoria"]
end
subgraph Billing["Modules/Billing"]
direction TB
subgraph BillingDomain["Domain/"]
Cliente3["Cliente (dono do limite de crédito)"]
RepoCliente["IClienteRepository"]
end
Adapter["Infraestrutura/VerificadorLimiteCreditoAdapter"]
end
Aplicacao --> Pedido3
Aplicacao --> Port
Aplicacao --> RepoPedido
Adapter -. implementa .-> Port
Adapter --> RepoCliente
ProgramMM["Program.cs"] -->|liga Adapter ao Port| Aplicacao
ProgramMM --> Adapter
A fronteira mais importante do sistema deixa de ser “Domain vs. Infrastructure” e passa a ser “Ordering vs.
Billing” — cada módulo tem sua própria Domain/ e sua própria Infraestrutura/. Um módulo só é acessível
a outro através de contratos explícitos, definidos pelo consumidor (Ordering), não pelo dono do dado
(Billing) — o mesmo princípio de Dependency Inversion que separa Domain de Infrastructure no Exemplo 1,
aplicado agora na fronteira entre bounded contexts em vez de entre camadas técnicas. Repare que dentro de
cada módulo ainda existe uma organização em camadas — modular monolith não é incompatível com Clean
Architecture, ele só adiciona uma fronteira estratégica acima da fronteira técnica.
Como um “Confirmar” atravessa as camadas no Exemplo 3
O fluxo abaixo mostra ColocarPedidoService, dentro de Ordering, precisando validar o limite de crédito de
um cliente que pertence a Billing — sem nunca importar a entidade Cliente diretamente.
sequenceDiagram
actor Console as Program.cs (composition root)
participant Service as Ordering.ColocarPedidoService
participant Pedido as Ordering.Domain.Pedido
participant Port as IVerificadorLimiteCredito (port)
participant Adapter as Billing.VerificadorLimiteCreditoAdapter
participant ClienteRepo as Billing.IClienteRepository
Console->>Service: Executar(clienteId, itens)
Service->>Pedido: Pedido.Novo(clienteId) + AdicionarItem(...)
Service->>Port: ObterLimiteCredito(clienteId)
Note over Service,Port: Ordering só conhece a interface,\nque ele mesmo declarou
Port->>Adapter: (implementação injetada no Program.cs)
Adapter->>ClienteRepo: ObterPorId(clienteId)
ClienteRepo-->>Adapter: Cliente (com LimiteCredito)
Adapter-->>Port: limiteCredito
Port-->>Service: limiteCredito
Service->>Pedido: Confirmar(limiteCredito)
alt invariantes ok
Pedido-->>Service: Status = Confirmado + PedidoConfirmadoEvento
Service-->>Console: [OK] pedido confirmado
else invariante violada
Pedido-->>Service: throw PedidoSemItensException / LimiteCreditoExcedidoException
Service-->>Console: [REJEITADO CORRETAMENTE]
end
Repare que Billing nunca aparece no Domain/ de Ordering — a única coisa que atravessa a fronteira é o
limiteCredito (um decimal), via um contrato que Ordering é dono.
O que é idêntico nos três exemplos
- O agregado
Pedido: a mesma classe, com o mesmo métodoConfirmar(decimal limiteCreditoCliente), protegendo as mesmas duas invariantes — comparePedido.csnos três exemplos, são propositalmente quase o mesmo arquivo. - A linguagem ubíqua:
Pedido,Item,Confirmar,LimiteCredito,PedidoSemItensException— os mesmos nomes, tirados do vocabulário do negócio, em todo lugar. - O repositório como abstração de domínio: em nenhum dos três exemplos o código de aplicação enxerga
um
DbContext, uma connection string ou umDictionarydiretamente. - Onde a regra de negócio é aplicada: sempre dentro do agregado, nunca espalhada pelo código que o
chama — nenhum
if (pedido.Itens.Count == 0)solto num handler.
Trade-offs
| Aspecto | Clean Architecture | Vertical Slice | Modular Monolith |
|---|---|---|---|
| Fronteira física principal | Camada técnica (Domain/App/Infra) | Caso de uso / feature | Bounded context (módulo) |
Nº de projetos (.csproj) | 4 | 1 | 1 (poderia crescer pra 1 por módulo) |
| Onde entra um caso de uso novo | Método novo em Application, tocando Domain e Infrastructure já existentes | Pasta nova em Features/, isolada das demais | Dentro de um módulo existente ou um módulo novo inteiro |
| Como dividir o time | Por camada — tende a gerar dependência cruzada em toda feature | Por feature — cada pessoa termina uma fatia vertical sozinha | Por bounded context — cada time é dono de um módulo ponta a ponta |
| Risco principal | Camadas viram “gaveta de tudo”; mudança pequena toca 3-4 projetos | Duplicação entre features (cada slice reinventa validação, mapeamento etc.) | Acoplamento indevido se os ports não forem respeitados |
| Facilidade de extrair um serviço depois | Baixa — camadas cortam por todos os módulos de negócio ao mesmo tempo | Média — feature isolada, mas ainda compartilha o Domain/ inteiro | Alta — o módulo já é o corte que você usaria |
Nenhum dos três é “o certo” — são otimizados pra problemas diferentes. Clean/Onion compensa quando a equipe é pequena/média, o domínio ainda está sendo entendido e você quer que o compilador imponha a separação Domain/Infra. Vertical Slice compensa quando o sistema tem muitos casos de uso relativamente independentes e o time quer PRs pequenos e isolados. Modular Monolith compensa quando o domínio já tem bounded contexts claros e você quer preparar terreno pra eventualmente extrair um módulo como serviço, sem pagar o custo operacional de microsserviços agora — o custo é exigir maturidade de modelagem estratégica pra desenhar as fronteiras certas desde o início, porque times inteiros se organizam em torno delas.
Também não são mutuamente exclusivos: é comum ter um monólito modular onde, dentro de cada módulo, o time organiza o código em camadas (Clean Architecture) ou por feature (Vertical Slice) — a escolha de layout dentro do módulo é local a cada bounded context, não precisa ser a mesma pro sistema inteiro.
Stack
- ◫ LINGUAGEM: C# / .NET 8, três console apps independentes, sem framework web nem ORM.
- ▤ PERSISTÊNCIA: repositórios em memória (dicionário) nos três exemplos — de propósito, pra manter o foco na organização do código, não em infraestrutura.
- ◆ DDD TÁTICO: agregados, entidades internas, objetos de valor, eventos de domínio, repositórios voltados ao domínio.
- ⬡ DDD ESTRATÉGICO: bounded contexts, ports/adapters entre módulos (Exemplo 3).
Como rodar / explorar o repo
Cada exemplo é um app de console independente (.NET 8), sem dependência de infraestrutura externa. Cada um roda um caso de sucesso e dois casos que violam as invariantes de propósito, pra provar que a regra é aplicada de verdade, não só “existe no papel”.
# Exemplo 1 — Clean/Onion Architecture
cd 01-CleanArchitecture/src/Pedidos.Console
dotnet run
# Exemplo 2 — Vertical Slice Architecture
cd 02-VerticalSlice/src/Pedidos.VerticalSlice
dotnet run
# Exemplo 3 — Package-by-feature / Modular Monolith
cd 03-ModularMonolith/src/Pedidos.ModularMonolith
dotnet run
A saída de cada um mostra explicitamente [OK] pro pedido aceito e [REJEITADO CORRETAMENTE] pras duas
tentativas de violar as invariantes. Cada pasta de exemplo é independente — sem .sln compartilhada, de
propósito, pra reforçar que são três respostas separadas à mesma pergunta, não etapas de uma migração. Pra
comparar lado a lado, o jeito mais direto é abrir Pedido.cs nos três (01-CleanArchitecture/.../Entidades/Pedido.cs,
02-VerticalSlice/.../Domain/Entidades/Pedido.cs, 03-ModularMonolith/.../Ordering/Domain/Entidades/Pedido.cs)
e ver que são, propositalmente, quase o mesmo arquivo — e só depois olhar pra fora dele, pra onde a
organização física muda de verdade.
# Código aberto
Os três exemplos são open source, cada um um console app .NET 8 independente e sem dependência de infraestrutura — só clonar e rodar.