← voltar ao índice

· 11 min de leitura · por Tiago

Camadas DDD: Guia Prático para Arquitetura .NET

Se você já trabalhou em um projeto .NET que começou pequeno e virou um emaranhado de Controllers chamando diretamente o DbContext, com regras de negócio espalhadas em validações de formulário, serviços estáticos e triggers de banco de dados, você já sentiu na pele o problema que o Domain-Driven Design (DDD) tenta resolver. Não é sobre usar palavras bonitas como "agregado" ou "bounded context" para parecer sofisticado. É sobre organizar o código de um jeito que reflita a complexidade real do negócio, sem deixar que detalhes técnicos (banco de dados, frameworks web, filas de mensagens) contaminem as regras que realmente importam.

Neste post vamos destrinchar as camadas clássicas de uma arquitetura orientada a DDD (Domain, Application, Infrastructure e Presentation), explicar o porquê de cada uma existir, mostrar exemplos práticos em C# e discutir os erros mais comuns que times cometem ao tentar aplicar esse modelo em projetos reais.

Por que dividir em camadas afinal?

Antes de entrar em cada camada, vale entender o problema que a separação resolve. Em sistemas sem essa disciplina, é comum que a lógica de negócio dependa diretamente de detalhes de infraestrutura: uma classe de domínio que importa o Entity Framework, um serviço que instancia um HttpClient no meio de uma regra de desconto, uma entidade que conhece o formato de um DTO da API. Isso cria um acoplamento que torna o sistema frágil: qualquer mudança de banco, de framework ou de biblioteca externa obriga você a mexer no coração do negócio.

A ideia central por trás das camadas DDD é aplicar o Princípio da Inversão de Dependência (o "D" do SOLID): as camadas internas (o domínio) não devem depender das camadas externas (infraestrutura, apresentação). Pelo contrário, as camadas externas é que dependem das abstrações definidas no domínio. Isso permite testar as regras de negócio isoladamente, trocar um banco de dados por outro sem tocar em uma linha de regra de negócio, e principalmente, entender o sistema lendo o código do domínio sem precisar saber como os dados são persistidos.

As quatro camadas principais

Embora existam variações (Onion Architecture, Clean Architecture, Hexagonal Architecture), a maioria das implementações de DDD em .NET converge para uma estrutura parecida com esta:

src/
  MeuProjeto.Domain/
  MeuProjeto.Application/
  MeuProjeto.Infrastructure/
  MeuProjeto.WebApi/ (ou Presentation)

Cada uma dessas pastas normalmente vira um projeto (.csproj) separado, o que reforça o isolamento por meio das referências entre assemblies. Vamos entender o papel de cada uma.

Domain: o coração do sistema

A camada de domínio contém as entidades, os value objects, os agregados, as interfaces de repositório e os eventos de domínio. É aqui que vive o vocabulário do negócio, o famoso "ubiquitous language" do DDD. Essa camada não deve ter nenhuma dependência de frameworks externos: nada de Microsoft.EntityFrameworkCore, nada de Microsoft.AspNetCore.Mvc, nada de bibliotecas de log específicas. O objetivo é que essa camada seja pura o suficiente para ser testada com testes unitários simples, sem precisar de mocks complicados de infraestrutura.

Um exemplo de entidade rica, que carrega comportamento e não apenas dados:

namespace MeuProjeto.Domain.Pedidos;

public class Pedido
{
    private readonly List<ItemPedido> _itens = new();

    public Guid Id { get; private set; }
    public Guid ClienteId { get; private set; }
    public StatusPedido Status { get; private set; }
    public IReadOnlyCollection<ItemPedido> Itens => _itens.AsReadOnly();

    private Pedido() { } // construtor exigido pelo ORM

    public static Pedido Criar(Guid clienteId)
    {
        if (clienteId == Guid.Empty)
            throw new ArgumentException("Cliente inválido");

        return new Pedido
        {
            Id = Guid.NewGuid(),
            ClienteId = clienteId,
            Status = StatusPedido.Aberto
        };
    }

    public void AdicionarItem(Guid produtoId, int quantidade, decimal precoUnitario)
    {
        if (Status != StatusPedido.Aberto)
            throw new InvalidOperationException("Não é possível alterar um pedido fechado");

        if (quantidade <= 0)
            throw new ArgumentException("Quantidade deve ser maior que zero");

        _itens.Add(new ItemPedido(produtoId, quantidade, precoUnitario));
    }

    public decimal CalcularTotal() => _itens.Sum(i => i.Subtotal);

    public void Fechar()
    {
        if (!_itens.Any())
            throw new InvalidOperationException("Pedido sem itens não pode ser fechado");

        Status = StatusPedido.Fechado;
    }
}

Repare que a validação de negócio (não permitir alterar um pedido fechado, exigir quantidade positiva, exigir pelo menos um item para fechar) está dentro da própria entidade, não em um serviço externo. Isso é o que se chama de "domínio rico" e é o oposto de um "anemic domain model", onde as entidades são apenas sacos de propriedades públicas e toda a lógica fica espalhada em serviços. A vantagem prática é que fica impossível criar um Pedido em um estado inválido, porque as regras estão encapsuladas onde os dados vivem.

Nessa mesma camada também definimos as interfaces de repositório, mas nunca a implementação:

namespace MeuProjeto.Domain.Pedidos;

public interface IPedidoRepository
{
    Task<Pedido?> ObterPorIdAsync(Guid id);
    Task AdicionarAsync(Pedido pedido);
    Task SalvarAsync(Pedido pedido);
}

A interface fica no domínio porque é o domínio que define o contrato de que precisa. A implementação concreta (com Entity Framework, Dapper, MongoDB, o que for) fica na camada de infraestrutura. Essa inversão é o ponto central: o domínio dita as regras, a infraestrutura obedece.

Application: orquestrando casos de uso

A camada de aplicação não contém regra de negócio. Ela orquestra o fluxo: recebe um comando, busca as entidades necessárias através dos repositórios, chama os métodos do domínio, e coordena a persistência e a publicação de eventos. É comum implementar essa camada com o padrão de Use Cases ou Application Services, muitas vezes junto com CQRS (separando comandos de consultas) usando bibliotecas como o MediatR.

namespace MeuProjeto.Application.Pedidos;

public record FecharPedidoCommand(Guid PedidoId) : IRequest<Result>;

public class FecharPedidoHandler : IRequestHandler<FecharPedidoCommand, Result>
{
    private readonly IPedidoRepository _pedidoRepository;
    private readonly IUnitOfWork _unitOfWork;

    public FecharPedidoHandler(IPedidoRepository pedidoRepository, IUnitOfWork unitOfWork)
    {
        _pedidoRepository = pedidoRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result> Handle(FecharPedidoCommand request, CancellationToken cancellationToken)
    {
        var pedido = await _pedidoRepository.ObterPorIdAsync(request.PedidoId);
        if (pedido is null)
            return Result.Falha("Pedido não encontrado");

        try
        {
            pedido.Fechar();
        }
        catch (InvalidOperationException ex)
        {
            return Result.Falha(ex.Message);
        }

        await _pedidoRepository.SalvarAsync(pedido);
        await _unitOfWork.CommitAsync(cancellationToken);

        return Result.Sucesso();
    }
}

Note que o handler não sabe nada sobre como o Pedido é validado internamente, ele só chama Fechar() e trata o resultado. Se amanhã a regra de fechamento mudar (por exemplo, passar a exigir aprovação de um gerente para pedidos acima de um valor), a mudança acontece só dentro da entidade, e a camada de aplicação nem precisa ser tocada. Isso é o benefício prático de separar orquestração de regra de negócio: mudanças de negócio ficam isoladas em um único lugar.

Essa camada também é o local ideal para DTOs de entrada e saída, validações de formato (não confundir com regras de negócio) e mapeamento entre modelos de domínio e modelos de transporte.

Infrastructure: os detalhes técnicos

Aqui moram as implementações concretas: acesso a banco de dados, integração com serviços externos, envio de e-mails, filas de mensagens, cache. Essa camada depende do domínio (implementa as interfaces definidas lá), mas o domínio nunca depende dela.

namespace MeuProjeto.Infrastructure.Persistence;

public class PedidoRepository : IPedidoRepository
{
    private readonly AppDbContext _context;

    public PedidoRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Pedido?> ObterPorIdAsync(Guid id)
    {
        return await _context.Pedidos
            .Include(p => p.Itens)
            .FirstOrDefaultAsync(p => p.Id == id);
    }

    public async Task AdicionarAsync(Pedido pedido)
    {
        await _context.Pedidos.AddAsync(pedido);
    }

    public Task SalvarAsync(Pedido pedido)
    {
        _context.Pedidos.Update(pedido);
        return Task.CompletedTask;
    }
}

Um detalhe importante aqui é o mapeamento do Entity Framework para entidades com construtores privados e coleções encapsuladas, como fizemos no Pedido. Isso geralmente exige configuração explícita via Fluent API, porque o EF por padrão espera propriedades públicas com setters. Vale a pena investir nesse mapeamento, porque é o preço que se paga para manter o domínio protegido de regras inválidas, e é bem menor do que o custo de um domínio anêmico cheio de bugs de estado inconsistente.

Presentation: a porta de entrada

Por fim, a camada de apresentação (controllers de uma API, páginas Razor, endpoints minimal API, consumidores de fila) é responsável por receber requisições externas, converter para comandos da camada de aplicação e devolver respostas. Ela não deve conter lógica de negócio nem acessar repositórios diretamente.

[ApiController]
[Route("api/pedidos")]
public class PedidosController : ControllerBase
{
    private readonly IMediator _mediator;

    public PedidosController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost("{id:guid}/fechar")]
    public async Task<IActionResult> Fechar(Guid id)
    {
        var resultado = await _mediator.Send(new FecharPedidoCommand(id));

        return resultado.Sucesso
            ? Ok()
            : BadRequest(resultado.Mensagem);
    }
}

O controller é propositalmente "burro": ele só traduz o protocolo HTTP em um comando e devolve o resultado. Isso torna trivial trocar a API REST por um consumidor de mensagens do RabbitMQ, por exemplo, sem duplicar regra de negócio, já que toda ela está no domínio e na orquestração da camada de aplicação.

Como as dependências devem fluir

Um jeito simples de lembrar a regra é desenhar as camadas em círculos concêntricos: o Domain no centro, a Application ao redor dele, e a Infrastructure e a Presentation na borda externa. As setas de dependência sempre apontam para dentro. Na prática, isso significa que o projeto MeuProjeto.Domain não deve ter nenhuma referência de pacote NuGet de infraestrutura, e o projeto MeuProjeto.Application deve referenciar apenas o Domain, nunca o Infrastructure diretamente. Quem "amarra tudo" é a camada de composição, geralmente o próprio projeto de Presentation (ou um projeto Startup/Composition Root dedicado), que registra as implementações concretas no container de injeção de dependência.

// Program.cs
builder.Services.AddScoped<IPedidoRepository, PedidoRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(FecharPedidoCommand).Assembly));

Essa configuração central é o único lugar do sistema que "conhece" tanto as abstrações do domínio quanto as implementações da infraestrutura. Isso é intencional e não é uma violação da regra: o objetivo da inversão de dependência é isolar o domínio, não proibir que exista algum ponto de junção.

Armadilhas comuns

Um erro frequente é criar camadas apenas na estrutura de pastas, sem de fato impor a direção de dependência via projetos e referências separadas. Se tudo está no mesmo projeto .csproj, nada impede que um desenvolvedor apressado importe o DbContext direto dentro de uma entidade de domínio, e com o tempo a separação vira só uma organização visual sem efeito prático. Separar em projetos distintos, mesmo que dê um pouco mais de trabalho inicial, força o compilador a barrar essas violações.

Outro problema comum é o domínio anêmico disfarçado de domínio rico: entidades com métodos, mas que apenas fazem set de propriedades sem validar nada, empurrando toda a regra de volta para a camada de aplicação. Isso anula boa parte do benefício do DDD, porque a regra de negócio volta a ficar espalhada em vários handlers, ao invés de centralizada onde os dados vivem.

Também é comum exagerar na aplicação de DDD tático (agregados, value objects, eventos de domínio) em contextos que são essencialmente CRUDs simples, como um cadastro de categorias de produto sem nenhuma regra complexa. Nesses casos, aplicar toda a parafernália de camadas e agregados só adiciona complexidade acidental sem trazer benefício real. DDD funciona melhor quando existe complexidade de negócio genuína a ser modelada, não como padrão obrigatório para qualquer sistema.

Por fim, vale mencionar o excesso de mapeamentos: cada camada costuma ter seu próprio modelo (entidade de domínio, modelo de persistência, DTO de API), o que gera bastante código de conversão. Ferramentas como AutoMapper ajudam, mas exageradas também escondem bugs sutis de mapeamento incorreto. Em times pequenos, às vezes vale a pena aceitar que o modelo de domínio e o modelo de persistência sejam a mesma classe, desde que isso não force o domínio a se curvar às limitações do ORM.

Quando vale a pena aplicar essa estrutura

Para decidir se vale investir nesse nível de separação, pense no tamanho e na expectativa de vida do projeto. Prefira aplicar camadas DDD completas quando o domínio tem regras de negócio não triviais, quando várias pessoas vão trabalhar no mesmo código ao longo de anos, ou quando existe a expectativa real de trocar peças de infraestrutura no futuro (por exemplo, migrar de um banco relacional para outro, ou expor a mesma lógica de negócio via API REST e via processamento em lote). Use uma abordagem mais simples, com poucas camadas ou até um único projeto bem organizado, quando o sistema é pequeno, de vida curta, ou essencialmente um conjunto de operações CRUD sem regra de negócio relevante, como um protótipo, uma ferramenta interna descartável ou um serviço de leitura de dados sem lógica de escrita complexa.

Conclusão

Camadas DDD não são um checklist de pastas para deixar o projeto "parecendo profissional". Elas existem para resolver um problema concreto: impedir que a complexidade acidental de frameworks, bancos de dados e protocolos de comunicação contamine a complexidade essencial do negócio. Ao manter o domínio isolado, rico em comportamento e livre de dependências externas, você ganha um sistema mais fácil de testar, mais fácil de entender e mais resiliente a mudanças de tecnologia. O segredo está em aplicar essa disciplina onde ela realmente traz valor, respeitando a direção das dependências, mantendo entidades com comportamento real e evitando a armadilha de transformar uma boa prática em burocracia desnecessária para problemas simples.

← voltar ao índice