Repository navigation
Arquitetura
Para quem é este artigo: para quem vai navegar pelo código pela primeira vez, e para quem quer entender Clean Architecture com um exemplo real, e não com caixinhas abstratas.
Pré-requisitos: saber o que é uma classe e uma interface em C# (ou em qualquer linguagem orientada a objetos) ajuda. Ter lido Entendendo o negócio ajuda mais ainda.
O que você vai aprender: o que é arquitetura de software, a regra de dependência, o papel de cada projeto da solução, o caminho completo de uma requisição e como as peças são ligadas.
Em um projeto pequeno, dá para colocar tudo em um arquivo: recebe a requisição, valida, faz o SELECT, aplica a regra, faz o UPDATE, responde. Funciona, até o dia em que:
- você precisa testar a regra "sinistro negado não pode ser pago" e descobre que, para isso, precisa de um banco de dados rodando e de uma requisição HTTP;
- a mesma regra aparece copiada em três lugares, e alguém corrige só dois;
- trocar de banco, ou só atualizar a biblioteca de acesso a dados, mexe em metade do sistema.
Arquitetura é a decisão de como dividir o código em partes, o que cada parte pode saber das outras, e por quê. O objetivo não é elegância: é que mudanças fiquem baratas e localizadas. Uma mudança na regra de negócio deve tocar um lugar; uma troca de banco deve tocar outro.
O PolicyDesk segue a Clean Architecture, proposta por Robert C. Martin ("Uncle Bob"). Ela é parente próxima da Arquitetura Hexagonal (Alistair Cockburn) e da Onion Architecture (Jeffrey Palermo). As três dizem, com palavras diferentes, a mesma coisa:
O código só pode depender de coisas mais internas. O núcleo, onde ficam as regras de negócio, não sabe que existe banco de dados, HTTP ou framework.
Pense nas camadas de uma cebola. No centro, as regras do negócio de seguros, que só mudariam se o negócio mudasse. Em volta, a orquestração das operações. Na casca, os detalhes técnicos: o banco, a web, as bibliotecas. A casca conhece o centro; o centro nunca conhece a casca.
flowchart TB
subgraph Externo["Casca: frameworks e detalhes técnicos"]
API["PolicyDesk.Api<br/>(ASP.NET Core, HTTP)"]
INF["PolicyDesk.Infrastructure<br/>(EF Core, PostgreSQL)"]
end
subgraph Aplicacao["Meio: orquestração"]
APP["PolicyDesk.Application<br/>(casos de uso)"]
end
subgraph Nucleo["Centro: regras de negócio"]
DOM["PolicyDesk.Domain<br/>(entidades, regras, contratos)"]
end
API --> APP
API --> INF
APP --> DOM
INF --> DOM
As setas são dependências de código: "A aponta para B" significa que A usa tipos de B. Repare que todas apontam para dentro.
Esta é a pergunta que todo mundo faz, e a resposta é o truque mais importante da arquitetura: inversão de dependência.
O domínio declara uma interface, um contrato que diz "eu preciso de alguém que saiba buscar um sinistro pelo id":
// Domain: só o contrato
public interface IClaimsReadOnlyRepository
{
Task<Claim?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
}A infraestrutura, na casca, implementa esse contrato usando o banco:
// Infrastructure: a implementação com EF Core
internal sealed class ClaimsRepository(PolicyDeskDbContext dbContext) : IClaimsReadOnlyRepository /* ... */
{
public Task<Claim?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return dbContext.Claims.AsNoTracking().FirstOrDefaultAsync(claim => claim.Id == id, cancellationToken);
}
}A dependência de código vai da infraestrutura para o domínio (a classe implementa uma interface do domínio), mas, quando o programa roda, quem faz o trabalho é a infraestrutura. Quem liga uma coisa à outra é a injeção de dependência, explicada mais abaixo.
| Benefício | Como aparece no PolicyDesk |
|---|---|
| Testar sem banco | A classe Claim é testada com new Claim(...) e nada mais. Os casos de uso são testados com repositórios falsos (mocks). Centenas de testes rodam em segundos |
| Trocar detalhes técnicos | Trocar o EF Core por Dapper exigiria reescrever só a Infrastructure. O domínio e os casos de uso não mudariam uma linha |
| Regras em um só lugar | "Só sinistros aprovados podem ser pagos" existe em um único método: Claim.RegisterPayment()
|
| O código conta o que o sistema faz | A pasta Application/Claims mostra Register, Analysis, Approve, Deny, Payment, Listing. Uncle Bob chama isso de Screaming Architecture: a estrutura "grita" o negócio, não o framework |
src/
├── Backend/
│ ├── PolicyDesk.Domain ← o centro: regras de negócio
│ ├── PolicyDesk.Application ← os casos de uso
│ ├── PolicyDesk.Infrastructure ← banco de dados
│ └── PolicyDesk.Api ← HTTP e a montagem de tudo
└── Shared/
├── PolicyDesk.Communication ← o formato do JSON de entrada e saída
└── PolicyDesk.Exception ← exceções e mensagens de erro
Em .NET, cada projeto vira uma assembly (uma DLL). Um projeto só pode usar tipos de outro se declarar uma referência (ProjectReference) no seu .csproj. Isso transforma a regra de dependência em algo que o compilador verifica: se alguém tentar usar o EF Core dentro do Domain, o código não compila, porque o Domain não referencia o EF Core.
O diagrama abaixo mostra exatamente as referências que existem nos .csproj:
flowchart LR
API[Api] --> APP[Application]
API --> INF[Infrastructure]
API --> EXC[Exception]
APP --> DOM[Domain]
APP --> COM[Communication]
APP --> EXC
INF --> DOM
DOM --> EXC
Aqui mora o conhecimento sobre seguros, escrito em C# puro.
-
Entidades (
Entities/):Policyholder,PolicyeClaim. Elas não são só "sacos de dados": cada uma protege as próprias regras.Claim.Approve()se recusa a aprovar um sinistro que não está em análise. -
Enums (
Enums/):PolicyStatuseClaimStatus, os estados possíveis. -
Validadores (
Validations/):CpfValidator,EmailValidator,PhoneValidator,FieldLimitseMoneyLimits. -
Relógio de negócio (
SeedWorks/BusinessClock): responde "que dia é hoje" no fuso de Brasília. -
Contratos (
Repositories/): as interfaces dos repositórios e doIUnitOfWork, que a infraestrutura implementa.
Depende apenas do projeto Exception (para lançar erros com mensagens traduzidas) e da biblioteca libphonenumber-csharp (para validar telefones). Aprofunde em Modelo de Domínio.
Um caso de uso é uma operação que o usuário do sistema quer fazer: "aprovar um sinistro", "cadastrar um segurado". Cada um vira uma classe com um único método público:
Application/
├── Claims/ Register · Listing · Analysis · Approve · Deny · Payment
├── Policies/ Register · Listing · Cancel
├── Policyholders/ Register · Listing · Update · Deactivate
└── Common/ Pagination · StatusMapping
Cada pasta tem uma interface pública (IApproveClaimUseCase) e uma implementação internal (ApproveClaimUseCase), com o método ExecuteAsync (ou ListAsync, nas listagens).
O caso de uso não contém regras de negócio; ele orquestra. Valida a entrada, busca as entidades, pede para a entidade fazer a mudança, confirma a gravação e registra o log. É como um gerente que distribui o trabalho, mas não executa as tarefas técnicas.
A pasta Common/ guarda o que vários casos de uso compartilham: a validação e a montagem da paginação, e a tradução dos status do domínio (InAnalysis) para os da API (EmAnalise).
Implementa os contratos do domínio usando o Entity Framework Core (EF Core), a biblioteca da Microsoft que traduz classes C# em tabelas e consultas SQL.
-
PolicyDeskDbContexte os mapeamentos (Mappings/*Map.cs), que dizem, por exemplo, que a propriedadeCoverageAmountvira a colunacoverage_amount. - Os repositórios (
Repositories/), que implementam as interfaces do domínio. - O
UnitOfWork, que grava tudo de uma vez e traduz erros do banco em erros de negócio. - As migrations, que versionam a estrutura do banco.
Quase tudo aqui é internal: o resto da solução só enxerga as interfaces e um único método público, AddInfrastructure. Aprofunde em Persistência e Banco de Dados.
A camada que fala HTTP, e o lugar onde todas as peças são montadas (a raiz de composição).
-
Controllers/: recebem a requisição, chamam o caso de uso e escolhem o status HTTP. São propositalmente "finos": nenhuma regra mora aqui. -
Filters/ExceptionFiltereErrors/ProblemDetailsSetup: transformam exceções em respostas de erro padronizadas. -
Observability/: logs, traces, métricas e o cabeçalhoX-Trace-Id. -
Converters/StringConverter: limpa os espaços de todo texto recebido. -
Program.cs: monta o pipeline HTTP e registra as dependências.
Os objetos que definem o formato do JSON: Request*Json (o que entra) e Response*Json (o que sai), com nomes em português, e os enums da API (StatusApolice, StatusSinistro).
Esse projeto não depende de nenhum outro. Isso permite, por exemplo, que um cliente .NET use esses mesmos tipos para chamar a API, sem levar o domínio junto. E garante que renomear algo no domínio não mude a API sem querer.
A hierarquia de exceções (PolicyDeskException e suas filhas) e os arquivos de mensagens: ResourcesMessageException.resx (português, o padrão) e ResourcesMessageException.en.resx (inglês). Aprofunde em Tratamento de Erros.
Teoria ajuda, mas nada substitui seguir uma requisição de verdade. Vamos acompanhar a aprovação de um sinistro: POST /api/sinistros/{id}/aprovacao.
sequenceDiagram
autonumber
participant C as Cliente
participant MW as Middlewares
participant CT as ClaimsController
participant UC as ApproveClaimUseCase
participant R as ClaimsRepository
participant E as Claim (entidade)
participant U as UnitOfWork
participant DB as PostgreSQL
C->>MW: POST /api/sinistros/{id}/aprovacao
Note over MW: X-Trace-Id · log · tratamento de erros · idioma
MW->>CT: Approve(id)
CT->>UC: ExecuteAsync(id, ct)
UC->>R: GetByIdForUpdateAsync(id)
R->>DB: SELECT ... (com rastreamento)
DB-->>R: linha + versão (xmin)
R-->>UC: Claim
UC->>E: Approve()
Note over E: está em análise? então Status = Approved,<br/>ApprovedAt = agora
UC->>U: CommitAsync(ct)
U->>DB: UPDATE ... WHERE id = @id AND xmin = @versao
DB-->>U: 1 linha afetada
UC-->>CT: (sem retorno)
CT-->>C: 204 No Content + X-Trace-Id
Passo a passo, em português:
- Os middlewares recebem a requisição antes de qualquer código nosso. Eles preparam o identificador da requisição, começam a medir o tempo, ficam de prontidão para capturar erros e leem o idioma pedido.
-
O roteamento do ASP.NET olha a URL e escolhe o método
ApprovedoClaimsController. O trecho{id:guid}da rota garante que o id seja um GUID válido. -
O controller só chama o caso de uso. Não há
ifnenhum ali. - O caso de uso pede ao repositório o sinistro, "para alteração".
-
O repositório faz o
SELECT. Junto com os dados, vem a versão da linha (xmin), usada mais tarde para detectar conflitos. -
A entidade recebe a ordem
Approve(). É ela quem decide se pode: se o sinistro não estiver em análise, lança um erro de regra de negócio. Se puder, muda o próprio estado. -
O Unit of Work grava. O
UPDATEsó acontece se a versão da linha ainda for a mesma que foi lida. -
O controller responde
204 No Content: deu certo, nada a devolver.
| Situação | Onde é percebida | O que é lançado | Resposta |
|---|---|---|---|
O id não é um GUID |
Roteamento | — | 404 |
| O sinistro não existe | ApproveClaimUseCase |
NotFoundException |
404 |
| O sinistro não está em análise | Claim.Approve() |
DomainRuleException |
400 |
| Outra requisição alterou o sinistro no meio |
UnitOfWork.CommitAsync (nenhuma linha afetada) |
ConflictException |
409 |
| O cliente desistiu e fechou a conexão | CancellationToken |
OperationCanceledException |
499 |
| Um erro inesperado | ExceptionFilter |
qualquer outra exceção |
500 (detalhes só no log) |
Cada camada lança uma exceção que descreve o problema ("não encontrado", "regra violada"), sem saber nada de HTTP. Um único lugar, na borda, traduz isso para status HTTP. Veja Tratamento de Erros.
Um middleware é uma etapa pela qual toda requisição passa, na ida e na volta, como as estações de uma esteira. Cada um pode fazer algo antes de passar adiante e algo depois que a resposta volta. A ordem em que são registrados no Program.cs é a ordem da esteira:
app.UseCorrelationIdHeader(); // 1. garante o X-Trace-Id em toda resposta
app.UseHttpLogging(); // 2. registra método, rota, status e duração
app.UseErrorHandling(); // 3. rede de segurança para exceções
app.UseRequestLocalization(); // 4. define o idioma a partir do Accept-Language
// Swagger (em Development ou com Swagger:Enabled=true)
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");Por que o log (2) vem antes do tratamento de erros (3)? Porque, na volta, ele fica por fora. Se um erro vira uma resposta 500 dentro do passo 3, o passo 2 vê o 500 final e registra corretamente. Na ordem inversa, o log registraria a resposta antes do tratamento e mostraria um status errado.
Outros detalhes do Program.cs que vale conhecer:
- Corpo de no máximo 64 KB. Nenhuma requisição legítima chega perto disso. O limite protege contra quem tenta derrubar a API com payloads enormes.
-
URLs em minúsculas (
LowercaseUrls = true). - Migrations automáticas só fora de produção. Em produção, o pipeline de deploy aplica as migrations em um passo próprio. Veja CI/CD e Deploy.
-
public partial class Program { }, no fim do arquivo, existe só para os testes de integração conseguirem referenciar a classeProgram.
Repare que nenhum caso de uso faz new ClaimsRepository(...). Eles só pedem o que precisam no construtor:
internal class ApproveClaimUseCase(
IClaimsUpdateOnlyRepository claimsUpdateOnlyRepository,
IUnitOfWork unitOfWork,
ILogger<ApproveClaimUseCase> logger) : IApproveClaimUseCaseQuem cria os objetos e entrega as dependências certas é o contêiner de injeção de dependência do ASP.NET. Para isso, cada camada registra suas peças:
// Application/DependencyInjectionExtension.cs
services.AddScoped<IApproveClaimUseCase, ApproveClaimUseCase>();
// Infrastructure/DependencyInjectionExtension.cs
services.AddScoped<IClaimsUpdateOnlyRepository, ClaimsRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddDbContext<PolicyDeskDbContext>(...);Lê-se: "quando alguém pedir um IClaimsUpdateOnlyRepository, entregue um ClaimsRepository". O Program.cs só chama AddInfrastructure(...) e AddApplication().
O contêiner precisa saber quanto tempo cada objeto vive. Existem três opções:
| Tempo de vida | Quando o contêiner cria uma instância nova | Exemplo no PolicyDesk |
|---|---|---|
| Singleton | Uma vez, e a mesma instância serve a aplicação inteira, até ela ser desligada |
BusinessClock, TimeProvider
|
| Scoped | Uma vez por requisição HTTP. Dentro da mesma requisição, todos recebem a mesma instância; a próxima requisição recebe outra | Casos de uso, repositórios, UnitOfWork, DbContext
|
| Transient | Toda vez que alguém pede, mesmo dentro da mesma requisição | (não usado) |
Para entender a escolha, comece pela peça central: o DbContext. Ele não é só uma "conexão com o banco". Ele guarda o change tracker, a lista das entidades carregadas e do que mudou em cada uma. É essa lista que o SaveChanges consulta para montar os UPDATEs. Por isso, a pergunta certa é: quem precisa enxergar o mesmo DbContext?
Na aprovação de um sinistro, duas peças diferentes usam o DbContext:
sequenceDiagram
participant UC as ApproveClaimUseCase
participant R as ClaimsRepository
participant U as UnitOfWork
participant C as DbContext
UC->>R: GetByIdForUpdateAsync(id)
R->>C: carrega o sinistro (fica no change tracker)
UC->>UC: claim.Approve() (muda o objeto em memória)
UC->>U: CommitAsync()
U->>C: SaveChanges(): "o que mudou?"
Note over C: só funciona se for o MESMO DbContext<br/>que carregou o sinistro
O repositório carrega; o UnitOfWork salva. Os dois precisam compartilhar a mesma instância. Agora, veja o que acontece em cada opção.
Cada pedido criaria um DbContext novo. O repositório receberia um, e o UnitOfWork, outro:
ClaimsRepository → DbContext A (carregou o sinistro; sabe que ele mudou)
UnitOfWork → DbContext B (vazio; nunca viu o sinistro)
UnitOfWork.CommitAsync() → DbContext B.SaveChanges() → "nada mudou" → nenhum UPDATE
A aprovação não seria gravada, e o pior: sem erro nenhum. A API responderia 204, como se tudo tivesse dado certo. Esse é o tipo de defeito mais perigoso, porque ninguém percebe até alguém notar o dado errado no banco. Além disso, cada DbContext extra abre e gerencia sua própria conexão, o que desperdiça recursos.
Uma única instância seria compartilhada por todas as requisições, de todos os usuários, ao mesmo tempo. Os problemas aparecem em cascata:
-
O
DbContextnão é thread-safe. Ele não foi feito para ser usado por duas threads ao mesmo tempo. Com duas requisições simultâneas, o EF Core lançaInvalidOperationException: A second operation was started on this context instance before a previous operation completed, ou, pior, corrompe o estado interno. -
Uma requisição gravaria as mudanças da outra. O change tracker é compartilhado: se a requisição A alterou um sinistro e falhou antes de salvar, a requisição B, ao chamar
SaveChanges, gravaria também a alteração pela metade de A. - Os dados envelheceriam. O change tracker guarda as entidades carregadas para sempre. Com o tempo, a memória cresce sem parar, e as consultas rastreadas podem devolver uma versão antiga, já em memória, em vez da versão atual do banco.
E repare: basta um repositório ser Singleton para causar tudo isso, mesmo com o DbContext registrado como Scoped. O repositório Singleton é criado uma única vez, na primeira requisição, e recebe o DbContext daquela requisição. Ele guarda esse DbContext para sempre e o usa em todas as requisições seguintes. Esse defeito tem nome: captive dependency (dependência capturada). Um objeto de vida longa "aprisiona" um objeto que deveria viver pouco.
A regra geral: um serviço nunca deve depender de outro com tempo de vida mais curto que o dele. Singleton pode depender de Singleton. Scoped pode depender de Scoped ou de Singleton (é o caso dos casos de uso que recebem o BusinessClock). Singleton nunca deve depender de Scoped. Em Development, o ASP.NET confere essa regra ao subir (ValidateScopes) e lança um erro se encontrar uma dependência capturada.
Com Scoped, dentro de uma requisição, o repositório e o UnitOfWork recebem o mesmo DbContext, e o SaveChanges enxerga tudo. Entre requisições, cada uma tem o seu, então ninguém vê nem grava as mudanças de ninguém. O "escopo" de uma requisição coincide com o de uma operação de negócio, que é exatamente a unidade de trabalho que queremos gravar de uma vez.
Poderiam, e funcionariam. Essa é uma sutileza que vale entender: o que precisa ser compartilhado é o DbContext, e não o repositório. Se um repositório fosse Transient, cada pedido criaria um repositório novo, mas o contêiner entregaria a todos eles o mesmo DbContext Scoped da requisição. Nada quebraria.
Mesmo assim, o PolicyDesk registra repositórios e casos de uso como Scoped, por três motivos:
- Não há ganho em Transient. Esses objetos não guardam estado próprio; criar várias instâncias deles na mesma requisição só gera trabalho extra para o coletor de lixo.
-
Consistência. Tudo o que depende do
DbContextvive o mesmo tempo que ele. Quem lê os registros não precisa se perguntar por que um é Transient e o outro, Scoped. - Segurança contra mudanças futuras. Se um dia alguém acrescentar estado a um repositório (um cache por requisição, por exemplo), Scoped já é o comportamento correto. Com Transient, cada consumidor teria uma cópia diferente desse estado, um defeito difícil de enxergar.
Transient é a escolha natural para objetos leves, sem estado e que não dependem de nada com escopo, como um formatador ou uma calculadora. O PolicyDesk simplesmente não tem esse tipo de serviço registrado no contêiner: os validadores são classes estáticas.
Opção para o DbContext
|
O que acontece |
|---|---|
| Transient | Repositório e UnitOfWork recebem instâncias diferentes: as alterações somem sem erro
|
| Singleton | Uma instância para todos os usuários: erros de concorrência, gravações misturadas, memória crescendo |
| Scoped (usado) | Compartilhado dentro da requisição, isolado entre requisições |
💡 Para experimentar: troque, em
Infrastructure/DependencyInjectionExtension.cs, o registro doIUnitOfWorkparaAddSingletone rode os testes de integração. Depois registre oPolicyDeskDbContextcomServiceLifetime.Transient(AddDbContext<PolicyDeskDbContext>(..., ServiceLifetime.Transient)) e rode de novo. Compare os dois tipos de falha: um barulhento, outro silencioso.
Os controllers recebem os casos de uso direto no método, com [FromServices], e não no construtor. Assim, cada action resolve só o caso de uso que usa. Mais detalhes em Design Patterns › Injeção de Dependência.
Nenhuma arquitetura real é igual à do livro. Estes são os desvios do PolicyDesk, e por que foram aceitos:
-
A
Apireferencia aInfrastructure. É preciso para chamarAddInfrastructuree rodar as migrations noProgram.cs. A regra continua valendo onde importa: nenhum controller usa tipos da infraestrutura. -
Os casos de uso recebem os objetos do JSON.
RegisterClaimUseCaserecebe umRequestClaimJson. É uma simplificação comum; em sistemas maiores, a camada de aplicação costuma ter seus próprios objetos de entrada, independentes do formato HTTP. -
O domínio conhece as mensagens de erro. Ele referencia o projeto
Exceptionpara lançar mensagens traduzidas. A alternativa seria lançar códigos de erro e traduzi-los na borda, ao custo de mais código.
Um desvio antigo foi corrigido: o
Communicationdependia doDomainpara expor os enums de status. Hoje o contrato tem enums próprios em português, e aApplicationconverte de um para o outro.
- Abra o
PolicyDesk.Domain.csproje confira: não há nenhuma referência a EF Core, ASP.NET ou Npgsql. - Tente usar
Microsoft.EntityFrameworkCoredentro deClaim.cs. O build falha: a arquitetura é verificada pelo compilador. - Coloque um breakpoint em
ApproveClaimUseCase.ExecuteAsynce outro emClaim.Approve, rode a API pela IDE e aprove um sinistro pelo Swagger. Siga a requisição com o depurador e compare com o diagrama acima.
- Arquitetura é sobre deixar mudanças baratas e localizadas.
- Regra de dependência: o código depende só de coisas mais internas. O domínio não conhece banco nem HTTP.
- O domínio declara interfaces; a infraestrutura as implementa; a injeção de dependência liga as duas.
- Cada camada tem um papel claro: regras (Domain), orquestração (Application), banco (Infrastructure), HTTP e montagem (Api).
- Quase tudo vive por requisição (Scoped), por causa do
DbContext.
- Clean Architecture, Robert C. Martin.
- "The Onion Architecture", série de artigos de Jeffrey Palermo.
- "Hexagonal Architecture", artigo original de Alistair Cockburn.
- Próximo artigo sugerido: Modelo de Domínio.
PolicyDesk · Distribuído sob a licença MIT · Repositório
Comece aqui
Como o sistema é feito
A API
Engenharia
Prática
Consulta