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:
| Camada | Depende de | O que vive aqui |
|---|---|---|
| Domain | nada | Entidades, enums, value objects e interfaces (ex.: IProductRepository). O coração do sistema. |
| Application | Domain | Casos de uso (services), DTOs, validações. Orquestra o domínio através das interfaces. |
| Infrastructure | Application, Domain | Implementações concretas: DbContext do EF Core, repositórios, integrações externas. |
| API | Application, Infrastructure | Controllers, 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
classlibcria umClass1.csem cada projeto, e owebapicria umWeatherForecast. Apague esses arquivos (Class1.cs,WeatherForecast.cseControllers/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ássicadotnet add referenceusada 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 oDbContext).--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 doDbContext— toda configuração de persistência fica na Infrastructure (via Fluent API, como noOnModelCreating). - 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
UpdatedoProduct).
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
Migratordedicado para aplicar migrations em CI/CD sem subir a API. - Projetos
Workerpara 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 Architecture —
dotnet new install Ardalis.CleanArchitecture.Templatee depoisdotnet new clean-arch -o MinhaSolucao. - Jason Taylor Clean Architecture —
dotnet new install Clean.Architecture.Solution.Templatee depoisdotnet 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.