Clean Architecture é um dos padrões de organização de código mais discutidos no ecossistema .NET — e também um dos mais mal aplicados. Este guia mostra como implementar na prática, com uma estrutura de projeto real em ASP.NET Core e C#, sem dogmatismo e com foco no que realmente importa.
O problema que Clean Architecture resolve
Em projetos que crescem sem arquitetura definida, é comum encontrar:
- Regras de negócio espalhadas por controllers, services e até queries SQL
- Testes de unidade impossíveis porque a lógica depende de banco de dados
- Mudança de banco de dados ou framework que exige reescrita da lógica de negócio
- Código duplicado porque não existe camada clara de abstração
Clean Architecture (popularizada por Robert Martin, o "Uncle Bob") resolve isso com uma regra simples: as dependências sempre apontam para dentro. As camadas externas (framework, banco, UI) dependem das internas (domínio, casos de uso) — nunca o contrário.
As quatro camadas
1. Domain (núcleo)
A camada mais interna. Contém as entidades do negócio, value objects, interfaces de repositório e regras de domínio. Não tem dependência de nenhum framework, ORM ou biblioteca externa — apenas C# puro.
// Domain/Entities/Pedido.cs namespace MeuProjeto.Domain.Entities;public class Pedido { public Guid Id { get; private set; } public Guid ClienteId { get; private set; } public List<ItemPedido> Itens { get; private set; } = new(); public StatusPedido Status { get; private set; } public decimal Total => Itens.Sum(i => i.Subtotal);
private Pedido() { } // para o EF Core public static Pedido Criar(Guid clienteId) { if (clienteId == Guid.Empty) throw new DomainException("ClienteId inválido"); return new Pedido { Id = Guid.NewGuid(), ClienteId = clienteId, Status = StatusPedido.Rascunho }; } public void AdicionarItem(Guid produtoId, int quantidade, decimal preco) { if (Status != StatusPedido.Rascunho) throw new DomainException("Não é possível adicionar itens a um pedido confirmado"); var item = new ItemPedido(produtoId, quantidade, preco); Itens.Add(item); } public void Confirmar() { if (!Itens.Any()) throw new DomainException("Pedido sem itens não pode ser confirmado"); Status = StatusPedido.Confirmado; }
}
// Domain/Repositories/IPedidoRepository.cs
namespace MeuProjeto.Domain.Repositories;
public interface IPedidoRepository
{
Task<Pedido?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<Pedido>> GetByClienteIdAsync(Guid clienteId, CancellationToken ct = default);
Task AddAsync(Pedido pedido, CancellationToken ct = default);
Task UpdateAsync(Pedido pedido, CancellationToken ct = default);
}
2. Application (casos de uso)
Orquestra as regras de domínio para executar casos de uso específicos. Depende do Domain, mas não sabe nada sobre banco de dados, HTTP ou frameworks. É a camada que recebe commands/queries e devolve resultados.
// Application/UseCases/Pedidos/ConfirmarPedido/ConfirmarPedidoCommand.cs namespace MeuProjeto.Application.UseCases.Pedidos;
public record ConfirmarPedidoCommand(Guid PedidoId); public record ConfirmarPedidoResult(Guid PedidoId, string Status, decimal Total);
// Application/UseCases/Pedidos/ConfirmarPedido/ConfirmarPedidoHandler.cs
namespace MeuProjeto.Application.UseCases.Pedidos;
public class ConfirmarPedidoHandler
{
private readonly IPedidoRepository _repository;
private readonly IUnitOfWork _unitOfWork;
public ConfirmarPedidoHandler(IPedidoRepository repository, IUnitOfWork unitOfWork)
{
_repository = repository;
_unitOfWork = unitOfWork;
}
public async Task<ConfirmarPedidoResult> HandleAsync(
ConfirmarPedidoCommand command,
CancellationToken ct = default)
{
var pedido = await _repository.GetByIdAsync(command.PedidoId, ct)
?? throw new NotFoundException($"Pedido {command.PedidoId} não encontrado");
pedido.Confirmar(); // regra de domínio chamada aqui
await _repository.UpdateAsync(pedido, ct);
await _unitOfWork.CommitAsync(ct);
return new ConfirmarPedidoResult(pedido.Id, pedido.Status.ToString(), pedido.Total);
}
}
3. Infrastructure (implementações externas)
Implementa as interfaces definidas no Domain. Aqui vivem o Entity Framework, os repositórios concretos, clientes HTTP, serviços de e-mail, provedores de cloud — tudo que é "detalhe de implementação".
// Infrastructure/Repositories/PedidoRepository.cs namespace MeuProjeto.Infrastructure.Repositories;public class PedidoRepository : IPedidoRepository { private readonly AppDbContext _context;
public PedidoRepository(AppDbContext context) => _context = context; public async Task<Pedido?> GetByIdAsync(Guid id, CancellationToken ct = default) => await _context.Pedidos .Include(p => p.Itens) .FirstOrDefaultAsync(p => p.Id == id, ct); public async Task AddAsync(Pedido pedido, CancellationToken ct = default) => await _context.Pedidos.AddAsync(pedido, ct); public async Task UpdateAsync(Pedido pedido, CancellationToken ct = default) => _context.Pedidos.Update(pedido); public async Task<IReadOnlyList<Pedido>> GetByClienteIdAsync(Guid clienteId, CancellationToken ct = default) => await _context.Pedidos .Where(p => p.ClienteId == clienteId) .ToListAsync(ct);
}
4. Presentation (API / UI)
A camada mais externa. No contexto de uma API ASP.NET Core, são os controllers — que recebem requisições HTTP, delegam para o Application e retornam respostas. Não contém lógica de negócio.
// Presentation/Controllers/PedidosController.cs [ApiController] [Route("api/pedidos")] public class PedidosController : ControllerBase { private readonly ConfirmarPedidoHandler _handler;public PedidosController(ConfirmarPedidoHandler handler) => _handler = handler; [HttpPost("{id}/confirmar")] public async Task<IActionResult> Confirmar(Guid id, CancellationToken ct) { var result = await _handler.HandleAsync(new ConfirmarPedidoCommand(id), ct); return Ok(result); }
}
Estrutura de pastas recomendada
src/
├── MeuProjeto.Domain/
│ ├── Entities/
│ ├── ValueObjects/
│ ├── Repositories/ # interfaces
│ ├── Exceptions/
│ └── MeuProjeto.Domain.csproj
│
├── MeuProjeto.Application/
│ ├── UseCases/
│ │ └── Pedidos/
│ │ ├── ConfirmarPedido/
│ │ └── CriarPedido/
│ ├── Interfaces/ # IUnitOfWork, IEmailService, etc.
│ └── MeuProjeto.Application.csproj
│
├── MeuProjeto.Infrastructure/
│ ├── Repositories/ # implementações concretas
│ ├── Persistence/ # DbContext, migrations, configurações EF
│ ├── ExternalServices/ # clientes HTTP, e-mail, storage
│ └── MeuProjeto.Infrastructure.csproj
│
└── MeuProjeto.API/
├── Controllers/
├── Middlewares/
├── Program.cs
└── MeuProjeto.API.csproj
Configurando as dependências com injeção de dependência
// Infrastructure/DependencyInjection.cs public static class DependencyInjection { public static IServiceCollection AddInfrastructure( this IServiceCollection services, IConfiguration configuration) { services.AddDbContext<AppDbContext>(opts => opts.UseNpgsql(configuration.GetConnectionString("Default")));services.AddScoped<IPedidoRepository, PedidoRepository>(); services.AddScoped<IUnitOfWork, UnitOfWork>(); return services; }}
// Program.cs builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddScoped<ConfirmarPedidoHandler>();
Testando sem banco de dados
A maior vantagem prática de Clean Architecture: os use cases podem ser testados sem instanciar banco de dados, framework HTTP ou qualquer dependência externa — basta mockar as interfaces.
// Application.Tests/UseCases/ConfirmarPedidoHandlerTests.cs public class ConfirmarPedidoHandlerTests { [Fact] public async Task Deve_confirmar_pedido_com_itens() { // Arrange var pedido = Pedido.Criar(Guid.NewGuid()); pedido.AdicionarItem(Guid.NewGuid(), 2, 50m);var repositoryMock = new Mock<IPedidoRepository>(); repositoryMock .Setup(r => r.GetByIdAsync(pedido.Id, default)) .ReturnsAsync(pedido); var unitOfWorkMock = new Mock<IUnitOfWork>(); var handler = new ConfirmarPedidoHandler(repositoryMock.Object, unitOfWorkMock.Object); // Act var result = await handler.HandleAsync(new ConfirmarPedidoCommand(pedido.Id)); // Assert result.Status.Should().Be("Confirmado"); result.Total.Should().Be(100m); unitOfWorkMock.Verify(u => u.CommitAsync(default), Times.Once); } [Fact] public async Task Deve_lancar_exception_para_pedido_sem_itens() { var pedido = Pedido.Criar(Guid.NewGuid()); // sem itens var repositoryMock = new Mock<IPedidoRepository>(); repositoryMock .Setup(r => r.GetByIdAsync(pedido.Id, default)) .ReturnsAsync(pedido); var handler = new ConfirmarPedidoHandler( repositoryMock.Object, new Mock<IUnitOfWork>().Object); // Act & Assert await handler.Awaiting(h => h.HandleAsync(new ConfirmarPedidoCommand(pedido.Id))) .Should().ThrowAsync<DomainException>() .WithMessage("*sem itens*"); }
}
Quando NÃO usar Clean Architecture
Clean Architecture tem custo de setup e overhead de abstrações. Em alguns contextos, esse custo não se paga:
- MVPs e provas de conceito: velocidade importa mais que extensibilidade. Use estrutura simples e refatore depois se o produto validar.
- CRUDs simples: um sistema que é basicamente "salva no banco e lê do banco" não tem regras de domínio para isolar — Clean Architecture vira burocracia.
- Times com menos de 2 desenvolvedores: o overhead de navegação entre camadas pode custar mais do que o benefício em times muito pequenos.
O critério prático: se você tem regras de negócio complexas que merecem ser testadas isoladamente, Clean Architecture paga o custo. Se o negócio é simples e o sistema é basicamente persistência de dados, considere uma arquitetura mais flat.
Conclusão
Clean Architecture não é um destino — é uma decisão de design que tem trade-offs reais. Aplicada nos contextos certos, ela resulta em código testável, manutenível e que sobrevive a mudanças de tecnologia sem reescrita total. Aplicada dogmaticamente em sistemas simples, vira overhead sem benefício.
O princípio essencial a guardar: dependências sempre apontam para dentro. Se você garantir isso, a variação na estrutura de pastas e nomenclatura é detalhe.
Se você está estruturando um novo projeto .NET ou revisando a arquitetura de um sistema existente, a Neryx tem experiência com Clean Architecture, DDD e .NET em produção. Consultoria inicial gratuita.