Todo projeto começa organizado. Aí entra o prazo, a regra de negócio nova, o “só um if rápido no controller” — e seis meses depois a lógica de negócio está espalhada entre controllers, o acesso ao banco vazou para dentro das regras e ninguém consegue escrever um teste sem subir um Postgres. A Clean Architecture existe para impedir esse apodrecimento: ela separa o que o seu sistema faz de como ele faz (banco, framework, APIs externas).

Este é um guia de referência baby steps: ao final você terá um esqueleto de projeto .NET funcional, rodando, com as quatro camadas separadas e uma rota de exemplo ponta a ponta. Cada comando aqui foi verificado no .NET 10 SDK, e nada depende de biblioteca paga. É exatamente o esqueleto que usamos como base nos nossos produtos (Turnno, HookScope, FiscalKeep) — adaptado para você reproduzir do zero.

O que é Clean Architecture (em 1 minuto)

A ideia central é uma só, chamada Regra da Dependência: o código-fonte só pode depender “para dentro”. As camadas de fora (banco, web, frameworks) conhecem as de dentro (regras de negócio), mas nunca o contrário. O núcleo do sistema não sabe que existe Postgres, ASP.NET ou Stripe — ele só conhece abstrações.

        ┌───────────────────────────────────────────┐
        │                   API                      │  ← controllers, Program.cs
        │   ┌───────────────────────────────────┐    │
        │   │          Infrastructure           │    │  ← EF Core, repositórios, e-mail
        │   │   ┌───────────────────────────┐   │    │
        │   │   │        Application         │   │    │  ← casos de uso (services), DTOs
        │   │   │   ┌───────────────────┐    │   │    │
        │   │   │   │      Domain       │    │   │    │  ← entidades, regras, interfaces
        │   │   │   └───────────────────┘    │   │    │
        │   │   └───────────────────────────┘   │    │
        │   └───────────────────────────────────┘    │
        └───────────────────────────────────────────┘
              As dependências apontam para dentro →

Traduzindo para projetos .NET, são quatro camadas:

CamadaDepende deO que vive aqui
DomainnadaEntidades, enums, value objects e interfaces (ex.: IProductRepository). O coração do sistema.
ApplicationDomainCasos de uso (services), DTOs, validações. Orquestra o domínio através das interfaces.
InfrastructureApplication, DomainImplementações concretas: DbContext do EF Core, repositórios, integrações externas.
APIApplication, InfrastructureControllers, middlewares e o Program.cs — onde tudo é “amarrado” (composition root).

Repare no detalhe que faz a mágica funcionar: a interface IProductRepository fica no Domain, mas a implementação ProductRepository (que usa EF Core) fica na Infrastructure. O domínio define o contrato; a infraestrutura o cumpre. Isso é a inversão de dependência — e é o que permite trocar o banco, ou testar as regras sem banco nenhum.

Pré-requisitos

Você só precisa do SDK do .NET 10 e de um PostgreSQL. Confirme o SDK:

dotnet --version
# 10.0.100

Para o banco, o jeito mais rápido é subir um Postgres no Docker:

docker run --name cleanarch-db \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=cleanarch \
  -p 5432:5432 -d postgres:17

Passo 1 — Criar a solução e os projetos

Crie uma pasta para o projeto e gere a solução mais os quatro projetos. Vamos usar uma entidade Product como exemplo para atravessar todas as camadas.

mkdir CleanArch && cd CleanArch

# Solução (no .NET 10 o formato padrão é o novo .slnx, em XML)
dotnet new sln -n CleanArch

# As três camadas internas são bibliotecas de classe
dotnet new classlib -n Domain         -o src/Domain
dotnet new classlib -n Application    -o src/Application
dotnet new classlib -n Infrastructure -o src/Infrastructure

# A API usa controllers (o padrão do template é Minimal API; --use-controllers muda isso)
dotnet new webapi --use-controllers -n Api -o src/Api

Adicione todos à solução:

dotnet sln CleanArch.slnx add src/Domain/Domain.csproj
dotnet sln CleanArch.slnx add src/Application/Application.csproj
dotnet sln CleanArch.slnx add src/Infrastructure/Infrastructure.csproj
dotnet sln CleanArch.slnx add src/Api/Api.csproj

Limpeza: o template classlib cria um Class1.cs em cada projeto, e o webapi cria um WeatherForecast. Apague esses arquivos (Class1.cs, WeatherForecast.cs e Controllers/WeatherForecastController.cs) antes de continuar.

Passo 2 — Configurar as referências entre projetos

Aqui é onde a Regra da Dependência deixa de ser teoria e vira algo que o compilador garante. Cada add reference abaixo aponta “para dentro”:

# Application depende de Domain
dotnet add src/Application/Application.csproj reference src/Domain/Domain.csproj

# Infrastructure depende de Application (e, por transitividade, de Domain)
dotnet add src/Infrastructure/Infrastructure.csproj reference src/Application/Application.csproj

# API depende de Application e Infrastructure
dotnet add src/Api/Api.csproj reference src/Application/Application.csproj
dotnet add src/Api/Api.csproj reference src/Infrastructure/Infrastructure.csproj

Pronto — agora, se alguém tentar acessar o DbContext (que está na Infrastructure) de dentro do Domain, o projeto nem compila. A arquitetura passou a ser autoexplicativa.

Dica (.NET 10): o SDK 10 introduziu a sintaxe “substantivo primeiro” dotnet reference add ... --project .... A forma clássica dotnet add reference usada acima continua funcionando e é compatível com .NET 8, 9 e 10 — por isso a preferimos aqui.

Passo 3 — Instalar os pacotes NuGet

Só três dependências, todas gratuitas e open source:

# Validação de entrada (Apache 2.0, gratuita)
dotnet add src/Application package FluentValidation

# EF Core + provider do PostgreSQL + ferramentas de migration
dotnet add src/Infrastructure package Microsoft.EntityFrameworkCore
dotnet add src/Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add src/Infrastructure package Microsoft.EntityFrameworkCore.Design

Fique atento ao licenciamento. Muitos tutoriais de Clean Architecture usam MediatR e AutoMapper. Desde julho de 2025 ambos passaram a ser comerciais (gratuitos só para empresas com receita abaixo de US$ 5M). Neste guia não usamos nenhum dos dois: casos de uso viram services simples e o mapeamento é feito à mão. É mais claro para aprender e não te prende a uma dependência paga por engano. Se quiser um substituto de MediatR gratuito, veja o Mediator (martinothamar); para mapeamento, o Mapster.

Passo 4 — Domain: entidades e contratos

O Domain é o único projeto sem dependências. Comece com uma classe base de entidade (com Id e auditoria — o mesmo padrão que usamos em produção):

// src/Domain/Common/BaseEntity.cs
namespace Domain.Common;

public abstract class BaseEntity
{
    public Guid Id { get; protected set; } = Guid.NewGuid();
    public DateTime CreatedAt { get; protected set; } = DateTime.UtcNow;
    public DateTime? UpdatedAt { get; protected set; }
}

A entidade Product carrega comportamento, não só dados — é isso que diferencia um modelo rico de um saco de propriedades:

// src/Domain/Entities/Product.cs
using Domain.Common;

namespace Domain.Entities;

public sealed class Product : BaseEntity
{
    public string Name { get; private set; } = string.Empty;
    public decimal Price { get; private set; }

    private Product() { }   // construtor usado pelo EF Core

    public Product(string name, decimal price)
    {
        Name = name;
        Price = price;
    }

    public void Update(string name, decimal price)
    {
        Name = name;
        Price = price;
        UpdatedAt = DateTime.UtcNow;
    }
}

Agora o contrato do repositório. Ele vive no Domain (a implementação virá depois, na Infrastructure):

// src/Domain/Repositories/IProductRepository.cs
using Domain.Entities;

namespace Domain.Repositories;

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task AddAsync(Product product, CancellationToken ct = default);
    Task SaveChangesAsync(CancellationToken ct = default);
}

Passo 5 — Application: casos de uso

Esta camada orquestra o domínio. Primeiro, um resultado padronizado para os services devolverem sucesso ou erro sem lançar exceção para tudo (usamos um ServiceResult<T> assim nos nossos projetos):

// src/Application/Common/ServiceResult.cs
namespace Application.Common;

public sealed class ServiceResult<T>
{
    public bool Success { get; }
    public T? Data { get; }
    public string? Error { get; }

    private ServiceResult(bool success, T? data, string? error)
        => (Success, Data, Error) = (success, data, error);

    public static ServiceResult<T> Ok(T data) => new(true, data, null);
    public static ServiceResult<T> Fail(string error) => new(false, default, error);
}

Os DTOs — a fronteira de entrada e saída da aplicação (nunca exponha a entidade direto na API):

// src/Application/Products/ProductDtos.cs
namespace Application.Products;

public record CreateProductRequest(string Name, decimal Price);
public record ProductResponse(Guid Id, string Name, decimal Price);

A validação com FluentValidation:

// src/Application/Products/CreateProductValidator.cs
using FluentValidation;

namespace Application.Products;

public sealed class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
        RuleFor(x => x.Price).GreaterThan(0);
    }
}

E o caso de uso em si. Note que ele depende só de abstrações (IProductRepository, IValidator) — nada de EF Core aqui:

// src/Application/Products/IProductService.cs
using Application.Common;

namespace Application.Products;

public interface IProductService
{
    Task<ServiceResult<ProductResponse>> CreateAsync(CreateProductRequest request, CancellationToken ct = default);
    Task<ServiceResult<ProductResponse>> GetByIdAsync(Guid id, CancellationToken ct = default);
}
// src/Application/Products/ProductService.cs
using Application.Common;
using Domain.Entities;
using Domain.Repositories;
using FluentValidation;

namespace Application.Products;

public sealed class ProductService(
    IProductRepository repository,
    IValidator<CreateProductRequest> validator) : IProductService
{
    public async Task<ServiceResult<ProductResponse>> CreateAsync(CreateProductRequest request, CancellationToken ct = default)
    {
        var validation = await validator.ValidateAsync(request, ct);
        if (!validation.IsValid)
            return ServiceResult<ProductResponse>.Fail(
                string.Join("; ", validation.Errors.Select(e => e.ErrorMessage)));

        var product = new Product(request.Name, request.Price);
        await repository.AddAsync(product, ct);
        await repository.SaveChangesAsync(ct);

        return ServiceResult<ProductResponse>.Ok(
            new ProductResponse(product.Id, product.Name, product.Price));
    }

    public async Task<ServiceResult<ProductResponse>> GetByIdAsync(Guid id, CancellationToken ct = default)
    {
        var product = await repository.GetByIdAsync(id, ct);
        return product is null
            ? ServiceResult<ProductResponse>.Fail("Produto não encontrado.")
            : ServiceResult<ProductResponse>.Ok(new ProductResponse(product.Id, product.Name, product.Price));
    }
}

Cada camada registra os próprios serviços em um DependencyInjection.cs — um método de extensão que a API vai chamar. Esse é o padrão que mantém o Program.cs limpo:

// src/Application/DependencyInjection.cs
using Application.Products;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;

namespace Application;

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        services.AddScoped<IProductService, ProductService>();
        services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();
        return services;
    }
}

Passo 6 — Infrastructure: EF Core e repositório

Aqui moram os detalhes técnicos. O DbContext:

// src/Infrastructure/Persistence/AppDbContext.cs
using Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace Infrastructure.Persistence;

public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(e =>
        {
            e.HasKey(p => p.Id);
            e.Property(p => p.Name).IsRequired().HasMaxLength(200);
            e.Property(p => p.Price).HasPrecision(18, 2);
        });

        base.OnModelCreating(modelBuilder);
    }
}

A implementação do contrato que definimos no Domain:

// src/Infrastructure/Persistence/ProductRepository.cs
using Domain.Entities;
using Domain.Repositories;
using Microsoft.EntityFrameworkCore;

namespace Infrastructure.Persistence;

public sealed class ProductRepository(AppDbContext context) : IProductRepository
{
    public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default)
        => await context.Products.FirstOrDefaultAsync(p => p.Id == id, ct);

    public async Task AddAsync(Product product, CancellationToken ct = default)
        => await context.Products.AddAsync(product, ct);

    public async Task SaveChangesAsync(CancellationToken ct = default)
        => await context.SaveChangesAsync(ct);
}

E o AddInfrastructure, que registra o banco e liga a interface à implementação:

// src/Infrastructure/DependencyInjection.cs
using Domain.Repositories;
using Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
    {
        var connectionString = configuration.GetConnectionString("DefaultConnection")
            ?? throw new InvalidOperationException("Connection string 'DefaultConnection' não configurada.");

        services.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString));
        services.AddScoped<IProductRepository, ProductRepository>();

        return services;
    }
}

Passo 7 — API: amarrando tudo (composition root)

O Program.cs é o único lugar que conhece todas as camadas. É aqui que a injeção de dependência resolve IProductRepository para ProductRepository, IProductService para ProductService, e assim por diante:

// src/Api/Program.cs
using Application;
using Infrastructure;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddApplication();                              // camada Application
builder.Services.AddInfrastructure(builder.Configuration);     // camada Infrastructure
builder.Services.AddOpenApi();                                 // documento OpenAPI (nativo no .NET 10)

var app = builder.Build();

if (app.Environment.IsDevelopment())
    app.MapOpenApi();

app.UseHttpsRedirection();
app.MapControllers();
app.Run();

A connection string vai no appsettings.json:

{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Database=cleanarch;Username=postgres;Password=postgres"
  }
}

E o controller — fino de propósito. Ele só recebe a requisição, chama o service e traduz o ServiceResult em resposta HTTP. Zero regra de negócio aqui:

// src/Api/Controllers/ProductsController.cs
using Application.Products;
using Microsoft.AspNetCore.Mvc;

namespace Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public sealed class ProductsController(IProductService service) : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> Create(CreateProductRequest request, CancellationToken ct)
    {
        var result = await service.CreateAsync(request, ct);
        return result.Success
            ? CreatedAtAction(nameof(GetById), new { id = result.Data!.Id }, result.Data)
            : BadRequest(new { error = result.Error });
    }

    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
    {
        var result = await service.GetByIdAsync(id, ct);
        return result.Success
            ? Ok(result.Data)
            : NotFound(new { error = result.Error });
    }
}

Passo 8 — Criar o banco com migrations

Instale a ferramenta do EF Core (uma vez só) e gere a primeira migration. Como o DbContext está na Infrastructure mas quem “roda” é a API, apontamos os dois projetos:

dotnet tool install --global dotnet-ef

dotnet ef migrations add InitialCreate \
  --project src/Infrastructure \
  --startup-project src/Api

dotnet ef database update \
  --project src/Infrastructure \
  --startup-project src/Api
  • --project → onde os arquivos de migration são gravados (Infrastructure, onde vive o DbContext).
  • --startup-project → o app que o EF executa para ler a configuração e a DI (a API).

Passo 9 — Rodar e testar

dotnet run --project src/Api

Anote a porta que aparece no console (algo como http://localhost:5087) e crie um produto:

curl -X POST http://localhost:5087/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Teclado Mecânico","price":349.90}'

Resposta:

{ "id": "b1f2...", "name": "Teclado Mecânico", "price": 349.90 }

Copie o id e busque o produto:

curl http://localhost:5087/api/products/b1f2...

Uma requisição inválida devolve o erro de validação, sem tocar no banco:

curl -X POST http://localhost:5087/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"","price":-5}'
# 400 → { "error": "'Name' must not be empty.; 'Price' must be greater than '0'." }

Funcionou. Você tem uma requisição atravessando as quatro camadas, com o banco isolado atrás de uma interface e a regra de negócio testável sem infraestrutura.

Erros comuns (e como evitar)

  • Vazar EF Core para o Domain. Entidades de domínio devem ser POCOs. Nada de [Table]/[Column] nas classes do Domain nem de herdar do DbContext — toda configuração de persistência fica na Infrastructure (via Fluent API, como no OnModelCreating).
  • Inverter a direção da dependência. Se você se pegar adicionando uma referência da Infrastructure para dentro da API, ou do Domain para qualquer coisa, parou de fazer Clean Architecture. A referência só aponta para dentro.
  • Colocar regra de negócio no controller. O controller orquestra e traduz HTTP. Toda decisão de negócio pertence ao Domain ou ao service da Application.
  • Over-engineering. Clean Architecture tem custo. Para um CRUD trivial ou um MVP descartável, quatro projetos podem ser exagero — comece simples e separe quando a complexidade justificar.
  • Modelo anêmico. Entidades que são só getters e setters, com toda a lógica em services, jogam fora metade do valor do domínio. Dê comportamento às entidades (como o método Update do Product).

Como levamos isso para produção na DevPlus

O esqueleto acima é a base real dos nossos produtos — Turnno, HookScope e FiscalKeep seguem exatamente esse padrão: camadas Domain, Application, Infrastructure e Api, casos de uso como services (sem MediatR), ServiceResult<T>, FluentValidation e PostgreSQL via Npgsql. Conforme o sistema cresce, a gente evolui a estrutura:

  • Separar a Infrastructure por tecnologia (ex.: Infrastructure.Postgres), isolando migrations e configuração de banco.
  • Projeto Migrator dedicado para aplicar migrations em CI/CD sem subir a API.
  • Projetos Worker para processamento em background (consumidores de fila, jobs agendados).
  • Multi-tenancy, auditoria e soft delete na base de entidades e no repositório genérico.

Nada disso muda o núcleo — só adiciona anéis externos. É essa a força da arquitetura: ela cresce por fora sem tocar nas regras de negócio.

Próximos passos

Se quiser acelerar com um template pronto, dois são referência na comunidade .NET:

  • Ardalis Clean Architecturedotnet new install Ardalis.CleanArchitecture.Template e depois dotnet new clean-arch -o MinhaSolucao.
  • Jason Taylor Clean Architecturedotnet new install Clean.Architecture.Solution.Template e depois dotnet new ca-sln -o MinhaSolucao.

Só um aviso: ambos usam MediatR (o de Jason Taylor também usa AutoMapper), que hoje são comerciais para empresas maiores. Sabendo montar o esqueleto na mão — como fizemos aqui — você decide conscientemente quais dependências adotar, em vez de herdar as escolhas do template.

Conclusão

Clean Architecture não é sobre ter muitos projetos; é sobre uma regra simples e inegociável — as dependências apontam para dentro. Com as quatro camadas separadas, seu domínio fica testável, a troca de banco vira detalhe e o sistema envelhece bem. O melhor jeito de internalizar isso é fazer o esqueleto do zero uma vez, exatamente como neste guia.

Quer modernizar ou estruturar um projeto .NET com arquitetura limpa e sustentável? A DevPlus constrói e mantém sistemas .NET em produção seguindo esse padrão. Fale com a gente.

Veja também: Clean Code em C#: práticas essenciais, C# 14: novidades práticas da linguagem e o guia de migração do .NET 8 para o .NET 10.