A Clean Architecture é uma forma de estruturar uma aplicação C# de modo que as regras de negócio fiquem no centro e não dependam de nada. O banco de dados, o framework web e todos os outros detalhes dependem delas, nunca o contrário.
No .NET, isso normalmente significa quatro projetos: Domain, Application, Infrastructure e Api.
Vamos começar pela teoria. Depois, vamos construir uma pequena API de reserva de ingressos, um projeto de cada vez, executá-la, testá-la e fazer o compilador proteger as regras por nós.
Vamos muito mais a fundo no nosso curso de Clean Architecture. Nele, você constrói um sistema completo de venda de ingressos no .NET 10, dos eventos de domínio aos testes de integração.
O que é Clean Architecture em C#?
A Clean Architecture organiza o código de acordo com o quanto ele está perto do negócio. Robert C. Martin a descreveu em um post de blog de 2012 como um conjunto de círculos, com as regras de negócio no meio e os detalhes técnicos do lado de fora.
Uma única regra mantém tudo de pé. A Regra da Dependência (Dependency Rule) diz que as dependências do código-fonte apontam para dentro, em direção às regras de negócio, de modo que o código interno nunca menciona um banco de dados, um framework web ou qualquer outro detalhe externo.
Em uma solução C#, os círculos costumam virar quatro projetos. O Domain guarda as entidades e as regras de negócio, e não referencia nada. O Application guarda os casos de uso e as interfaces de que eles precisam do mundo exterior.
O Infrastructure implementa essas interfaces com EF Core, arquivos ou serviços externos. O Api recebe as requisições HTTP e conecta tudo na inicialização.
Essa estrutura compensa de duas formas. As regras que mais importam para você rodam em testes unitários sem banco de dados, e uma mudança técnica fica contida em um único projeto externo.
Martin enuncia a regra em uma frase no post The Clean Architecture: “Essa regra diz que as dependências do código-fonte só podem apontar para dentro.”
Veja o diagrama clássico, redesenhado:

Os nomes dos anéis vêm do post original e não correspondem um a um aos projetos .NET. Entities vira o nosso projeto Domain, e Use Cases vira o Application.
Os dois anéis externos, Interface Adapters e Frameworks and Drivers, acabam divididos entre o Infrastructure e o Api. Esses dois projetos ficam lado a lado, do lado de fora.
Que problema a Clean Architecture resolve?
Vamos partir de um código que tem o problema. Este endpoint de API mínima reserva ingressos para um evento.
Ele funciona e é curto, mas não o copie, porque é o design que estamos prestes a desmontar:
app.MapPost("/api/events/{eventId:int}/reservations",
async (int eventId, ReserveRequest request, TicketingDbContext db) =>
{
var ev = await db.Events.FindAsync(eventId);
if (ev is null)
return Results.NotFound();
if (ev.TicketsSold + request.Quantity > ev.Capacity)
return Results.Conflict("Sold out");
ev.TicketsSold += request.Quantity;
await db.SaveChangesAsync();
return Results.Ok(ev);
});
O Event com que ele trabalha é um simples amontoado de dados, e isso faz parte do problema:
public class Event
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int Capacity { get; set; }
public int TicketsSold { get; set; }
}
Aqui, uma única lambda faz tudo. Ela carrega o evento com o EF Core e verifica a regra de capacidade.
Depois, altera a entidade, salva e retorna a entidade do banco de dados como resposta HTTP.
Então, o que há de errado com ela? Por enquanto, nada. O problema começa quando a aplicação cresce:
- Não dá para testar a regra que impede vender além da capacidade sem um banco de dados, porque ela vive dentro de um handler HTTP que precisa de um
TicketingDbContext. - A regra não tem dono.
TicketsSoldtem um acessador set público, então o endpoint de reembolso que você escrever no mês que vem pode alterar esse valor sem verificar nada. - A tabela do banco de dados é o contrato da API.
Results.Ok(ev)serializa a entidade, então, quando você renomear uma coluna, todos os clientes veem a mudança. - Escolhas técnicas vazam para o código de negócio. Se você trocar o acesso a dados do EF Core pelo Dapper, vai reescrever essa lambda, com regra e tudo.
Os quatro problemas têm uma causa só. A regra de negócio, a única linha que nos impede de vender além da capacidade, depende dos detalhes em volta dela: EF Core, SQLite, HTTP e JSON.
A Clean Architecture inverte essa seta, de modo que os detalhes dependam da regra:

Você vai ouvir muito que a Clean Architecture permite trocar de banco de dados. Poucas equipes chegam a fazer isso, então é um motivo fraco para adotá-la.
Em vez disso, dois benefícios aparecem toda semana. As suas regras mais importantes rodam em testes que levam milissegundos, e uma mudança técnica fica contida em um único projeto.
O que é a Regra da Dependência?
A Regra da Dependência diz que as dependências do código-fonte apontam para dentro. Uma classe em um projeto interno não pode usar, nem sequer mencionar pelo nome, uma classe de um projeto externo.
Então, se os casos de uso não podem referenciar o código do banco de dados, como um caso de uso carrega alguma coisa do banco?
Por meio de uma interface que pertence ao projeto interno. O projeto Application declara aquilo de que precisa, por exemplo:
public interface IEventRepository
{
Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default);
}
O Infrastructure implementa essa interface com o EF Core, e o contêiner de injeção de dependência (DI) entrega essa implementação ao caso de uso em tempo de execução.
Então, em tempo de execução, a chamada viaja para fora, do caso de uso até o banco de dados. A referência em tempo de compilação aponta para dentro, do Infrastructure para a interface no Application.
No diagrama da Clean Architecture, as setas que apontam para dentro mostram só o segundo tipo, as dependências que o compilador verifica. Se você as ler como chamadas em tempo de execução, o diagrama inteiro parece estar ao contrário.
Uma interface que pertence a uma camada interna se chama porta (port). A classe externa que a implementa é um adaptador (adapter).
Se isso lembra o princípio da inversão de dependência, você acertou. A Regra da Dependência aplica esse princípio a projetos inteiros.
O que vamos construir?
Uma funcionalidade de um sistema de venda de ingressos: a reserva de ingressos para um evento. Ela tem uma regra de negócio, aquela com que toda bilheteria se preocupa: nunca vendemos mais ingressos do que um evento tem.
A API ganha dois endpoints:
POST /api/events/{eventId}/reservationsreserva ingressos.GET /api/events/{eventId}informa quantos ingressos restam.
Isso é pequeno o bastante para acompanhar de uma vez só, e grande o bastante para mostrar cada camada fazendo o seu trabalho. Construímos de dentro para fora: primeiro o Domain, depois o Application, o Infrastructure e o Api.
Passo 1: criar a solução e os quatro projetos
Você vai precisar do SDK do .NET 10. Os comandos abaixo usam as formas dele em que o substantivo vem primeiro, como dotnet solution add e dotnet reference add.
SDKs anteriores à versão 9.0.300 não têm todas essas formas e usam dotnet sln add e dotnet add reference no lugar delas.
Vamos criar a solução e um projeto por camada:
dotnet new sln -n EventTicketing dotnet new classlib -n EventTicketing.Domain dotnet new classlib -n EventTicketing.Application dotnet new classlib -n EventTicketing.Infrastructure dotnet new web -n EventTicketing.Api dotnet solution add EventTicketing.Domain EventTicketing.Application EventTicketing.Infrastructure EventTicketing.Api
Cada projeto tem uma função:
EventTicketing.Domainguarda o negócio: a entidade, a regra dela e os erros que essa regra pode produzir.EventTicketing.Applicationguarda os casos de uso, uma classe para cada coisa que a aplicação faz.EventTicketing.Infrastructureconversa com o mundo exterior, que, no nosso caso, é um banco de dados SQLite acessado com o EF Core.EventTicketing.Apié a borda web. Ele recebe as requisições HTTP e conecta os outros projetos.
O modelo de biblioteca de classes adiciona um Class1.cs a cada biblioteca. Não precisamos deles, então exclua os três. No SDK do .NET 10, dotnet new sln cria EventTicketing.slnx, o formato de solução XML mais novo.
Agora vêm as referências entre os projetos. Estes três comandos são a Regra da Dependência, escrita uma única vez:
dotnet reference add EventTicketing.Domain --project EventTicketing.Application dotnet reference add EventTicketing.Application --project EventTicketing.Infrastructure dotnet reference add EventTicketing.Application EventTicketing.Infrastructure --project EventTicketing.Api
O Application referencia o Domain. O Infrastructure referencia o Application e, por meio dele, o Domain. O Api referencia o Application e o Infrastructure, e o Domain não referencia nada.

Por que o Api referencia o Infrastructure? Para que o Program.cs possa registrar as classes do EF Core que ficam por trás das interfaces do Application.
O Program.cs é a raiz de composição (composition root), o único lugar em que os serviços de todas as camadas se encontram. Nenhum endpoint usa um tipo do Infrastructure.
Por fim, cada projeto recebe os pacotes de que precisa, e nada mais:
dotnet package add Microsoft.Extensions.DependencyInjection.Abstractions --version 10.0.12 --project EventTicketing.Application dotnet package add Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12 --project EventTicketing.Infrastructure
O Application recebe só as abstrações de DI, para poder registrar os próprios serviços. O EF Core vai para o Infrastructure e para nenhum outro lugar. Fixamos as versões para que os comandos continuem funcionando depois que o .NET 11 for lançado.
Passo 2: construir a camada Domain
O projeto Domain guarda o negócio. Ele não referencia nenhum outro projeto nem nenhum pacote, então o código dele não tem como alcançar o EF Core ou o ASP.NET Core.
A nossa regra fica em uma entidade Event, e, antes de escrevermos Event, precisamos de uma coisa: uma forma de dizer não.
Como o padrão Result trata as falhas?
Um evento esgotado não é um defeito do programa. Clientes tentam comprar os últimos lugares todos os dias, e cada uma dessas requisições é o sistema funcionando corretamente.
Então Event não deveria lançar uma exceção quando os ingressos se esgotam. Ele deveria retornar um resultado que diga o que deu errado. Classificamos toda falha com uma pergunta:

Se um usuário bem-comportado pode causar isso numa terça-feira qualquer, estamos diante de uma falha esperada, e a retornamos como valor.
Caso contrário, algo está quebrado, e lançamos uma exceção. Ingressos esgotados e um ID de evento desconhecido são esperados. Uma quantidade negativa que passou pela validação, ou uma queda do banco de dados, não são.
Crie EventTicketing.Domain/Result.cs:
namespace EventTicketing.Domain;
public enum ErrorType
{
NotFound,
Conflict
}
public sealed record Error(string Code, string Description, ErrorType Type);
public class Result
{
protected Result(Error? error) => Error = error;
public Error? Error { get; }
public bool IsSuccess => Error is null;
public static Result Success() => new(null);
public static implicit operator Result(Error error) => new(error);
}
public sealed class Result<TValue> : Result
{
private readonly TValue? _value;
private Result(TValue? value, Error? error)
: base(error) => _value = value;
public TValue Value => IsSuccess
? _value!
: throw new InvalidOperationException("A failed result has no value.");
public static implicit operator Result<TValue>(TValue value) =>
new(value, null);
public static implicit operator Result<TValue>(Error error) =>
new(default, error);
}
Aqui, Error descreve uma falha com um código, uma mensagem e um tipo. ErrorType indica mais tarde à borda da API qual status HTTP enviar, e o Domain nunca fica sabendo que o HTTP existe.
Result é um sucesso ou uma falha que carrega um Error.
Result<TValue> acrescenta um valor para as operações que retornam um. Value lança uma exceção se você o ler a partir de um resultado com falha, então é preciso verificar IsSuccess antes.
Os operadores implícitos permitem que um método use return com um valor simples ou com um Error, e o compilador o envolve no Result certo. É isso que mantém os casos de uso curtos.
Por que escrever o nosso próprio? São menos de 40 linhas, e controlamos cada uma delas. Se você preferir usar uma biblioteca, ErrorOr, FluentResults e Ardalis.Result resolvem o mesmo problema.
Para saber mais sobre o padrão em si, veja o nosso artigo sobre o padrão Result na Web API do .NET.
A entidade Event e sua regra
Os erros ficam ao lado da entidade que eles descrevem. Crie EventTicketing.Domain/EventErrors.cs:
namespace EventTicketing.Domain;
public static class EventErrors
{
public static Error NotFound(int eventId) =>
new("Event.NotFound", $"Event {eventId} was not found.", ErrorType.NotFound);
public static Error SoldOut(int ticketsLeft, int requested) =>
new("Event.SoldOut", $"Only {ticketsLeft} tickets left, {requested} requested.", ErrorType.Conflict);
}
Cada erro tem um código estável, como Event.SoldOut, e uma mensagem para pessoas. O código cliente deve verificar o código do erro, porque o texto da mensagem pode ser reescrito mais tarde.
Agora, a entidade em si. Crie EventTicketing.Domain/Event.cs:
namespace EventTicketing.Domain;
public sealed class Event
{
private Event(string name, int capacity)
{
Name = name;
Capacity = capacity;
}
public int Id { get; private set; }
public string Name { get; private set; }
public int Capacity { get; private set; }
public int TicketsSold { get; private set; }
public int TicketsLeft => Capacity - TicketsSold;
public static Event Create(string name, int capacity)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
return new Event(name, capacity);
}
public Result Reserve(int quantity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
if (quantity > TicketsLeft)
return EventErrors.SoldOut(TicketsLeft, quantity);
TicketsSold += quantity;
return Result.Success();
}
}
Aqui, todos os acessadores set são privados, então o código fora da classe não consegue mudar os números. Create() é a única forma de criar um evento, e Reserve() é a única forma de vender ingressos.
Reserve() tem duas formas de dizer não. Uma quantidade menor ou igual a zero é um bug no chamador, então ThrowIfNegativeOrZero() lança uma exceção.
Um evento esgotado é um dia comum na bilheteria, então o método retorna EventErrors.SoldOut em vez disso. TicketsSold só muda quando a regra é atendida.
Event é propositalmente pequeno. Um domínio maior também teria objetos de valor, como um tipo Money que recusa quantias negativas.
Também teria agregados, grupos de objetos que mudam juntos por trás de um único ponto de entrada. O nosso artigo sobre design de agregados trata deles no .NET.
Passo 3: escrever os casos de uso na camada Application
O projeto Application guarda os casos de uso. Cada caso de uso é uma classe. Um caso de uso que altera dados carrega aquilo de que precisa, pede ao Domain que faça o trabalho e salva o resultado.
Um caso de uso não pode falar diretamente com o banco de dados, porque o Application não pode referenciar o Infrastructure. Então ele declara aquilo de que precisa como uma porta. Crie EventTicketing.Application/IEventRepository.cs:
using EventTicketing.Domain;
namespace EventTicketing.Application;
public interface IEventRepository
{
Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default);
Task<EventAvailability?> GetAvailabilityAsync(int eventId, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
GetByIdAsync() carrega um evento que estamos prestes a alterar. GetAvailabilityAsync() lê o que o endpoint de disponibilidade mostra, na forma de um record EventAvailability que vamos escrever daqui a pouco. SaveChangesAsync() faz o commit. O Infrastructure implementa os três no Passo 4.
Onde as interfaces de repositório devem ficar? Em alguns modelos, você vai encontrá-las no Domain; em outros, no Application.
Nós as mantemos no Application, ao lado dos casos de uso que as chamam, porque o código do Domain nunca carrega nem salva nada.
Se os seus serviços de domínio precisarem consultar dados, o Domain também é um bom lugar. Escolha um lugar e mantenha a consistência.
Agora, o primeiro caso de uso. Um comando é um record que carrega a entrada, e um handler faz o trabalho. Crie EventTicketing.Application/ReserveTickets.cs:
using EventTicketing.Domain;
namespace EventTicketing.Application;
public sealed record ReserveTicketsCommand(int EventId, int Quantity);
public sealed record ReservationResponse(int EventId, int TicketsReserved, int TicketsLeft);
public sealed class ReserveTicketsHandler(IEventRepository events)
{
public async Task<Result<ReservationResponse>> HandleAsync(
ReserveTicketsCommand command, CancellationToken cancellationToken = default)
{
var ev = await events.GetByIdAsync(command.EventId, cancellationToken);
if (ev is null)
return EventErrors.NotFound(command.EventId);
var reservation = ev.Reserve(command.Quantity);
if (!reservation.IsSuccess)
return reservation.Error!;
await events.SaveChangesAsync(cancellationToken);
return new ReservationResponse(ev.Id, command.Quantity, ev.TicketsLeft);
}
}
Aqui, o handler carrega o evento por meio da porta e retorna o erro Event.NotFound quando o evento não existe.
Em seguida, pede à entidade que reserve os ingressos e, se a entidade disser não, o handler repassa esse erro sem alteração.
Só em caso de sucesso ele chama SaveChangesAsync(). Por fim, monta um ReservationResponse à mão, com uma única expressão new que o compilador verifica.
O handler nunca verifica a capacidade por conta própria. Event é o dono dessa regra, e o handler apenas encadeia os passos: carregar, reservar, salvar, responder.

E-book gratuito
Sua Web API está pronta para produção?
33 itens para verificar antes de implantá-la, com a correção de cada um. Um PDF gratuito de 76 páginas para .NET 10.
O e-book está em inglês.
Baixe o checklist gratuitoPDF gratuito. Um único e-mail para enviá-lo. Cancele a inscrição quando quiser.
O segundo caso de uso responde a uma pergunta e não altera nada. Crie EventTicketing.Application/GetEventAvailability.cs:
using EventTicketing.Domain;
namespace EventTicketing.Application;
public sealed record GetEventAvailabilityQuery(int EventId);
public sealed record EventAvailability(int EventId, string Name, int TicketsLeft);
public sealed class GetEventAvailabilityHandler(IEventRepository events)
{
public async Task<Result<EventAvailability>> HandleAsync(
GetEventAvailabilityQuery query, CancellationToken cancellationToken = default)
{
var availability = await events.GetAvailabilityAsync(query.EventId, cancellationToken);
if (availability is null)
return EventErrors.NotFound(query.EventId);
return availability;
}
}
Esta consulta nunca carrega a entidade Event. Ela pede à porta exatamente os três valores de que a resposta precisa.
Por que a diferença? Os comandos passam pela entidade para que a regra dela seja executada, e as consultas a ignoram. Essa divisão tem nome, CQRS, e ganha uma seção própria depois que executarmos a aplicação.
Por fim, o projeto registra os próprios handlers. Crie EventTicketing.Application/DependencyInjection.cs:
using Microsoft.Extensions.DependencyInjection;
namespace EventTicketing.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddScoped<ReserveTicketsHandler>();
services.AddScoped<GetEventAvailabilityHandler>();
return services;
}
}
O Program.cs vai chamar AddApplication() sem saber quais handlers existem. Quando você adicionar um caso de uso, registre-o aqui, no projeto que é dono dele.
Precisamos do MediatR para usar Clean Architecture?
Não. Os nossos endpoints vão injetar o handler de que precisam e chamar HandleAsync(). Esse é todo o mecanismo de despacho, e o compilador verifica cada chamada.
O MediatR coloca um mediador entre o endpoint e o handler e adiciona um pipeline.
O pipeline executa código em volta de cada requisição, como logging, validação ou uma transação, para que você não precise repeti-lo em cada handler.
Sem o MediatR, você consegue o mesmo efeito com decoradores em volta dos handlers ou com filtros de endpoint no lado da API.
Há também uma questão de licenciamento. Segundo o FAQ de licenciamento do fornecedor, o MediatR 13.0.0 e as versões posteriores têm licença dupla.
Você pode usá-las sob a Reciprocal Public License 1.5, sem custo, se aceitar as obrigações recíprocas (copyleft) dessa licença, ou comprar uma licença comercial.
Uma licença Community gratuita cobre as organizações que atendem a todas as quatro condições do fornecedor. A versão 12.5.0, lançada em abril de 2025, é a última sob a licença Apache 2.0.
O MediatR continua sendo uma escolha razoável quando você quer o pipeline dele e a licença serve para o seu caso. A Clean Architecture não o exige. Se quiser seguir esse caminho, veja o nosso passo a passo de CQRS com MediatR.
Passo 4: adicionar a persistência na camada Infrastructure
Onde fica a persistência na Clean Architecture? No projeto Infrastructure. É lá que o EF Core vive, junto com tudo o mais que conversa com o mundo fora do processo, como arquivos, filas, e-mail e outros serviços.
Primeiro, o DbContext. Crie EventTicketing.Infrastructure/TicketingDbContext.cs:
using EventTicketing.Domain;
using Microsoft.EntityFrameworkCore;
namespace EventTicketing.Infrastructure;
public sealed class TicketingDbContext(DbContextOptions<TicketingDbContext> options)
: DbContext(options)
{
public DbSet<Event> Events => Set<Event>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Event>(builder =>
{
builder.Property(e => e.Name).HasMaxLength(200);
builder.HasData(
new { Id = 1, Name = "Clean Architecture Live", Capacity = 100, TicketsSold = 0 },
new { Id = 2, Name = "Tiny Jazz Club Night", Capacity = 2, TicketsSold = 0 });
});
}
}
A classe Event não tem nenhum atributo do EF Core, e todo o mapeamento fica aqui. É isso que significa ignorância de persistência (persistence ignorance): a entidade não sabe como é armazenada.
Ainda assim, o EF Core a carrega e salva, inclusive com o construtor privado e os acessadores set privados. HasData() cria dois eventos iniciais (seed) para termos algo a reservar: “Clean Architecture Live”, com 100 lugares, e “Tiny Jazz Club Night”, com dois.
Em seguida, o repositório implementa a porta do Application. Crie EventTicketing.Infrastructure/EventRepository.cs:
using EventTicketing.Application;
using EventTicketing.Domain;
using Microsoft.EntityFrameworkCore;
namespace EventTicketing.Infrastructure;
public sealed class EventRepository(TicketingDbContext dbContext) : IEventRepository
{
public Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default) =>
dbContext.Events.FirstOrDefaultAsync(e => e.Id == eventId, cancellationToken);
public Task<EventAvailability?> GetAvailabilityAsync(int eventId, CancellationToken cancellationToken = default) =>
dbContext.Events
.AsNoTracking()
.Where(e => e.Id == eventId)
.Select(e => new EventAvailability(e.Id, e.Name, e.Capacity - e.TicketsSold))
.FirstOrDefaultAsync(cancellationToken);
public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
dbContext.SaveChangesAsync(cancellationToken);
}
Aqui, GetByIdAsync() carrega um Event rastreado, então o EF Core percebe quando Reserve() o altera. GetAvailabilityAsync() projeta diretamente no record de resposta, com o controle de alterações (change tracking) desligado. SaveChangesAsync() grava tudo o que mudou.
O Infrastructure registra os próprios serviços em um único método. Crie EventTicketing.Infrastructure/DependencyInjection.cs:
using EventTicketing.Application;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace EventTicketing.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services, string connectionString)
{
services.AddDbContext<TicketingDbContext>(options => options.UseSqlite(connectionString));
services.AddScoped<IEventRepository, EventRepository>();
return services;
}
}
AddInfrastructure() recebe a string de conexão como parâmetro. Ler a configuração é trabalho do host, então o Infrastructure nunca precisa saber de onde vem a string.
Você também vai ver soluções que colocam a persistência em um projeto Persistence separado, ao lado do Infrastructure. Isso também funciona, já que os dois projetos ficam do lado de fora e apontam para dentro.
Vale a pena usar o padrão Repository com o EF Core?
É uma das perguntas mais discutidas na Clean Architecture, e os dois lados têm seus argumentos.
Os argumentos contra começam pelo próprio EF Core. A referência do DbContext da Microsoft diz: “O DbContext é uma combinação dos padrões Unit Of Work e Repository.”
Então, um IRepository<T> genérico com GetAll(), Add(), Update() e Delete() para cada entidade copia o DbSet<T> à mão, um método de cada vez.
Os argumentos a favor vêm da Regra da Dependência. O nosso projeto Application não referencia o EF Core, então um handler não pode receber um DbContext.
Um repositório pequeno dá a cada caso de uso uma operação com nome, mantém o EF Core do lado de fora e dá aos seus testes um lugar para encaixar um fake.
A nossa regra: escreva um repositório quando ele mantiver o EF Core fora das camadas internas e falar a língua do caso de uso. Dispense o genérico.
O nosso repositório também expõe SaveChangesAsync(), o que não é problema enquanto cada caso de uso salva por meio de um único repositório. Quando um caso de uso salva alterações em vários repositórios, dê ao salvamento uma interface própria. Esse é o padrão Unit of Work.
Passo 5: expor os casos de uso pela API
O projeto Api é onde o HTTP chega. Ele transforma as requisições em comandos e consultas, chama os handlers e transforma os resultados deles em respostas HTTP.
Comece pelo mapeamento de erros. Crie EventTicketing.Api/ResultExtensions.cs:
using EventTicketing.Domain;
namespace EventTicketing.Api;
public static class ResultExtensions
{
public static IResult ToProblem(this Result result)
{
var error = result.Error
?? throw new InvalidOperationException("A successful result is not a problem.");
var statusCode = error.Type switch
{
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status500InternalServerError
};
return TypedResults.Problem(statusCode: statusCode, title: error.Code, detail: error.Description);
}
}
Nenhum outro arquivo da solução mapeia tipos de erro para códigos de status. O Domain diz “conflito”, e só a borda da API sabe que um conflito é um 409.
TypedResults.Problem() grava um corpo no formato problem details, o formato JSON padrão para erros de APIs HTTP, e o nosso código de erro vai para title. Saiba mais sobre o formato em ProblemDetails no ASP.NET Core.
Agora, os endpoints. Crie EventTicketing.Api/EventEndpoints.cs:
using System.ComponentModel.DataAnnotations;
using EventTicketing.Application;
namespace EventTicketing.Api;
public sealed record ReserveTicketsRequest([property: Range(1, 20)] int Quantity);
public static class EventEndpoints
{
public static void MapEventEndpoints(this IEndpointRouteBuilder app)
{
var events = app.MapGroup("/api/events");
events.MapGet("/{eventId:int}", async (
int eventId,
GetEventAvailabilityHandler handler,
CancellationToken cancellationToken) =>
{
var result = await handler.HandleAsync(new GetEventAvailabilityQuery(eventId), cancellationToken);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
});
events.MapPost("/{eventId:int}/reservations", async (
int eventId,
ReserveTicketsRequest request,
ReserveTicketsHandler handler,
CancellationToken cancellationToken) =>
{
var command = new ReserveTicketsCommand(eventId, request.Quantity);
var result = await handler.HandleAsync(command, cancellationToken);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
});
}
}
O cliente envia só uma quantidade, já que o ID do evento vai na URL.
Por que dois records que parecem iguais? ReserveTicketsRequest descreve o corpo HTTP, e ReserveTicketsCommand descreve a entrada do caso de uso. Hoje eles são parecidos, e mais tarde podem mudar por motivos diferentes.
Cada endpoint monta uma consulta ou um comando, chama o handler e transforma o Result em uma resposta. Não há lógica de negócio aqui para você testar.
O Api precisa de uma string de conexão para o SQLite. Substitua o EventTicketing.Api/appsettings.json por:
{
"ConnectionStrings": {
"Ticketing": "Data Source=tickets.db"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Por fim, o Program.cs conecta tudo. Substitua o EventTicketing.Api/Program.cs por:
using EventTicketing.Api;
using EventTicketing.Application;
using EventTicketing.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration.GetConnectionString("Ticketing")!);
builder.Services.AddProblemDetails();
builder.Services.AddValidation();
var app = builder.Build();
app.UseExceptionHandler();
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<TicketingDbContext>();
dbContext.Database.EnsureCreated();
}
app.MapEventEndpoints();
app.Run();
AddApplication() e AddInfrastructure() registram os serviços de cada camada.
AddProblemDetails() e UseExceptionHandler() transformam qualquer exceção não tratada em uma resposta 500 no formato problem details. Saiba mais sobre como tratar erros em um só lugar no nosso artigo sobre tratamento global de erros.
AddValidation() ativa a validação nativa que o .NET 10 trouxe para as APIs mínimas.
EnsureCreated() cria o banco de dados SQLite e os dois eventos iniciais na primeira execução. Isso serve para uma demonstração; uma aplicação real usaria migrações do EF Core. Se o EF Core em si é novidade para você, a nossa série sobre EF Core começa do básico.
O Program.cs é o único arquivo que enxerga todas as camadas. É isso que o torna a raiz de composição, e é o único motivo pelo qual o Api referencia o Infrastructure. Se a injeção de dependência no ASP.NET Core é novidade para você, comece por ela.
Por que APIs mínimas e não controladores? A visão geral de APIs da Microsoft recomenda APIs mínimas para projetos novos.
Na Clean Architecture, um endpoint só traduz entre o HTTP e um caso de uso, então uma lambda basta. Controladores também funcionam, e, se você os preferir, nada fora do projeto Api muda.
Onde fica a validação?
Em dois lugares, porque “validação” cobre dois trabalhos diferentes.
A validação de entrada verifica o formato de uma requisição. A quantidade está entre 1 e 20? Isso não exige nenhum conhecimento de negócio, então fica na borda, antes que um caso de uso seja executado.
O nosso atributo [Range] mais o AddValidation() fazem exatamente isso.
Uma regra de negócio continuaria valendo mesmo sem nenhuma requisição HTTP em volta. “Não podemos vender mais ingressos do que temos” é uma delas.
Ela fica no Domain, dentro do método que altera o estado. É por isso que ela vive em Event.Reserve().
Então por que Reserve() ainda lança uma exceção com zero? Porque o Domain não pode presumir que todo chamador fez a validação. Uma tarefa em segundo plano ou um endpoint futuro poderia chamar Reserve(0), e isso seria um bug no chamador.
Em aplicações maiores, é comum ver a validação de entrada passar para a camada Application com o FluentValidation, para que um caso de uso receba as mesmas verificações, não importa quem o chame.
Para dois endpoints, atributos bastam.
Passo 6: executar a aplicação e enviar algumas requisições
Na pasta da solução, inicie a API em uma porta fixa:
dotnet run --project EventTicketing.Api -- --urls http://localhost:5000
Quando o console mostrar Now listening on: http://localhost:5000, a API está pronta. Na primeira execução, EnsureCreated() também cria EventTicketing.Api/tickets.db com os nossos dois eventos. Para começar do zero depois, pare a aplicação e exclua esse arquivo.
A forma mais fácil de enviar requisições é um arquivo .http, que o Visual Studio, o Rider e o VS Code (com a extensão REST Client) conseguem executar. Crie EventTicketing.Api/EventTicketing.Api.http:
GET http://localhost:5000/api/events/1
###
POST http://localhost:5000/api/events/1/reservations
Content-Type: application/json
{ "quantity": 3 }
###
POST http://localhost:5000/api/events/2/reservations
Content-Type: application/json
{ "quantity": 3 }
###
POST http://localhost:5000/api/events/1/reservations
Content-Type: application/json
{ "quantity": 0 }
###
GET http://localhost:5000/api/events/99
A primeira requisição consulta o primeiro evento, e todos os 100 ingressos ainda estão lá:
{"eventId":1,"name":"Clean Architecture Live","ticketsLeft":100}
Reservar três ingressos retorna a nova contagem:
{"eventId":1,"ticketsReserved":3,"ticketsLeft":97}
Agora, vamos pedir três ingressos para o Tiny Jazz Club Night, que tem dois lugares. A resposta é um 409 Conflict com um corpo no formato problem details:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Event.SoldOut",
"status": 409,
"detail": "Only 2 tickets left, 3 requested.",
"traceId": "00-ce108ce4bc6489ca9b29b3ea9c78fe26-ebc9be53eb756f7b-00"
}
A falha viajou de Event.Reserve(), passando pelo handler, até o endpoint, como um valor de retorno comum. Nada lançou exceção, e nada foi salvo.
Uma quantidade igual a zero nunca chega ao nosso código. AddValidation() lê o atributo [Range] e responde 400 por conta própria:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Quantity": [
"The field Quantity must be between 1 and 20."
]
},
"traceId": "00-ecfcd14241c21757f5f5ebe6d761e553-406ab76e75f2823e-00"
}
E um evento desconhecido volta do mesmo jeito que o esgotado, como um 404 com o título Event.NotFound:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Event.NotFound",
"status": 404,
"detail": "Event 99 was not found.",
"traceId": "00-e0c75250298c4435886b8fcc54d7d64c-d2ac6be30f33ddae-00"
}
Como uma requisição flui pelas camadas?
Vamos acompanhar a requisição com ingressos esgotados, do cliente até a entidade e de volta:

No passo 3, o handler chama o Infrastructure, para fora, e mesmo assim nenhum arquivo do Application menciona uma classe do Infrastructure.
A resposta de ingressos esgotados, no passo 6, é um valor de retorno comum. O handler pula SaveChangesAsync() ao retornar mais cedo, então nada chega ao banco de dados.
Como o CQRS se encaixa na Clean Architecture?
O CQRS, sigla de Command Query Responsibility Segregation, separa o código que altera dados do código que os lê.
Você não precisa de dois bancos de dados nem de uma biblioteca de mediador para isso. Um banco de dados com dois caminhos pelo código já é CQRS. A nossa aplicação tem os dois caminhos.
A reserva é um comando. Ela carrega a entidade Event, porque a regra de capacidade precisa ser executada, e depois salva.
A verificação de disponibilidade é uma consulta. Ela nunca toca na entidade e pede só o que a resposta mostra.
Dá para ver a diferença no SQL. O EF Core registra em log cada comando que executa, então o console mostra os dois caminhos. Esta é a consulta:
SELECT "e"."Id", "e"."Name", "e"."Capacity" - "e"."TicketsSold" FROM "Events" AS "e" WHERE "e"."Id" = @eventId LIMIT 1
E esta é a reserva, que carrega a entidade inteira e depois salva a única coluna que mudou:
SELECT "e"."Id", "e"."Capacity", "e"."Name", "e"."TicketsSold" FROM "Events" AS "e" WHERE "e"."Id" = @eventId LIMIT 1 UPDATE "Events" SET "TicketsSold" = @p0 WHERE "Id" = @p1 RETURNING 1;
A consulta calcula os ingressos restantes no banco de dados e não rastreia nada. O comando carrega Event para que Reserve() possa verificar a regra, e depois o EF Core atualiza só TicketsSold.
Em uma aplicação maior, as leituras costumam ganhar uma porta própria e modelos de leitura próprios. A divisão continua a mesma.
Passo 7: testar as regras sem banco de dados
As camadas tornam os testes baratos. Vamos adicionar um projeto de teste ao lado dos outros:
dotnet new classlib -n EventTicketing.UnitTests dotnet solution add EventTicketing.UnitTests
Exclua o Class1.cs dele e substitua o EventTicketing.UnitTests/EventTicketing.UnitTests.csproj por:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EventTicketing.Application\EventTicketing.Application.csproj" />
</ItemGroup>
</Project>
O projeto usa o xUnit v3 por meio do pacote xunit.v3.mtp-off, que mantém o executor de testes clássico, então um simples dotnet test funciona.
Se, em vez disso, você criar projetos de teste com dotnet new xunit3, fique de olho no global.json que ele cria ou atualiza. Esse arquivo muda o dotnet test para o Microsoft Testing Platform, e o modelo também tem net8.0 como alvo.
Se você executar dotnet test a partir de uma pasta em que esse arquivo não é encontrado, o SDK do .NET 10 interrompe a execução com este erro:
error : Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. If you use dotnet test, you should opt-in to the new dotnet test experience. For more information, see https://aka.ms/dotnet-test-mtp-error
Agora, os testes. Os testes do Domain só precisam da entidade. Crie EventTicketing.UnitTests/EventTests.cs:
using EventTicketing.Domain;
namespace EventTicketing.UnitTests;
public class EventTests
{
[Fact]
public void Reserve_WhenEnoughTicketsAreLeft_SellsThem()
{
var ev = Event.Create("Clean Architecture Live", capacity: 100);
var result = ev.Reserve(3);
Assert.True(result.IsSuccess);
Assert.Equal(97, ev.TicketsLeft);
}
[Fact]
public void Reserve_WhenTooFewTicketsAreLeft_ReturnsSoldOutAndSellsNothing()
{
var ev = Event.Create("Tiny Jazz Club Night", capacity: 2);
var result = ev.Reserve(3);
Assert.False(result.IsSuccess);
Assert.Equal("Event.SoldOut", result.Error!.Code);
Assert.Equal(0, ev.TicketsSold);
}
}
Esses testes criam um Event e chamam o método Reserve() dele. Não há banco de dados, nem biblioteca de mocks, nem código de preparação.
Os testes do Application substituem a porta por um fake, uma pequena classe que nós mesmos escrevemos e que implementa a porta com o código mais simples que funciona. Crie EventTicketing.UnitTests/ReserveTicketsHandlerTests.cs:
using EventTicketing.Application;
using EventTicketing.Domain;
namespace EventTicketing.UnitTests;
public class ReserveTicketsHandlerTests
{
[Fact]
public async Task HandleAsync_WhenTicketsAreAvailable_ReservesAndSaves()
{
var events = new FakeEventRepository(Event.Create("Clean Architecture Live", capacity: 100));
var handler = new ReserveTicketsHandler(events);
var result = await handler.HandleAsync(
new ReserveTicketsCommand(EventId: 1, Quantity: 2), TestContext.Current.CancellationToken);
Assert.True(result.IsSuccess);
Assert.Equal(98, result.Value.TicketsLeft);
Assert.Equal(1, events.SaveCount);
}
[Fact]
public async Task HandleAsync_WhenEventIsSoldOut_SavesNothing()
{
var events = new FakeEventRepository(Event.Create("Tiny Jazz Club Night", capacity: 1));
var handler = new ReserveTicketsHandler(events);
var result = await handler.HandleAsync(
new ReserveTicketsCommand(EventId: 1, Quantity: 2), TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess);
Assert.Equal("Event.SoldOut", result.Error!.Code);
Assert.Equal(0, events.SaveCount);
}
}
internal sealed class FakeEventRepository(Event? ev) : IEventRepository
{
public int SaveCount { get; private set; }
public Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default) =>
Task.FromResult(ev);
public Task<EventAvailability?> GetAvailabilityAsync(int eventId, CancellationToken cancellationToken = default) =>
Task.FromResult<EventAvailability?>(null);
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
SaveCount++;
return Task.CompletedTask;
}
}
FakeEventRepository retorna o evento que lhe entregarmos e conta quantas vezes o handler salvou. O handler não percebe a diferença:


E-book gratuito
Sua Web API está pronta para produção?
33 itens para verificar antes de implantá-la, com a correção de cada um. Um PDF gratuito de 76 páginas para .NET 10.
O e-book está em inglês.
Baixe o checklist gratuitoPDF gratuito. Um único e-mail para enviá-lo. Cancele a inscrição quando quiser.
O primeiro teste reserva dois de 100 ingressos e verifica se o handler salvou exatamente uma vez. O segundo pede dois ingressos quando só resta um e verifica se nada foi salvo.
Execute os testes na pasta da solução:
dotnet test
A saída termina com o resumo:
Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4, Duration: 127 ms - EventTicketing.UnitTests.dll (net10.0)
Na nossa máquina, os quatro testes rodaram em 127 milissegundos. Nenhum deles precisou de banco de dados.
Você poderia usar uma biblioteca de mocks em vez de fakes. Para uma porta tão pequena, o fake ocupa 16 linhas e se lê como o código que ele substitui. Se preferir uma biblioteca, veja o nosso guia sobre o NSubstitute.
As asserções usam a classe Assert do próprio xUnit. Deixamos o FluentAssertions de fora porque a versão 8, lançada em janeiro de 2025, é paga para uso comercial. A versão 7 continua sob a Apache 2.0.
Testes unitários não conseguem provar que as peças se encaixam: o mapeamento do EF Core, os registros de DI ou o JSON. Esse é o trabalho dos testes de integração com WebApplicationFactory, que este artigo deixa de fora.
Passo 8: impor a Regra da Dependência com testes de arquitetura
O compilador já impõe metade dela. Vamos quebrar a regra de propósito. É uma mudança que você não deve manter no seu código.
Adicionamos no topo de Event.cs uma linha using que aponta para fora:
using EventTicketing.Application;
A compilação falha:
Event.cs(1,22): error CS0234: The type or namespace name 'Application' does not exist in the namespace 'EventTicketing' (are you missing an assembly reference?)
O Domain não tem referência ao Application, então o compilador nem enxerga o namespace. As referências entre projetos transformam a Regra da Dependência em erro de compilação.
Então, por que precisamos de testes? Porque uma referência de projeto não é o único caminho de entrada.
Nada impede alguém de adicionar o pacote do EF Core diretamente ao Application e escrever uma classe como esta. É exatamente o vazamento que queremos pegar. Então, não faça isso:
using EventTicketing.Domain;
using Microsoft.EntityFrameworkCore;
namespace EventTicketing.Application;
public sealed class SneakyReportHandler(DbContext dbContext)
{
public Task<int> CountEventsAsync(CancellationToken cancellationToken = default) =>
dbContext.Set<Event>().CountAsync(cancellationToken);
}
Desta vez, a compilação funciona. Um projeto pode referenciar qualquer pacote NuGet que quiser, e o compilador não sabe quais pacotes quebram a nossa arquitetura.
Os testes de arquitetura fecham essa brecha. Vamos adicionar um segundo projeto de teste:
dotnet new classlib -n EventTicketing.ArchitectureTests dotnet solution add EventTicketing.ArchitectureTests
Exclua o Class1.cs dele e substitua o EventTicketing.ArchitectureTests/EventTicketing.ArchitectureTests.csproj por:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="NetArchTest.Rules" Version="1.3.2" />
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EventTicketing.Application\EventTicketing.Application.csproj" />
</ItemGroup>
</Project>
O projeto referencia o Application, que traz o Domain junto, e adiciona o NetArchTest.Rules. Essa biblioteca lê os assemblies compilados e verifica do que cada tipo depende. Crie EventTicketing.ArchitectureTests/DependencyRuleTests.cs:
using EventTicketing.Application;
using EventTicketing.Domain;
using NetArchTest.Rules;
namespace EventTicketing.ArchitectureTests;
public class DependencyRuleTests
{
[Fact]
public void Domain_DependsOnNoOtherLayer()
{
var result = Types.InAssembly(typeof(Event).Assembly)
.ShouldNot()
.HaveDependencyOnAny("EventTicketing.Application", "EventTicketing.Infrastructure", "EventTicketing.Api")
.GetResult();
Assert.True(result.IsSuccessful, "Offending types: " + string.Join(", ", result.FailingTypeNames ?? []));
}
[Fact]
public void Application_DoesNotDependOnInfrastructureOrTheWeb()
{
var result = Types.InAssembly(typeof(ReserveTicketsHandler).Assembly)
.ShouldNot()
.HaveDependencyOnAny("EventTicketing.Infrastructure", "EventTicketing.Api",
"Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore")
.GetResult();
Assert.True(result.IsSuccessful, "Offending types: " + string.Join(", ", result.FailingTypeNames ?? []));
}
}
Cada teste carrega o assembly de uma camada por meio de um tipo que vive nele, como Event para o Domain. HaveDependencyOnAny() lista os namespaces que a camada não pode usar, e GetResult() informa cada tipo que quebra a regra.
Com a classe sorrateira no lugar, a execução dos testes falha e aponta o nome dela:
Failed EventTicketing.ArchitectureTests.DependencyRuleTests.Application_DoesNotDependOnInfrastructureOrTheWeb [312 ms] Error Message: Offending types: EventTicketing.Application.SneakyReportHandler Failed! - Failed: 1, Passed: 1, Skipped: 0, Total: 2, Duration: 366 ms - EventTicketing.ArchitectureTests.dll (net10.0)
Exclua a classe e a referência ao pacote, e os dois testes voltam a passar. Se você os executar na CI junto com os testes unitários, a regra se mantém sem que ninguém precise perceber o vazamento em uma revisão de código.
Veja como os dois guardiões dividem o trabalho:

O NetArchTest lê o código compilado, então enxerga os tipos que uma classe realmente usa. Uma diretiva using não utilizada não deixa nada no assembly, então o teste não consegue vê-la, e ela também não causa dano algum.
O NetArchTest.Rules não tem uma versão nova desde a 1.3.2, de maio de 2021, e continua funcionando bem no .NET 10, como mostra a saída acima.
Se preferir uma biblioteca com versões recentes, o ArchUnitNET é uma alternativa. A versão mais recente dele saiu em agosto de 2026.
Como fica a solução finalizada?
Esta é a solução finalizada, com os arquivos que criamos em cada projeto:
EventTicketing.slnx
EventTicketing.Domain/
Event.cs, EventErrors.cs, Result.cs
EventTicketing.Application/
DependencyInjection.cs, GetEventAvailability.cs, IEventRepository.cs, ReserveTickets.cs
EventTicketing.Infrastructure/
DependencyInjection.cs, EventRepository.cs, TicketingDbContext.cs
EventTicketing.Api/
EventEndpoints.cs, EventTicketing.Api.http, Program.cs, ResultExtensions.cs, appsettings.json
EventTicketing.UnitTests/
EventTests.cs, ReserveTicketsHandlerTests.cs
EventTicketing.ArchitectureTests/
DependencyRuleTests.cs
O Domain não sabe nada sobre os outros três, e os testes provam isso a cada execução.
Onde fica o meu código?
As quatro camadas parecem organizadas em um diagrama, e o código real é mais bagunçado. Onde fica uma chamada a um provedor de pagamentos? O ID do usuário atual? A hora atual?
Fazemos quatro perguntas, em ordem, e paramos no primeiro sim:

Quando um código parece pertencer a duas camadas, verifique se ele não está, na verdade, fazendo dois trabalhos. A validação foi o exemplo que já vimos: a verificação do formato da requisição fica na borda, e a regra de capacidade fica no Domain.
Veja onde os trechos de código mais comuns vão parar:
| Código | Camada | Por que fica ali |
|---|---|---|
Entidades e as regras que elas protegem, como Event.Reserve() | Domain | A regra continuaria valendo sem nenhuma aplicação em volta |
Objetos de valor, como um tipo Money | Domain | Um conceito de negócio que valida os próprios valores |
Erros de domínio, como EventErrors.SoldOut | Domain | A regra que falha sabe o que deu errado |
| Leitura da hora atual | Domain ou Application, por meio de TimeProvider | O .NET 8 adicionou TimeProvider, então nenhuma camada precisa de uma interface de relógio própria |
Handlers de casos de uso, como ReserveTicketsHandler | Application | Eles encadeiam os passos de uma operação |
| Interfaces de repositório e de Unit of Work | Application | Os casos de uso que as chamam são donos delas |
| Uma interface para o usuário atual | Application | O caso de uso precisa do usuário, e o Api implementa a interface a partir de HttpContext |
DbContext, mapeamentos do EF Core e repositórios | Infrastructure | Eles conversam com o banco de dados |
| Clientes de e-mail, de pagamento e de armazenamento de arquivos | Infrastructure | Eles conversam com algo fora do processo |
| Endpoints e records de requisição | Api | Eles existem porque os clientes falam HTTP |
Mapeamento de ErrorType para códigos de status | Api | Só a borda sabe o que é um 409 |
Registro de serviços no Program.cs | Api | A raiz de composição é o único lugar em que os serviços de todas as camadas se encontram |
A tabela é um ponto de partida. Quando algo não se encaixa, dê um nome ao que o código faz antes de decidir onde ele fica.
Quais são os erros mais comuns na Clean Architecture?
Estes cinco passam na revisão de código e, mesmo assim, causam problemas mais tarde.
Um modelo de domínio anêmico
Entidades com acessadores set públicos e sem comportamento, mais uma classe de serviço que guarda as regras. Nada impede o próximo handler de ignorar o serviço.
É o endpoint emaranhado do início deste artigo, espalhado por mais arquivos.
Lógica de negócio no handler
Um handler que verifica a capacidade por conta própria assumiu o trabalho da entidade. Esta é a versão a evitar:
if (ev.TicketsSold + command.Quantity > ev.Capacity)
return EventErrors.SoldOut(ev.TicketsLeft, command.Quantity);
ev.TicketsSold += command.Quantity;
Com o nosso Event, ela nem compila:
ReserveTickets.cs(21,9): error CS0200: Property or indexer 'Event.TicketsSold' cannot be assigned to -- it is read only
O acessador set privado é o que torna a regra impossível de contornar. Quando um handler precisa dos dados de uma entidade para tomar uma decisão, essa decisão normalmente pertence à entidade.
Um repositório genérico
Um IRepository<T> genérico copia o DbSet<T> à mão e não dá nome a nenhuma das operações que os seus casos de uso executam. Em vez disso, dê a cada agregado um repositório pequeno, com métodos nomeados.
Retornar IQueryable de um repositório
Um método de repositório que retorna IQueryable<Event> entrega ao Application o provedor de consultas do EF Core.
As consultas começam a crescer dentro dos handlers, e a fronteira para a qual a porta foi criada desaparece. Em vez disso, retorne resultados ou modelos de leitura.
Uma interface para tudo
Uma interface se justifica quando algo precisa variar por trás dela, como um repositório real e um fake.
Os nossos handlers não têm interface nenhuma, e os testes não sentem falta delas.
Quando não usar Clean Architecture?
Quando a sua aplicação não tem regras de negócio que valha a pena proteger.
A Clean Architecture tem um custo diário. Adicionar uma funcionalidade mexe em quatro projetos, e acompanhar uma requisição significa abrir cinco ou seis arquivos.
Até uma leitura simples passa por uma consulta, um handler, uma porta e um repositório.
Esse custo compensa quando o domínio tem regras que nunca podem ser quebradas, como reservas e pagamentos.
Não compensa em uma ferramenta administrativa de CRUD nem em um serviço pequeno que copia dados de um lugar para outro.
A principal alternativa é a Vertical Slice Architecture, que organiza o código por funcionalidade em vez de por camada. Veja as nossas duas funcionalidades, cortadas dos dois jeitos:

Uma fatia (slice) mantém o endpoint, a lógica e o acesso a dados de uma funcionalidade em um só lugar.
Esta é uma funcionalidade que o nosso exemplo não tem, uma lista de eventos com ingressos disponíveis, escrita como uma fatia:
using EventTicketing.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace EventTicketing.Api;
public static class ListAvailableEvents
{
public sealed record AvailableEvent(int EventId, string Name, int TicketsLeft);
public static void MapListAvailableEvents(this IEndpointRouteBuilder app) =>
app.MapGet("/api/events", async (TicketingDbContext dbContext, CancellationToken cancellationToken) =>
await dbContext.Events
.AsNoTracking()
.Where(e => e.TicketsSold < e.Capacity)
.Select(e => new AvailableEvent(e.Id, e.Name, e.Capacity - e.TicketsSold))
.ToListAsync(cancellationToken));
}
É um arquivo só, sem handler, sem porta e sem repositório, e o endpoint consulta TicketingDbContext diretamente.
Executamos isso dentro do nosso exemplo e, depois de esgotar a noite de jazz, GET /api/events retornou só o outro evento:
[{"eventId":1,"name":"Clean Architecture Live","ticketsLeft":100}]
A fatia paga pela sua brevidade quebrando uma das nossas regras. Ela vive no projeto Api e usa uma classe do Infrastructure diretamente, o que os nossos endpoints em camadas nunca fazem.
Para uma leitura sem regras de negócio, é uma troca justa.
Você não precisa escolher um único estilo para a aplicação inteira.
Um meio-termo comum mantém um núcleo protegido para as funcionalidades com regras de verdade, como as nossas reservas, e escreve as leituras simples como fatias finas.
O nosso artigo sobre Vertical Slice Architecture mostra o estilo por completo. E, quando uma aplicação cresce e passa a ter várias áreas de negócio, um monólito modular permite que cada módulo escolha o estilo que combina com ele.
Quantos projetos são necessários?
Quatro é uma convenção. O guia da Microsoft sobre arquiteturas comuns de aplicações web usa três: um projeto Application Core, que guarda o modelo de negócio e as interfaces, mais o Infrastructure e um projeto de UI.
O que os projetos separados trazem é a imposição da regra. O compilador rejeita código que aponta para fora, como vimos com o CS0234.
Em um único projeto, você pode manter a mesma regra com pastas, namespaces e testes de arquitetura, já que o NetArchTest também consegue verificar namespaces.
Começaríamos com quatro projetos quando o domínio tem regras de verdade e mais de uma pessoa trabalha nele. Para um serviço pequeno, um projeto com pastas e alguns testes de arquitetura é mais que suficiente.
Qual a diferença entre a Clean Architecture e as arquiteturas Onion, Hexagonal e em N camadas?
A arquitetura Onion e a arquitetura Hexagonal, também chamada de portas e adaptadores (ports and adapters), compartilham a ideia central: código de negócio no meio, com as dependências apontando para dentro.
Elas diferem principalmente nos nomes e em onde traçam as fronteiras internas.
Comparamos a Onion e a Clean Architecture lado a lado em um artigo separado. O nosso artigo sobre arquitetura Hexagonal trata de portas e adaptadores em C#.
A arquitetura em N camadas é a exceção. Ela empilha a apresentação sobre a lógica de negócio, e esta sobre o acesso a dados. Todas as dependências apontam para baixo, em direção ao banco de dados.
A Clean Architecture pode ter o mesmo número de caixas, mas inverte as setas, de modo que o banco de dados acaba do lado de fora.
Clean Architecture é o mesmo que Clean Code?
Não. Os dois nomes vêm de Robert C. Martin, e é por isso que são confundidos.
O Clean Code trata do interior de uma classe: nomes claros e funções pequenas e legíveis.
A Clean Architecture trata das fronteiras entre as partes de uma aplicação e da direção das dependências entre elas. Uma base de código pode ter uma sem a outra.
Conclusão
Construímos uma solução Clean Architecture em C# a partir de uma pasta vazia. Ao longo do caminho, vimos:
- A Regra da Dependência, e por que as chamadas em tempo de execução podem viajar para fora enquanto as referências apontam para dentro
- Quatro projetos: um Domain que protege a sua regra, um Application com casos de uso e uma porta, um Infrastructure com o EF Core e um Api que conecta tudo
- O padrão Result para falhas esperadas, convertidas em problem details na borda
- Comandos e consultas como dois caminhos pelo mesmo código, o que é CQRS sem mediador
- Testes unitários com um fake e testes de arquitetura que falham assim que uma camada vaza
O nosso exemplo para em uma funcionalidade.
Se você quiser ver o sistema completo, o nosso curso de Clean Architecture no .NET constrói a mesma aplicação de venda de ingressos, de um ponto de partida emaranhado até uma solução totalmente testada.
Ele cobre um mediador e um pipeline feitos à mão, eventos de domínio despachados depois do commit da transação, uma funcionalidade de compra e reembolso, quatro suítes de testes e um olhar justo sobre a Vertical Slice Architecture.
Se você está trazendo a Clean Architecture para uma aplicação existente, comece pelos testes de arquitetura. Apontados para os seus projetos atuais, eles mostram para onde as setas apontam hoje.
Testado com .NET 10.0.10, EF Core 10.0.12, xUnit v3 4.0.1 e NetArchTest.Rules 1.3.2.