Arquitetura C# .NET Backend

Clean Architecture no .NET: guia prático com exemplos em C#

Como implementar Clean Architecture em projetos ASP.NET Core. Camadas, dependências, casos de uso, repositórios e injeção de dependência com exemplos.

N
Neryx Digital Architects
2 de outubro de 2025
12 min de leitura
210 profissionais leram
Categoria: Arquitetura de Software Público: Tech leads e devs definindo base arquitetural Etapa: Aprendizado

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) =&gt; _context = context;

public async Task&lt;Pedido?&gt; GetByIdAsync(Guid id, CancellationToken ct = default)
    =&gt; await _context.Pedidos
        .Include(p =&gt; p.Itens)
        .FirstOrDefaultAsync(p =&gt; p.Id == id, ct);

public async Task AddAsync(Pedido pedido, CancellationToken ct = default)
    =&gt; await _context.Pedidos.AddAsync(pedido, ct);

public async Task UpdateAsync(Pedido pedido, CancellationToken ct = default)
    =&gt; _context.Pedidos.Update(pedido);

public async Task&lt;IReadOnlyList&lt;Pedido&gt;&gt; GetByClienteIdAsync(Guid clienteId, CancellationToken ct = default)
    =&gt; await _context.Pedidos
        .Where(p =&gt; 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)
    =&gt; _handler = handler;

[HttpPost("{id}/confirmar")]
public async Task&lt;IActionResult&gt; 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&lt;IPedidoRepository, PedidoRepository&gt;();
    services.AddScoped&lt;IUnitOfWork, UnitOfWork&gt;();

    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&lt;IPedidoRepository&gt;();
    repositoryMock
        .Setup(r =&gt; r.GetByIdAsync(pedido.Id, default))
        .ReturnsAsync(pedido);

    var unitOfWorkMock = new Mock&lt;IUnitOfWork&gt;();
    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 =&gt; 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&lt;IPedidoRepository&gt;();
    repositoryMock
        .Setup(r =&gt; r.GetByIdAsync(pedido.Id, default))
        .ReturnsAsync(pedido);

    var handler = new ConfirmarPedidoHandler(
        repositoryMock.Object, new Mock&lt;IUnitOfWork&gt;().Object);

    // Act & Assert
    await handler.Awaiting(h =&gt; h.HandleAsync(new ConfirmarPedidoCommand(pedido.Id)))
        .Should().ThrowAsync&lt;DomainException&gt;()
        .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.

Esse cenário pede clareza antes de executar

Quando a decisão é grande demais para adivinhar, o Discovery ajuda a mapear arquitetura, riscos e roadmap com mais segurança.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.