// TUTORIAL · ORGANIZAÇÃO DE CÓDIGO DDD

DDD Organization Patterns

DDD não é sinônimo de Clean Architecture. Três organizações físicas diferentes — Clean/Onion, Vertical Slice e Modular Monolith com bounded contexts — implementando o mesmo agregado, as mesmas invariantes, pra deixar visível o que muda e o que não muda.

OPEN SOURCE C# / .NET 8DDD TÁTICODDD ESTRATÉGICOVERTICAL SLICEMODULAR MONOLITH

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):
    1. PedidoSemItensException — pedido sem itens não pode ser confirmado.
    2. 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étodo Confirmar(decimal limiteCreditoCliente), protegendo as mesmas duas invariantes — compare Pedido.cs nos 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 um Dictionary diretamente.
  • 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

AspectoClean ArchitectureVertical SliceModular Monolith
Fronteira física principalCamada técnica (Domain/App/Infra)Caso de uso / featureBounded context (módulo)
Nº de projetos (.csproj)411 (poderia crescer pra 1 por módulo)
Onde entra um caso de uso novoMétodo novo em Application, tocando Domain e Infrastructure já existentesPasta nova em Features/, isolada das demaisDentro de um módulo existente ou um módulo novo inteiro
Como dividir o timePor camada — tende a gerar dependência cruzada em toda featurePor feature — cada pessoa termina uma fatia vertical sozinhaPor bounded context — cada time é dono de um módulo ponta a ponta
Risco principalCamadas viram “gaveta de tudo”; mudança pequena toca 3-4 projetosDuplicaçã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 depoisBaixa — camadas cortam por todos os módulos de negócio ao mesmo tempoMédia — feature isolada, mas ainda compartilha o Domain/ inteiroAlta — 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.