Repository navigation
Design Patterns
Para quem é este artigo: para quem estuda padrões de projeto e quer vê-los aplicados a um sistema real, e para quem precisa entender por que o código do PolicyDesk tem a forma que tem.
Pré-requisitos: C# básico e uma leitura de Arquitetura. Cada padrão é explicado do zero.
Como ler: cada seção é um artigo independente sobre um padrão. Você pode ler de ponta a ponta ou pular direto para o que interessa pelo índice.
Um padrão de projeto (design pattern) é uma solução conhecida para um problema que se repete. O termo vem da arquitetura de construções (Christopher Alexander, nos anos 1970) e chegou ao software em 1994, com o livro Design Patterns, de Gamma, Helm, Johnson e Vlissides, a "Gangue dos Quatro" (GoF).
Um padrão não é um código pronto para copiar. É uma ideia com nome. O nome é a parte mais valiosa: quando alguém diz "isso é um Unit of Work", toda a equipe entende, em três palavras, o que levaria um parágrafo para explicar.
Um alerta. Padrões são vocabulário, não objetivo. Ninguém deveria abrir um projeto pensando "vou usar o padrão X". O caminho certo é o inverso: você encontra um problema, percebe que ele já foi resolvido antes e usa o nome do padrão para comunicar a solução. Por isso, cada seção abaixo começa pelo problema.
| Parte | O que responde |
|---|---|
| Em uma frase | O que o padrão faz |
| Analogia | Uma comparação com algo do dia a dia, para fixar a ideia |
| O problema | Como o código ficaria sem o padrão, e o que daria errado |
| A solução | A ideia central do padrão |
| No PolicyDesk | O código real e onde encontrá-lo |
| Custos e alternativas | Nenhum padrão é gratuito |
| Para praticar | Um exercício curto |
Arquitetura
Domínio
- Domínio Rico vs. Domínio Anêmico
- Entity e Layer Supertype
- Always-Valid Entity e Guard Clauses
- Máquina de Estados com transições protegidas
- Validadores de valor e Value Objects
- Aggregate e referência por identidade
Aplicação
Persistência
- Repository
- Repositórios segregados (ISP + CQS)
- Unit of Work
- Optimistic Offline Lock
- Soft Delete com Global Query Filter
- Upsert atômico
- Exception Translation
API e infraestrutura transversal
- Pipeline de Middlewares (Chain of Responsibility)
- Exception Filter
- Problem Details
- Correlation ID
- Options Pattern
- Relógio injetável
- Source-Generated Logging
- Health Check
- Cooperative Cancellation
- Normalização na borda
Testes
- Test Data Builder
- Object Mother
- Fluent Mock Builder
- Shared Fixture com Testcontainers
- Arrange-Act-Assert
Fechamento
Em uma frase. Organizar o código em camadas concêntricas em que as dependências apontam sempre para dentro, protegendo as regras de negócio dos detalhes técnicos.
Analogia. Pense em uma tomada. O aparelho (a regra de negócio) define o formato do plugue; a companhia elétrica (a infraestrutura) fornece a energia nesse formato. Você troca de fornecedor de energia sem trocar o aparelho. Se o aparelho viesse soldado aos fios da rua, trocar qualquer coisa seria uma obra.
O problema. Em uma aplicação "tudo no controller", o mesmo método lê o JSON, monta o SQL, aplica a regra de negócio e escolhe o status HTTP. Para testar a regra "só sinistros aprovados podem ser pagos", é preciso subir um servidor web e um banco. Para trocar de banco, é preciso mexer em todas as regras. O código que muda por motivos de negócio e o código que muda por motivos técnicos ficam misturados, e cada mudança arrasta a outra.
A solução. Separar o código em círculos, do mais estável (as regras do negócio) para o mais volátil (frameworks, banco, HTTP), com uma regra: o código só depende de círculos mais internos.
No PolicyDesk.
flowchart TB
subgraph L4["Adaptadores: Api e Infrastructure"]
API[Api]
INF[Infrastructure]
subgraph L3["Casos de uso: Application"]
APP[Application]
subgraph L2["Entidades: Domain"]
DOM[Domain]
end
end
end
| Círculo | Projeto | Pode depender de |
|---|---|---|
| Entidades | Domain |
Nada de framework |
| Casos de uso | Application |
Domain |
| Adaptadores |
Infrastructure, Api
|
Application, Domain
|
O ponto que costuma confundir é a persistência. Intuitivamente, a regra de negócio "usa" o banco, então deveria depender dele. A Clean Architecture inverte isso:
Domain declara IClaimsUpdateOnlyRepository (uma interface: a "porta")
Infrastructure implementa ClaimsRepository (a classe concreta: o "adaptador")
O domínio define a porta; a infraestrutura fornece o adaptador. Por isso a família de arquiteturas à qual a Clean Architecture pertence também é chamada de Ports and Adapters (portas e adaptadores), ou Arquitetura Hexagonal.
O artigo Arquitetura mostra cada projeto em detalhe e segue uma requisição do começo ao fim.
Custos e alternativas.
- Mais projetos, mais interfaces, mais "cerimônia". Para um CRUD simples, pode ser exagero.
- A alternativa mais citada é a Vertical Slice Architecture (Jimmy Bogard): organizar por funcionalidade, e não por camada. Cada fatia tem seu endpoint, sua lógica e seu acesso a dados, aceitando alguma duplicação em troca de independência entre funcionalidades. O PolicyDesk já tem um "sabor" de fatias dentro da
Application(Claims/Approve/,Claims/Deny/...).
Para praticar. Abra PolicyDesk.Domain.csproj e confirme que ele não referencia nenhum pacote do EF Core nem do ASP.NET. Depois tente usar DbContext dentro de Claim.cs: o projeto não compila. A arquitetura é garantida pelo compilador.
Em uma frase. Uma classe recebe prontas as dependências de que precisa, em vez de criá-las.
Analogia. Um cozinheiro de restaurante não planta os legumes nem cria as galinhas: recebe os ingredientes já separados na bancada e cozinha. Se um dia o restaurante trocar de fornecedor, o cozinheiro nem fica sabendo. E, para treinar um aprendiz, basta colocar ingredientes de treino na bancada.
O problema.
// ❌ Sem injeção de dependência
public class ApproveClaimUseCase
{
public async Task ExecuteAsync(Guid id)
{
var db = new PolicyDeskDbContext(new DbContextOptionsBuilder()...); // presa ao EF e à configuração
var repo = new ClaimsRepository(db);
// ...
}
}Essa classe não pode ser testada sem um banco de verdade, não pode trocar de implementação, e decide sozinha quanto tempo o DbContext vive.
A solução. A classe declara o que precisa (normalmente no construtor, e de preferência como interfaces), e alguém de fora entrega. Esse "alguém" é o contêiner de injeção de dependência. Tirar da classe a responsabilidade de montar as próprias dependências se chama Inversão de Controle (IoC): o controle sobre quem cria o quê passa da classe para o contêiner.
No PolicyDesk.
internal class ApproveClaimUseCase(
IClaimsUpdateOnlyRepository claimsUpdateOnlyRepository,
IUnitOfWork unitOfWork,
ILogger<ApproveClaimUseCase> logger) : IApproveClaimUseCase
{
public async Task ExecuteAsync(Guid claimId, CancellationToken cancellationToken = default)
{
var claim = await claimsUpdateOnlyRepository.GetByIdForUpdateAsync(claimId, cancellationToken)
?? throw new NotFoundException(ResourcesMessageException.CLAIM_NOT_FOUND);
claim.Approve();
await unitOfWork.CommitAsync(cancellationToken);
logger.ClaimApproved(claim.Id);
}
}Três técnicas aparecem juntas:
- Injeção pelo construtor, com a sintaxe de primary constructor do C# 12: os parâmetros declarados ao lado do nome da classe ficam disponíveis em todos os métodos.
-
Dependência de abstrações:
IClaimsUpdateOnlyRepositoryé uma interface, não uma classe concreta. Esse é o D do SOLID, o Dependency Inversion Principle. -
Classe
internal: ninguém fora do projeto consegue criarApproveClaimUseCasediretamente. A única porta de entrada é a interfaceIApproveClaimUseCase, entregue pelo contêiner.
Nos controllers, a injeção é feita no método, e não no construtor:
[HttpPost("sinistros/{id:guid}/aprovacao")]
public async Task<IActionResult> Approve(
[FromRoute] Guid id,
[FromServices] IApproveClaimUseCase useCase,
CancellationToken cancellationToken)Por quê? O ClaimsController tem sete actions. Com injeção pelo construtor, cada requisição criaria os sete casos de uso (e todas as dependências deles) para usar só um. Com [FromServices], cada action recebe só o que usa.
Tempos de vida. O contêiner também decide quanto tempo cada objeto vive:
| Tempo de vida | Uma instância por... | No PolicyDesk |
|---|---|---|
Transient |
pedido ao contêiner | — |
Scoped |
requisição HTTP | Casos de uso, repositórios, UnitOfWork, DbContext
|
Singleton |
aplicação inteira |
BusinessClock, TimeProvider, a infraestrutura do framework |
A regra de ouro: um serviço nunca deve depender de outro com tempo de vida mais curto. Um Singleton que recebe um DbContext (Scoped) o "captura" para sempre, e passa a compartilhá-lo entre requisições e threads diferentes. Esse defeito se chama captive dependency, e o ASP.NET o detecta em Development. O artigo Arquitetura mostra, passo a passo, o que quebra com cada tempo de vida no DbContext, e por que os repositórios não são Transient.
Custos e alternativas.
- O grafo de dependências fica implícito: para saber qual classe roda de verdade, é preciso olhar o registro no contêiner.
- O Service Locator (chamar
serviceProvider.GetService<T>()dentro da classe) é a alternativa considerada antipadrão, porque esconde as dependências: olhando o construtor, você não sabe do que a classe precisa. O único uso dele no projeto está na borda, emDatabaseMigration, executado uma vez na inicialização.
Para praticar. Registre o IUnitOfWork como Singleton e rode os testes de integração. Observe os erros e explique por que eles acontecem.
Em uma frase. Montar todo o grafo de objetos em um único ponto, o mais perto possível da entrada da aplicação.
Analogia. A planta elétrica de uma casa: os fios correm por dentro das paredes, mas todas as ligações se encontram no quadro de distribuição. Para entender ou mudar a instalação, você vai a um lugar só.
O problema. Se cada parte do sistema registrasse dependências por conta própria, espalhadas pelo código, ninguém saberia ao certo o que está ligado a quê, e uma configuração poderia sobrescrever outra sem ninguém perceber.
A solução. Um único lugar, a raiz de composição (Composition Root), conhece todas as peças e as conecta. O resto do código só declara o que precisa.
No PolicyDesk. A raiz é o Program.cs. Mas ele não conhece os detalhes: cada camada oferece um método que registra as próprias peças:
// Program.cs
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddApplication();// Infrastructure/DependencyInjectionExtension.cs
public static class DependencyInjectionExtension
{
public static void AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<IClaimsReadOnlyRepository, ClaimsRepository>();
// ...
}
}O this antes do primeiro parâmetro faz de AddInfrastructure um método de extensão: ele pode ser chamado como se fosse um método do próprio IServiceCollection. A Application segue a mesma forma.
O resultado: as implementações continuam internal. O Program.cs nem sabe que existe uma classe ClaimsRepository; ele só pede "adicione a infraestrutura". Cada camada é dona do próprio registro.
Custos e alternativas. Em sistemas grandes, os métodos de registro podem crescer muito; bibliotecas como Scrutor registram automaticamente por convenção ("toda classe que termina em Repository"), ao custo de um registro menos explícito.
Para praticar. Abra o DependencyInjectionTests, em Infrastructure.Tests. Ele verifica que a aplicação se recusa a subir, com mensagem clara, quando a connection string não existe. Escreva um teste que monte o contêiner com AddApplication() e AddInfrastructure() e peça todos os casos de uso. Que tipo de erro esse teste pegaria antes da produção?
Em uma frase. Colocar o comportamento junto dos dados que ele manipula.
Analogia. Um caixa eletrônico não deixa você abrir a gaveta e pegar o dinheiro: você pede um saque, e ele confere o saldo antes de liberar. Um "caixa anêmico" seria uma gaveta aberta com um aviso na parede: "confira seu saldo antes de pegar".
O problema. Martin Fowler chamou de Anemic Domain Model (modelo de domínio anêmico) o estilo em que as entidades são só propriedades públicas e toda a lógica fica em classes de "serviço":
// ❌ Anêmico
public class Claim
{
public ClaimStatus Status { get; set; }
public DateTime? PaidAt { get; set; }
}
public class ClaimService
{
public void Pay(Claim claim)
{
if (claim.Status != ClaimStatus.Approved) throw ...;
claim.Status = ClaimStatus.Paid;
claim.PaidAt = DateTime.UtcNow;
}
}
// Em outro lugar, alguém esquece a regra:
claim.Status = ClaimStatus.Paid; // compila, e o sinistro foi pago sem aprovaçãoA regra existe, mas nada obriga ninguém a passar por ela.
A solução. A entidade esconde os dados (setters privados) e oferece métodos com nomes do negócio, que conferem as regras antes de mudar qualquer coisa.
No PolicyDesk.
public class Claim : Entity
{
public ClaimStatus Status { get; private set; } = ClaimStatus.Pending;
public DateTime? PaidAt { get; private set; }
public void RegisterPayment()
{
EnsureStatus(ClaimStatus.Approved, ResourcesMessageException.CLAIM_MUST_BE_APPROVED_FOR_PAYMENT);
Status = ClaimStatus.Paid;
PaidAt = DateTime.UtcNow;
UpdatedAt = PaidAt;
}
}E o caso de uso fica trivial:
claim.RegisterPayment();
await unitOfWork.CommitAsync(cancellationToken);O encapsulamento (os setters privados) transforma a regra em uma restrição do compilador. Esse é o princípio Tell, Don't Ask ("mande, não pergunte"): em vez de perguntar o status e decidir do lado de fora, você manda a entidade pagar, e ela decide se pode.
Custos e alternativas. Entidades ricas exigem cuidados com o ORM (construtores e setters privados precisam ser mapeados). Em sistemas que são quase só cadastro, sem regras de verdade, o modelo anêmico com Transaction Script (Fowler: um procedimento por operação) pode ser mais simples e honesto.
Para praticar. Procure por Status = ClaimStatus no projeto. Só aparece dentro de Claim.cs. Agora tente escrever claim.Status = ClaimStatus.Paid; em um caso de uso e leia o erro de compilação.
Em uma frase. Entity: um objeto definido pela identidade, e não pelos atributos. Layer Supertype: uma classe base com o que é comum a todos os objetos de uma camada.
Analogia. Duas notas de R$ 50 são intercambiáveis: o que importa é o valor (isso é um Value Object). Já duas pessoas chamadas "Maria Silva", nascidas no mesmo dia, continuam sendo pessoas diferentes: o que as distingue é a identidade (isso é uma Entity).
O problema. Sem uma noção clara de identidade, como saber se dois objetos representam o mesmo sinistro? E, sem uma classe base, o Id, a data de criação e a de atualização seriam repetidos (e talvez implementados de formas diferentes) em cada entidade.
A solução. Toda entidade herda de uma base que lhe dá identidade e as datas de auditoria.
No PolicyDesk.
public abstract class Entity
{
public Guid Id { get; private set; } = Guid.NewGuid();
public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; protected set; }
}Dois sinistros com a mesma descrição, valor e data continuam sendo sinistros diferentes, porque têm Ids diferentes.
Decisões de design:
-
O
Idé gerado pela aplicação (Guid.NewGuid()), e não pelo banco. A entidade está completa antes de ser salva, e os casos de uso não precisam consultar o banco para saber o id. -
abstract: não faz sentido existir uma "entidade genérica".
Custos e alternativas.
- GUIDs aleatórios (versão 4) espalham os registros pelo índice do banco, o que o torna menos eficiente com o tempo. O .NET 9+ oferece
Guid.CreateVersion7(), que gera GUIDs ordenados pelo tempo e reduz esse problema. É uma melhoria possível. - Uma evolução comum é fazer a base comparar entidades pelo
Id(sobrescrevendoEqualseGetHashCode). O PolicyDesk não faz isso: duas instâncias carregadas separadamente, com o mesmoId, não são consideradas iguais pelo==.
Para praticar. Implemente Equals e GetHashCode em Entity, comparando o tipo e o Id. Que cuidados são necessários com os proxies que o EF Core pode criar?
Em uma frase. Always-Valid: uma entidade nunca existe em estado inválido. Guard Clause: conferir as condições no início do método e sair imediatamente se não forem atendidas.
Analogia. O porteiro de um prédio confere a identificação na entrada. Lá dentro, ninguém precisa perguntar de novo quem você é.
O problema. Se uma entidade pode ser criada inválida, todo código que a usa precisa se defender: "e se a descrição estiver vazia?", "e se o valor for negativo?". A validação se espalha pelo sistema e, inevitavelmente, algum lugar esquece.
A solução. O construtor (e cada método que muda estado) confere tudo antes de fazer qualquer coisa. Se algo estiver errado, lança uma exceção, e o objeto nunca chega a existir.
No PolicyDesk.
public Policy(Guid policyholderId, string description, decimal coverageAmount,
decimal premiumAmount, DateOnly startDate, DateOnly endDate)
{
if (policyholderId == Guid.Empty)
throw new DomainRuleException(ResourcesMessageException.POLICYHOLDER_REQUIRED);
if (string.IsNullOrWhiteSpace(description))
throw new DomainRuleException(ResourcesMessageException.DESCRIPTION_REQUIRED);
if (coverageAmount <= 0)
throw new DomainRuleException(ResourcesMessageException.COVERAGE_AMOUNT_MUST_BE_POSITIVE);
// ...
if (endDate <= startDate)
throw new DomainRuleException(ResourcesMessageException.POLICY_END_DATE_MUST_BE_AFTER_START_DATE);
PolicyholderId = policyholderId;
// ...
}As guardas ficam alinhadas à esquerda, uma por regra, sem if aninhado. O "caminho feliz" vem no final, sem indentação. Esse formato é fácil de ler e fácil de estender: uma regra nova é uma linha nova.
Se existe uma instância de Policy, ela é válida, e o resto do sistema pode confiar nisso. Essa ideia vem do Design by Contract (Bertrand Meyer): condições verificadas na entrada, e regras preservadas por todos os métodos.
Uma sutileza: o construtor do ORM. O EF Core usa um construtor privado sem parâmetros, que não valida. Isso é intencional: dados antigos precisam continuar legíveis mesmo depois de uma regra nova. Veja Modelo de Domínio › Um construtor escondido para o banco.
Custos e alternativas.
- Usar exceções para validação é criticado por custo e por ser "controle de fluxo". A alternativa é o Result Pattern: o construtor devolveria um
Result<Policy>, com sucesso ou uma lista de erros (bibliotecas comoFluentResultseErrorOr). O PolicyDesk escolheu exceções porque, no domínio, uma violação é excepcional: a camada de aplicação já validou a entrada antes (veja o Notification Pattern). - Bibliotecas como
Ardalis.GuardClausesoferecemGuard.Against.NullOrWhiteSpace(...). O projeto usaifexplícitos, que não exigem dependência nenhuma.
Para praticar. Escreva um teste que tente criar new Claim(policyId, "x", -10m, new DateOnly(2026, 3, 10)) e confira a mensagem da exceção. Depois procure o teste equivalente em ClaimsTests.cs.
Em uma frase. Restringir as mudanças de estado de um objeto a uma lista explícita de transições permitidas.
Analogia. Um semáforo só muda de verde para amarelo, de amarelo para vermelho e de vermelho para verde. Nunca de verde direto para vermelho. As transições permitidas são poucas e conhecidas.
O problema. Se o status de um sinistro pudesse ser mudado livremente, nada impediria pagar um sinistro pendente ou aprovar um já negado. As regras sobre "o que pode vir depois do quê" ficariam espalhadas em ifs pelo sistema.
A solução. Modelar o ciclo de vida como uma máquina de estados finita: um conjunto de estados e as transições permitidas entre eles. Cada transição vira um método que confere o estado de origem.
No PolicyDesk.
stateDiagram-v2
direction LR
[*] --> Pending
Pending --> InAnalysis: StartAnalysis()
InAnalysis --> Approved: Approve()
InAnalysis --> Denied: Deny()
Approved --> Paid: RegisterPayment()
public void StartAnalysis()
{
EnsureStatus(ClaimStatus.Pending, ResourcesMessageException.CLAIM_MUST_BE_PENDING_FOR_ANALYSIS);
Status = ClaimStatus.InAnalysis;
UpdatedAt = DateTime.UtcNow;
}
private void EnsureStatus(ClaimStatus expected, string error)
{
if (Status != expected)
throw new DomainRuleException(error);
}Comparação com o padrão State (GoF). O padrão State clássico cria uma classe para cada estado, e cada classe decide o que cada ação faz naquele estado:
// Padrão State (GoF), apenas para comparação
interface IClaimState
{
IClaimState Approve(Claim claim);
IClaimState Pay(Claim claim);
}
class InAnalysisState : IClaimState
{
public IClaimState Approve(Claim c) => new ApprovedState();
public IClaimState Pay(Claim c) => throw new DomainRuleException("...");
}| Critério | Enum + métodos com guarda (PolicyDesk) | Padrão State (GoF) |
|---|---|---|
| Número de classes | 1 | 1 por estado, mais uma interface |
| Persistência | Trivial (o nome do enum vira texto) | Exige converter estado ↔ classe |
| Comportamento muito diferente em cada estado | Viraria muitos if/switch
|
Natural: cada classe cuida do seu |
| Legibilidade com 5 estados e 4 transições | Alta | Excessiva |
Para uma máquina pequena, com transições simples, o enum com guardas é a escolha proporcional. O padrão State compensa quando o mesmo método se comporta de forma muito diferente em cada estado.
Custos e alternativas. Para fluxos grandes e configuráveis (dezenas de estados, regras que mudam sem deploy), existem bibliotecas de máquinas de estado, como a Stateless, e até motores de workflow.
Para praticar. Siga o Tutorial e adicione a reabertura de sinistros negados. Quantos lugares você precisou alterar?
Em uma frase. Concentrar as regras de formato de um conceito (CPF, e-mail, telefone, dinheiro) em um único lugar reaproveitável.
Analogia. Um gabarito de corte: toda peça passa pelo mesmo molde, em vez de cada marceneiro medir com a sua régua.
O problema. A validação de CPF é necessária no cadastro de segurado, na entidade e, talvez amanhã, em outro lugar. Copiada, ela diverge: uma cópia aceita CPF com máscara, a outra não.
A solução. Uma classe por conceito, com três métodos que cobrem os usos possíveis.
No PolicyDesk.
public static class CpfValidator
{
public static string Normalize(string? cpf) => ...; // "529.982.247-25" → "52998224725"
public static bool IsValid(string? cpf) => ...; // nunca lança exceção
public static string NormalizeOrThrow(string? cpf) => ...; // lança DomainRuleException
}Cada consumidor escolhe o modo de falha de que precisa: a aplicação usa IsValid para juntar todos os erros; a entidade usa NormalizeOrThrow para garantir a regra. O mesmo formato vale para EmailValidator, PhoneValidator e, com adaptações, para MoneyLimits e FieldLimits. Os detalhes de cada um estão em Modelo de Domínio › Validadores de valor.
Custos e alternativas: Value Objects. A evolução natural seria transformar o CPF em um Value Object, um tipo próprio que só existe se for válido:
public sealed record Cpf
{
public string Value { get; }
private Cpf(string value) => Value = value;
public static Cpf Create(string? raw) =>
new(CpfValidator.NormalizeOrThrow(raw));
}Com isso, Policyholder.Cpf seria do tipo Cpf, e seria impossível atribuir um texto não validado. Isso combate o Primitive Obsession (usar string e decimal para tudo). O custo é o mapeamento extra no EF Core (HasConversion ou complex types).
Para praticar. Implemente o Value Object Email e use-o em Policyholder. Configure o mapeamento com HasConversion(e => e.Value, v => Email.Create(v)).
Em uma frase. Aggregate (Eric Evans): um grupo de objetos tratado como uma unidade de consistência, acessado por uma raiz. Agregados se referem uns aos outros só pelo id.
Analogia. Em um arquivo de documentos, cada pasta é uma unidade: você não grampeia a pasta de um cliente dentro da pasta de outro. Se precisar relacioná-las, anota o número da outra pasta.
O problema. Com propriedades de navegação para tudo (claim.Policy.Policyholder.Policies...), carregar um sinistro pode puxar, sem querer, uma apólice com todos os sinistros dela. E uma única transação pode acabar alterando objetos de todos os lados, sem fronteiras claras de consistência.
A solução. Cada agregado é pequeno, tem uma raiz, e aponta para os outros apenas pelo identificador.
No PolicyDesk.
public class Claim : Entity
{
public Guid PolicyId { get; private set; } // ✅ referência pelo id
// public Policy Policy { get; set; } // ❌ não existe navegação
}O relacionamento existe no banco (chave estrangeira com DeleteBehavior.Restrict), mas não no modelo de objetos:
builder.HasOne<Policy>()
.WithMany()
.HasForeignKey(claim => claim.PolicyId)
.OnDelete(DeleteBehavior.Restrict);Por que funciona:
- Cada transação altera, em regra, um agregado. As fronteiras de consistência ficam claras.
- Não há carregamento acidental de grafos enormes para mudar um campo.
- O
Restrictimpede apagar uma apólice que tenha sinistros: o histórico é protegido pelo banco.
A consequência. Regras que envolvem mais de um agregado são coordenadas pelo caso de uso. O saldo da cobertura é o exemplo: o RegisterClaimUseCase busca a soma dos sinistros que contam e entrega à apólice, que decide em Policy.RegisterClaim. Em um modelo maior, essa coordenação poderia virar um Domain Service.
Uma exceção controlada. RegisterPolicyUseCase altera dois agregados na mesma transação: cria a Policy e chama policyholder.RegisterPolicy(). Isso é proposital, para proteger a regra "segurado inativo não tem apólice em vigor" contra operações simultâneas. Veja Optimistic Offline Lock.
Custos e alternativas. Sem navegação, algumas consultas precisam de mais de uma ida ao banco, ou de consultas específicas para leitura. Muitos projetos aceitam navegações só para leitura, mantendo as alterações restritas a um agregado.
Para praticar. Desenhe os três agregados do PolicyDesk e marque quais casos de uso alteram mais de um. Para cada um, explique por que a exceção à regra é necessária.
Em uma frase. Representar cada operação de negócio como uma unidade explícita, que coordena o domínio e a infraestrutura sem conter regra de negócio própria.
Analogia. O maestro de uma orquestra não toca nenhum instrumento. Ele sabe quem entra quando, e garante que a música aconteça na ordem certa. Os músicos (as entidades) é que sabem tocar.
O problema. Uma classe ClaimService com 15 métodos (abrir, analisar, aprovar, negar, pagar, listar...) vira um ponto de encontro de tudo: muitas dependências, muitos motivos para mudar, e testes que precisam montar dependências que o método testado nem usa.
A solução. Uma classe por operação, com um método público. A classe orquestra: valida a entrada, busca o que precisa, manda as entidades agirem, confirma a gravação e registra o que aconteceu.
No PolicyDesk. Uma interface e uma classe por operação:
Application/Claims/
├── Approve/
│ ├── IApproveClaimUseCase.cs
│ └── ApproveClaimUseCase.cs
├── Deny/
│ ├── IDenyClaimUseCase.cs
│ └── DenyClaimUseCase.cs
...
Todos seguem o mesmo roteiro:
flowchart LR
A[1. Validar a entrada] --> B[2. Carregar as entidades]
B --> C[3. Delegar ao domínio]
C --> D[4. Confirmar a gravação]
D --> E[5. Registrar o log]
E --> F[6. Montar a resposta]
Veja o roteiro no caso de uso mais completo, o cadastro de sinistro:
public async Task<ResponseCreatedJson> ExecuteAsync(Guid policyId, RequestClaimJson request, CancellationToken ct)
{
Validate(request, clock.Today); // 1
var policy = await policiesUpdateOnlyRepository.GetByIdForUpdateAsync(policyId, ct) // 2
?? throw new NotFoundException(ResourcesMessageException.POLICY_NOT_FOUND);
if (policy.IsCancelled) throw ...;
if (!policy.IsInForce(request.DataOcorrencia)) throw ...;
var committedAmount = await claimsReadOnlyRepository.SumCommittedAmountAsync(policy.Id, ct);
policy.RegisterClaim(request.Valor, committedAmount); // 3
var claim = new Claim(policy.Id, request.Descricao, request.Valor, request.DataOcorrencia);
await claimsWriteOnlyRepository.AddAsync(claim);
await unitOfWork.CommitAsync(ct); // 4
logger.ClaimRegistered(claim.Id, policy.Id, claim.Amount); // 5
return new ResponseCreatedJson { Id = claim.Id }; // 6
}Repare que nenhuma regra de negócio está escrita aqui: "a apólice cobre a data?" é pergunta para a apólice (IsInForce), "há saldo?" também (RegisterClaim), e "os dados do sinistro são válidos?" é garantido pelo construtor de Claim.
Relação com o padrão Command. Cada caso de uso tem praticamente um único método (ExecuteAsync). É uma forma do padrão Command (GoF), em que uma operação vira um objeto.
Por que funciona:
-
Responsabilidade única:
ApproveClaimUseCasemuda por um único motivo, a regra de aprovação. - Testabilidade: cada caso de uso tem poucos colaboradores, todos substituíveis por dublês.
- Descoberta: a estrutura de pastas é o índice das funcionalidades.
Custos e alternativas.
- Muitas classes pequenas. Para quem vem de services grandes, parece fragmentado.
- A alternativa popular é o Mediator (biblioteca MediatR): os controllers enviam um objeto (
new ApproveClaimCommand(id)) e um handler o trata. Isso permite pipeline behaviors (validação, log e transação aplicados a todos os handlers de uma vez), mas acrescenta uma indireção que dificulta navegar pelo código: o "ir para a definição" da IDE para noSend. O PolicyDesk optou pela chamada direta à interface, mais explícita.
Para praticar. Escreva o ReopenClaimUseCase seguindo o roteiro acima. O Tutorial guia o caminho.
Em uma frase. Juntar todos os erros de validação e informá-los de uma vez, em vez de parar no primeiro.
Analogia. Um professor que corrige a prova inteira e devolve todas as correções, em vez de devolver a prova a cada erro encontrado para o aluno corrigir e entregar de novo.
O problema. Um formulário com cinco campos errados. Se a API devolve só o primeiro erro, o usuário corrige, envia, recebe o segundo, corrige, envia... cinco idas e voltas.
A solução. Validar tudo, colecionar os erros em uma lista e, só no final, falhar com a lista completa.
No PolicyDesk.
internal static class PolicyholderDataValidator
{
public static List<string> Validate(string? name, DateOnly birthDate, string? email, string? phone, DateOnly today)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(name))
errors.Add(ResourcesMessageException.POLICYHOLDER_NAME_REQUIRED);
else if (FieldLimits.Exceeds(name, FieldLimits.PolicyholderName))
errors.Add(FieldLimits.PolicyholderNameTooLong);
if (!EmailValidator.IsValid(email))
errors.Add(ResourcesMessageException.INVALID_EMAIL);
// ...
return errors;
}
}if (errors.Count > 0)
throw new ErrorOnValidationException(errors); // leva a lista inteiraO cliente recebe:
{
"status": 400,
"errors": [
"O nome é obrigatório.",
"E-mail inválido.",
"O telefone é obrigatório."
]
}Validação em duas camadas: uma redundância proposital. A mesma regra ("nome obrigatório") é conferida na aplicação e na entidade. Não é duplicação acidental:
| Camada | Objetivo | Estilo |
|---|---|---|
| Aplicação | Experiência do usuário: todos os erros de uma vez | Notification (acumula) |
| Domínio | Integridade: nunca existir uma entidade inválida | Guard Clause (para no primeiro) |
Se amanhã alguém criar um caso de uso novo e esquecer de validar, o domínio continua protegendo os dados. A aplicação melhora a experiência; o domínio garante a correção.
Custos e alternativas. A biblioteca FluentValidation é a forma mais comum de implementar esse padrão em .NET (RuleFor(x => x.Nome).NotEmpty().MaximumLength(150)). O PolicyDesk usa validação manual, sem dependência externa e com as mesmas constantes do domínio.
Para praticar. Envie POST /api/segurados com todos os campos vazios e conte os erros. Depois envie um CPF inválido junto com um e-mail já usado: o erro de e-mail duplicado aparece? Por quê? (Dica: a verificação de duplicidade consulta o banco, e só roda depois que o formato é válido.)
Em uma frase. Data Transfer Object (Fowler): um objeto simples, sem comportamento, que define o formato dos dados que cruzam uma fronteira do sistema.
Analogia. Um formulário de papel. O formulário de abertura de conta tem os campos que o cliente preenche; o sistema interno do banco tem muito mais informação, que o cliente nunca vê nem preenche.
O problema. Usar a própria entidade como formato do JSON parece prático, mas: o cliente poderia enviar campos que não deveria controlar ("status": "Pago"); qualquer mudança interna mudaria a API e quebraria clientes; e entrada e saída raramente têm os mesmos campos.
A solução. Classes separadas para o que entra e o que sai, e uma conversão explícita entre elas e as entidades.
No PolicyDesk. O projeto Communication contém só DTOs:
public class RequestClaimJson
{
public string Descricao { get; set; } = string.Empty;
public decimal Valor { get; set; }
public DateOnly DataOcorrencia { get; set; }
}E a conversão da entidade para a resposta é escrita à mão, no caso de uso:
private static ResponseClaimJson MapToResponse(Domain.Entities.Claim claim) => new()
{
Id = claim.Id,
ApoliceId = claim.PolicyId,
Descricao = claim.Description,
Valor = claim.Amount,
Status = claim.Status.ToContract(), // ClaimStatus.InAnalysis → StatusSinistro.EmAnalise
// ...
};Os status têm enums próprios no contrato (StatusSinistro, StatusApolice), em português, e o StatusMapping converte nos dois sentidos. O Communication não referencia o Domain.
Por que não expor a entidade diretamente:
-
Segurança: a entidade tem dados internos (como a versão de concorrência) que não devem vazar, e aceitá-la como entrada permitiria mass assignment: o cliente enviar
"status": "Pago"e o campo ser aceito. - Estabilidade do contrato: renomear uma propriedade da entidade não quebra os clientes.
-
Formas diferentes: a criação, a atualização e a resposta têm campos diferentes (
RequestUpdatePolicyholderJsonnão tem CPF, porque o CPF não muda).
Contratos padronizados. Todos os requests e responses usam nomes em português (Nome, Telefone, ValorCobertura, CriadoEm), enquanto o domínio fica em inglês. O DTO é a fronteira onde a tradução acontece. As listagens paginadas usam um envelope genérico, ResponsePagedJson<T>.
Custos e alternativas. O mapeamento manual é verboso, mas é verificado pelo compilador, fácil de depurar e sem "mágica". Uma propriedade renomeada quebra a compilação, não a produção. O AutoMapper mapeia por convenção de nomes, o que economiza código mas esconde erros até a execução (e não funcionaria aqui, com nomes em idiomas diferentes). Bibliotecas que geram o código de mapeamento na compilação, como o Mapperly, são um meio-termo.
Para praticar. Adicione o campo diasDeVigencia à resposta da apólice, calculado a partir das datas. Em que lugar você colocou o cálculo? Ele deveria estar na entidade ou no mapeamento?
Em uma frase. (Fowler) Mediar entre o domínio e o acesso a dados com uma interface parecida com uma coleção de objetos.
Analogia. Um bibliotecário. Você pede "o livro tal" e ele traz; você não precisa saber em que estante, em que andar, nem como o acervo está organizado.
O problema. Sem repositórios, o código de negócio escreveria consultas (LINQ ou SQL) por toda parte. O domínio passaria a depender do ORM, e a mesma consulta apareceria copiada em vários lugares.
A solução. O domínio declara uma interface com métodos de negócio ("busque o sinistro", "some os valores comprometidos"); a infraestrutura a implementa com o ORM.
No PolicyDesk.
// Domínio: o contrato
public interface IClaimsReadOnlyRepository
{
Task<Claim?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IEnumerable<Claim>> GetByPolicyIdAsync(Guid policyId, CancellationToken cancellationToken = default);
Task<decimal> SumCommittedAmountAsync(Guid policyId, CancellationToken cancellationToken = default);
}
// Infraestrutura: a implementação
internal sealed class ClaimsRepository(PolicyDeskDbContext dbContext) : IClaimsReadOnlyRepository /* ... */
{
public Task<decimal> SumCommittedAmountAsync(Guid policyId, CancellationToken cancellationToken = default)
{
return dbContext.Claims
.Where(claim => claim.PolicyId == policyId && claim.Status != ClaimStatus.Denied)
.SumAsync(claim => claim.Amount, cancellationToken);
}
}"O DbContext já não é um repositório?" É uma discussão clássica. O DbSet<T> do EF Core já funciona como um Repository, e o DbContext, como um Unit of Work. Por que outra camada?
| A favor da camada extra | Contra |
|---|---|
| O domínio não referencia o EF Core | Mais código, mais indireção |
Consultas com nome de negócio (HasActiveOrFuturePolicyAsync, SumCommittedAmountAsync) |
A abstração "vaza": o domínio ainda assume certos comportamentos do ORM |
| Casos de uso testáveis com dublês simples | Testes com banco real (Testcontainers) também resolveriam |
| Permite SQL especializado (o upsert) sem expor isso | — |
No PolicyDesk, a camada se paga principalmente pela regra de dependência (o domínio não conhece o EF) e pelas consultas com nome de negócio.
Resultados materializados, nunca IQueryable. Os repositórios executam a consulta (ToListAsync) antes de devolver. Devolver um IQueryable deixaria quem chamou montar consultas arbitrárias, vazando o ORM para fora da infraestrutura.
Para praticar. Escreva uma consulta nova no repositório de apólices: "apólices que vencem nos próximos 30 dias". Que nome de método você daria? Ele recebe a data de hoje como parâmetro, ou consulta o relógio? (Dica: veja HasActiveOrFuturePolicyAsync.)
Em uma frase. Dividir a interface de persistência de cada agregado pela intenção de uso: ler, inserir ou alterar.
Analogia. Crachás com níveis de acesso. O visitante só entra na recepção; o funcionário entra no escritório; só a manutenção entra na casa de máquinas. Todos estão no mesmo prédio, mas cada um só consegue fazer o que precisa.
O problema. Um repositório "faz tudo" entrega a um caso de uso de listagem a capacidade de alterar e apagar dados. Nada impede um erro, e olhando o construtor não dá para saber o que o caso de uso realmente faz.
A solução. Três interfaces por agregado, implementadas pela mesma classe:
| Interface | Métodos típicos | Rastreamento do EF |
|---|---|---|
I...ReadOnlyRepository |
GetByIdAsync, GetPageAsync, consultas |
AsNoTracking() |
I...WriteOnlyRepository |
AddAsync |
— |
I...UpdateOnlyRepository |
GetByIdForUpdateAsync |
Com rastreamento |
No PolicyDesk.
internal sealed class ClaimsRepository(PolicyDeskDbContext dbContext) :
IClaimsWriteOnlyRepository,
IClaimsReadOnlyRepository,
IClaimsUpdateOnlyRepositoryCompare as duas buscas pelo id:
public Task<Claim?> GetByIdAsync(Guid id, CancellationToken ct) =>
dbContext.Claims.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id, ct);
public Task<Claim?> GetByIdForUpdateAsync(Guid id, CancellationToken ct) =>
dbContext.Claims.FirstOrDefaultAsync(c => c.Id == id, ct);Duas ideias se combinam:
-
Interface Segregation Principle (o I do SOLID): "nenhum cliente deve depender de métodos que não usa".
ListClaimsUseCaserecebe sóIClaimsReadOnlyRepository. Ele não consegue alterar um sinistro, porque não tem acesso a nenhum método que faça isso. -
Command-Query Separation (Bertrand Meyer): um método ou consulta, ou altera, nunca os dois. Aqui a separação vai até o EF Core: consultas usam
AsNoTracking()(mais rápidas e sem risco de gravar algo por acidente), e buscas para alteração usam rastreamento, para que o EF detecte o que mudou.
Só de olhar o construtor de um caso de uso, você sabe o que ele faz:
internal class ApproveClaimUseCase(
IClaimsUpdateOnlyRepository ..., // altera sinistros
IUnitOfWork ..., // confirmainternal class ListClaimsUseCase(
IClaimsReadOnlyRepository ..., // só lê sinistros
IPoliciesReadOnlyRepository ...) // só lê apólicesCustos e alternativas. Três interfaces por agregado é mais cerimônia. Isso não é CQRS completo (modelos separados de leitura e escrita, talvez até bancos separados), mas é o mesmo princípio em escala menor, e um bom primeiro passo: permitiria, por exemplo, apontar as leituras para uma réplica do banco sem mexer nos casos de uso.
Para praticar. Em GetByIdForUpdateAsync, acrescente AsNoTracking() e rode um teste de integração de aprovação. O que acontece com a aprovação? Por quê?
Em uma frase. (Fowler) Manter a lista de objetos afetados por uma operação de negócio e gravar todas as mudanças juntas, de uma vez.
Analogia. Um carrinho de compras: você vai colocando os itens, e só no caixa tudo é pago de uma vez. Se o cartão for recusado, nenhum item é levado.
O problema. Se cada repositório salvasse por conta própria, uma operação que altera duas coisas (criar a apólice e atualizar o segurado) poderia gravar a primeira e falhar na segunda, deixando o banco pela metade.
A solução. Os repositórios só registram mudanças; um único objeto, o Unit of Work, grava tudo em uma transação, quando o caso de uso mandar.
No PolicyDesk.
public interface IUnitOfWork
{
Task CommitAsync(CancellationToken cancellationToken = default);
}
internal class UnitOfWork(PolicyDeskDbContext dbContext) : IUnitOfWork
{
public async Task CommitAsync(CancellationToken cancellationToken = default)
{
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
throw new ConflictException(ResourcesMessageException.CONCURRENCY_CONFLICT);
}
catch (DbUpdateException exception) when (exception.InnerException is PostgresException
{ SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: ActiveEmailIndex })
{
throw new ErrorOnValidationException(ResourcesMessageException.POLICYHOLDER_ACTIVE_EMAIL_EXISTS);
}
}
}Os repositórios não salvam. AddAsync só registra a entidade no contexto; GetByIdForUpdateAsync só a coloca sob rastreamento. Quem decide quando tudo vai para o banco é o caso de uso, chamando CommitAsync() uma vez, no final.
Em RegisterPolicyUseCase, isso garante que a nova apólice e a alteração no segurado são gravadas na mesma transação (o SaveChanges do EF Core é transacional). Ou as duas acontecem, ou nenhuma:
sequenceDiagram
participant UC as RegisterPolicyUseCase
participant PH as Policyholder (rastreado)
participant RP as PoliciesRepository
participant UoW as UnitOfWork
participant DB as PostgreSQL
UC->>PH: RegisterPolicy() (marcado como alterado)
UC->>RP: AddAsync(policy) (marcado como novo)
UC->>UoW: CommitAsync()
UoW->>DB: BEGIN
UoW->>DB: INSERT INTO policies ...
UoW->>DB: UPDATE policyholders ... WHERE xmin = @v
UoW->>DB: COMMIT
Os testes de aplicação conferem explicitamente que o CommitAsync não é chamado quando há erro:
await Should.ThrowAsync<NotFoundException>(() => useCase.ExecuteAsync(Guid.NewGuid()));
unitOfWork.Verify(work => work.CommitAsync(), Times.Never);Custos e alternativas. Operações que precisam de várias transações, ou que envolvem outros sistemas (uma fila, outro serviço), não cabem em um único SaveChanges. Para isso existem padrões como o Outbox e as Sagas.
Para praticar. Em RegisterClaimUseCase, mova o CommitAsync para antes das validações de vigência. Que teste quebra? O que isso mostra?
Em uma frase. (Fowler) Detectar conflitos entre operações simultâneas conferindo, no momento da gravação, se o registro mudou desde que foi lido.
Analogia. Uma planilha compartilhada que avisa "este arquivo foi alterado por outra pessoa desde que você o abriu; recarregue antes de salvar".
O problema. Dois analistas abrem o mesmo sinistro em análise. Um aprova, o outro nega, quase ao mesmo tempo:
t1 Analista A lê o sinistro (InAnalysis)
t2 Analista B lê o sinistro (InAnalysis)
t3 A: claim.Approve() → UPDATE status = 'Approved'
t4 B: claim.Deny() → UPDATE status = 'Denied' ← sobrescreve a aprovação!
Cada requisição, isoladamente, passou na guarda EnsureStatus(InAnalysis). O resultado final é inconsistente: um sinistro negado com data de aprovação preenchida. Isso se chama atualização perdida (lost update).
A solução. Guardar a versão do registro na leitura, e gravar só se a versão continuar a mesma. "Otimista" porque não bloqueia ninguém: supõe que conflitos são raros e trata o conflito quando ele acontece.
No PolicyDesk. O PostgreSQL tem uma coluna de sistema, xmin, que muda a cada UPDATE da linha. O Npgsql permite usá-la como token de concorrência:
// ClaimMap.cs
builder.Property<uint>("Version").IsRowVersion();Version é uma shadow property: existe no modelo do EF, mas não na classe Claim. O domínio não fica poluído com um detalhe de banco de dados. Com isso, o EF Core gera:
UPDATE claims SET status = 'Approved', approved_at = ..., updated_at = ...
WHERE id = @id AND xmin = @versao_lida;Se outra transação já alterou a linha, o xmin mudou, o WHERE não encontra nada, nenhuma linha é afetada, e o EF lança DbUpdateConcurrencyException. O UnitOfWork a traduz em ConflictException, que vira HTTP 409. A linha do tempo passa a ser:
t3 A: UPDATE ... WHERE xmin = 100 → 1 linha ✅ (xmin vira 101)
t4 B: UPDATE ... WHERE xmin = 100 → 0 linhas ❌ → 409 Conflict
O teste ClaimConcurrencyTests.Deve_AceitarApenasUmaDecisao_Quando_AprovarENegarAoMesmoTempo dispara as duas requisições em paralelo e confere que exatamente uma é aceita.
Há um caso mais sutil. A regra S9 diz: "não é possível inativar um segurado com apólice em vigor".
t1 Inativação: confere HasActiveOrFuturePolicyAsync → false (ainda não há apólice)
t2 Emissão: confere segurado.Active → true
t3 Emissão: INSERT da apólice
t4 Inativação: UPDATE segurado SET active = false ← segurado inativo com apólice!
As duas operações gravam em tabelas diferentes, então a versão de uma linha não ajuda. A solução é fazer a emissão também alterar o segurado:
// Policyholder.cs
public void RegisterPolicy()
{
UpdatedAt = DateTime.UtcNow;
}Agora as duas operações gravam na mesma linha de policyholders, e o xmin faz uma delas falhar com 409. Essa técnica é conhecida como "touch" (tocar) do agregado pai. O saldo da cobertura usa a mesma ideia: Policy.RegisterClaim toca a apólice, e dois sinistros simultâneos que juntos estourariam a cobertura não passam.
Custos e alternativas.
| Abordagem | Como | Quando usar |
|---|---|---|
| Otimista (usado) | Token de versão, falha no conflito | Conflitos raros |
| Pessimista |
SELECT ... FOR UPDATE (bloqueia a linha) |
Conflitos frequentes, operações curtas |
Isolamento SERIALIZABLE
|
O banco detecta as anomalias | Regras complexas, aceitando refazer operações |
| Restrição no banco |
CHECK, índice único, exclusion constraint
|
Quando a regra cabe em SQL |
O cliente que recebe 409 deve reler o recurso e decidir se repete a operação. A API não tenta de novo sozinha, porque a decisão do usuário pode mudar ao ver o estado novo.
Para praticar. Exponha a versão como um cabeçalho ETag no GET /api/sinistros/{id} e exija If-Match nas transições. Isso estende a concorrência otimista até o cliente, cobrindo o caso de "abri a tela há 10 minutos".
Em uma frase. Marcar registros como removidos em vez de apagá-los, e escondê-los automaticamente das consultas.
Analogia. Arquivar um e-mail em vez de excluí-lo: ele some da caixa de entrada, mas continua lá se você precisar procurar.
O problema. Apagar um segurado de verdade deixaria o histórico de apólices e sinistros sem dono. Mas, se ele só for marcado como inativo, toda consulta precisaria lembrar de filtrar os inativos, e uma esquecida mostraria clientes que não existem mais.
A solução. Um campo Active e um filtro global, aplicado pelo ORM a toda consulta, com um jeito explícito de desligá-lo.
No PolicyDesk.
// PolicyholderMap.cs
public const string ActiveFilter = "ActivePolicyholders";
builder.HasQueryFilter(ActiveFilter, policyholder => policyholder.Active);Toda consulta aos segurados ganha um WHERE active = TRUE implícito. Quando é preciso ver os inativos, o filtro é desligado pelo nome:
public Task<Policyholder?> GetByIdIncludingInactiveAsync(Guid id, CancellationToken ct)
{
return dbContext.Policyholders
.IgnoreQueryFilters([PolicyholderMap.ActiveFilter])
.FirstOrDefaultAsync(p => p.Id == id, ct);
}Filtros com nome são novidade do EF Core 10. Antes,
IgnoreQueryFilters()desligava todos os filtros da entidade de uma vez. Com nomes, um filtro futuro (de multi-empresa, por exemplo) continuaria ativo.
Por que existe GetByIdIncludingInactiveAsync? Para a mensagem certa. Ao emitir uma apólice para um segurado inativo, a resposta deve ser "o segurado está inativo" (400), e não "segurado não encontrado" (404).
Unicidade com soft delete. O índice único de e-mail é parcial:
builder.HasIndex(p => p.Email)
.HasDatabaseName("IX_policyholders_email_active")
.IsUnique()
.HasFilter("\"active\" = TRUE");Um e-mail usado por um segurado inativo pode ser reutilizado, mas nunca por dois ativos.
Custos e alternativas. É fácil esquecer que o filtro existe e se surpreender com um "não encontrado". Relatórios e junções precisam considerar os inativos explicitamente. Uma alternativa é mover os registros apagados para uma tabela de arquivo.
Para praticar. Escreva uma consulta que conte os segurados inativos. Que método do EF você precisou usar?
Em uma frase. Inserir um registro ou, se ele já existir, atualizá-lo, em uma única instrução atômica.
Analogia. Uma catraca: verificar se a passagem é válida e liberar a passagem acontecem no mesmo movimento. Não existe um intervalo em que duas pessoas passem com o mesmo bilhete.
O problema. A regra S8 diz que cadastrar o CPF de um segurado inativo o reativa. A implementação ingênua tem uma condição de corrida:
// ❌ Verificar, depois agir
var existing = await db.Policyholders.IgnoreQueryFilters().FirstOrDefaultAsync(p => p.Cpf == cpf);
if (existing is null) db.Add(novo); // duas requisições podem chegar aqui juntas
else if (!existing.Active) existing.Reactivate(...);
else throw CpfDuplicado;A solução. Deixar o banco verificar e gravar na mesma instrução.
No PolicyDesk. O repositório usa o INSERT ... ON CONFLICT do PostgreSQL:
INSERT INTO policyholders (id, name, cpf, birth_date, email, phone, created_at, updated_at, active)
VALUES (...)
ON CONFLICT (cpf) DO UPDATE
SET name = EXCLUDED.name,
birth_date = EXCLUDED.birth_date,
email = EXCLUDED.email,
phone = EXCLUDED.phone,
active = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE policyholders.active = FALSE
RETURNING idO resultado é interpretado pelas linhas devolvidas pelo RETURNING:
| Situação | Linhas devolvidas | Resultado |
|---|---|---|
| CPF novo | 1 (INSERT), com o id novo | RegisteredOrReactivated |
| CPF de um inativo | 1 (UPDATE), com o id original | RegisteredOrReactivated |
| CPF de um ativo | 0 (o WHERE do UPDATE falha) |
ActiveCpfAlreadyExists |
| E-mail de outro ativo | Exceção 23505 no índice de e-mail |
ActiveEmailAlreadyExists |
O repositório devolve um resultado, sem lançar exceção, e o caso de uso decide a mensagem:
switch (registration.Result)
{
case PolicyholderRegistrationResult.ActiveCpfAlreadyExists:
throw new ErrorOnValidationException(ResourcesMessageException.POLICYHOLDER_ACTIVE_CPF_EXISTS);
case PolicyholderRegistrationResult.ActiveEmailAlreadyExists:
throw new ErrorOnValidationException(ResourcesMessageException.POLICYHOLDER_ACTIVE_EMAIL_EXISTS);
case PolicyholderRegistrationResult.RegisteredOrReactivated:
logger.PolicyholderRegisteredOrReactivated();
return new ResponseCreatedJson { Id = registration.PolicyholderId ?? policyholder.Id };
default:
throw new UnreachableException($"Resultado de cadastro inesperado: {registration.Result}");
}Detalhe de segurança. O SQL é escrito à mão com uma string interpolada, mas o SqlQuery transforma cada {valor} em um parâmetro SQL, e não em texto colado. Não há risco de SQL injection.
Custos e alternativas. É SQL específico do PostgreSQL (outros bancos têm MERGE ou sintaxes próprias) e fica fora do rastreamento do EF. É uma troca consciente: a atomicidade vale mais que a portabilidade. A alternativa portável seria um bloqueio pessimista, com mais idas ao banco.
Para praticar. Em um teste de infraestrutura, dispare dois cadastros com o mesmo CPF ao mesmo tempo (Task.WhenAll) e confira que só um é gravado.
Em uma frase. Converter exceções de uma camada mais baixa em exceções com significado para a camada de cima, sem vazar detalhes de implementação.
Analogia. Um intérprete em uma reunião: ele não repete as palavras ao pé da letra, mas transmite o sentido na língua de quem ouve.
O problema. Se a exceção do banco (PostgresException 23505) subisse até a API, a camada HTTP precisaria conhecer códigos de erro do PostgreSQL, e o cliente receberia um erro 500 para algo que, na verdade, é "e-mail já cadastrado".
A solução. No ponto em que a camada inferior é usada, capturar só as exceções conhecidas e relançá-las como exceções do vocabulário do negócio.
No PolicyDesk. O UnitOfWork é o ponto de tradução da persistência:
| Exceção do EF/Npgsql | Significado técnico | Traduzida para | HTTP |
|---|---|---|---|
DbUpdateConcurrencyException |
Nenhuma linha afetada no UPDATE com versão | ConflictException |
409 |
PostgresException 23505 no IX_policyholders_email_active
|
Violação do índice único | ErrorOnValidationException |
400 |
O filtro when, com pattern matching, só captura a exceção exata:
catch (DbUpdateException exception) when (exception.InnerException is PostgresException
{ SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: ActiveEmailIndex })Qualquer outra violação continua sendo um erro inesperado (500), em vez de ser disfarçada de erro do usuário.
Por que conferir no banco, se o caso de uso já confere? UpdatePolicyholderUseCase consulta ExistActivePolicyholdersWithEmailAsync antes de salvar. Mas duas atualizações simultâneas podem passar pela consulta ao mesmo tempo. O índice é a última linha de defesa, e a tradução garante que essa defesa produza a mesma mensagem amigável da verificação anterior.
Na borda da API, o ExceptionFilter faz a segunda tradução, de exceções de negócio para status HTTP. Veja Exception Filter e Tratamento de Erros.
Custos e alternativas. Capturar exceções por código de erro e nome de índice acopla o UnitOfWork a detalhes do banco. É o lugar certo para esse acoplamento: a infraestrutura existe justamente para conhecer o banco.
Para praticar. Crie um índice único novo (por exemplo, telefone entre ativos) e acrescente a tradução correspondente no UnitOfWork. Escreva o teste de infraestrutura que prova a tradução.
Em uma frase. Processar cada requisição por uma sequência de componentes, em que cada um pode agir antes e depois do próximo, ou interromper a cadeia.
Analogia. Uma linha de montagem de ida e volta: o produto passa pela estação de inspeção, depois pela de embalagem, e, no caminho de volta, cada estação ainda pode conferir ou carimbar o resultado. A ordem das estações muda o resultado.
O problema. Preocupações que valem para toda requisição (identificador de rastreamento, log, tratamento de erros, idioma) não podem ser repetidas em cada controller. Seria código duplicado e fácil de esquecer.
A solução. Uma cadeia de componentes, os middlewares, montada uma vez. Cada um recebe a requisição, faz sua parte e chama o próximo (next). Isso é uma variação do padrão Chain of Responsibility (GoF), com características do Decorator: cada middleware "embrulha" o resto da cadeia.
No PolicyDesk.
app.UseCorrelationIdHeader();
app.UseHttpLogging();
app.UseErrorHandling();
app.UseRequestLocalization();
// ...
app.MapControllers();flowchart LR
R[Requisição] --> A[CorrelationId] --> B[HttpLogging] --> C[ExceptionHandler] --> D[Localization] --> E[Controller]
E -.resposta.-> D -.-> C -.-> B -.-> A -.-> S[Resposta]
Um middleware customizado, em sua forma mínima:
app.Use((context, next) =>
{
var traceId = Get(context);
context.Response.OnStarting(() =>
{
context.Response.Headers[HeaderName] = traceId;
return Task.CompletedTask;
});
return next(context); // passa adiante
});A ordem tem significado. O log de requisições "embrulha" o tratamento de erros, então registra o status final. Se ficasse dentro, veria a exceção passar, mas não o 500 que o tratador produz depois.
Um detalhe técnico: OnStarting. O cabeçalho não é escrito na hora, mas no instante em que a resposta começa a ser enviada. Motivo: o UseExceptionHandler limpa os cabeçalhos antes de escrever o erro. Se o X-Trace-Id fosse escrito no início, sumiria justamente nas respostas de erro, que são as que mais precisam dele.
Custos e alternativas. A ordem é implícita no código e fácil de quebrar ao acrescentar um middleware no lugar errado. Para comportamentos que só valem para controllers, os filtros do MVC são uma alternativa mais localizada.
Para praticar. Inverta a ordem de UseHttpLogging e UseErrorHandling, provoque um erro 500 e compare o status registrado no log com o status da resposta.
Em uma frase. Centralizar a conversão de exceções em respostas HTTP, tirando try/catch dos controllers.
Analogia. Uma central de atendimento: qualquer problema, venha de onde vier, chega a uma mesa que sabe classificá-lo e responder ao cliente no formato padrão.
O problema. Sem um ponto central, cada controller teria um try/catch traduzindo exceções em status HTTP, cada um de um jeito, e um controller esquecido devolveria um 500 com detalhes internos.
A solução. Um único filtro, registrado para todos os controllers, com a tabela "tipo de exceção → status HTTP".
No PolicyDesk.
public class ExceptionFilter(ProblemDetailsFactory problemDetailsFactory, ILogger<ExceptionFilter> logger) : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
switch (context.Exception)
{
case ErrorOnValidationException e: SetErrorResult(context, 400, e.GetErrors()); break;
case DomainRuleException e: SetErrorResult(context, 400, [e.Message]); break;
case NotFoundException e: SetErrorResult(context, 404, [e.Message]); break;
case ConflictException e: SetErrorResult(context, 409, [e.Message]); break;
case BadHttpRequestException e: SetErrorResult(context, e.StatusCode, [INVALID_REQUEST]); break;
case OperationCanceledException when context.HttpContext.RequestAborted.IsCancellationRequested:
context.Result = new StatusCodeResult(499); break;
default:
logger.UnhandledException(...);
SetErrorResult(context, 500, [UNKNOWN_ERROR]); break;
}
}
}(Versão resumida; o código completo está em Tratamento de Erros.)
Registrado para todos os controllers:
builder.Services.AddControllers(options => options.Filters.Add<ExceptionFilter>());O domínio e a aplicação lançam exceções com significado ("não encontrado", "conflito"), sem saber nada de HTTP. Só o filtro sabe que NotFoundException significa 404. Trocar a API REST por outra tecnologia exigiria só um novo tradutor.
Segurança. No default, o detalhe da exceção vai só para o log. O cliente recebe uma mensagem genérica e o traceId.
Custos e alternativas. O filtro só pega exceções lançadas dentro do MVC. Para exceções em middlewares, existe a rede de segurança UseExceptionHandler(). Uma alternativa moderna ao filtro é implementar IExceptionHandler (ASP.NET Core 8+), que funciona no nível de middleware e cobre os dois casos.
Para praticar. Crie uma exceção ForbiddenException e faça o filtro responder 403. Quantos arquivos você alterou?
Em uma frase. Usar um formato padronizado (RFC 9457) para descrever os erros de uma API HTTP.
Analogia. O formulário padrão de reclamação de uma agência reguladora: qualquer empresa que receba aquele formulário sabe exatamente onde está cada informação.
O problema. Sem um padrão, cada endpoint inventa seu formato de erro: { "message": ... } aqui, { "errors": { "Name": [...] } } ali, corpo vazio acolá. O cliente precisa de um tratador diferente para cada caso.
A solução. Um formato único, definido por uma especificação pública e conhecido por bibliotecas de várias linguagens.
No PolicyDesk. Todas as respostas de erro, inclusive as geradas pelo próprio framework (404 de rota, 405, 415), seguem:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"instance": "/api/sinistros/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"errors": ["Sinistro não encontrado."],
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}errors e traceId são extensões, permitidas pela RFC. A configuração em ProblemDetailsSetup garante três coisas:
-
CustomizeProblemDetailsacrescentainstance,traceIdeerrorsa qualquer ProblemDetails gerado. -
InvalidModelStateResponseFactorysubstitui a resposta padrão de JSON malformado (que usariaerrorscomo dicionário) pela mesma lista das demais respostas. -
UseStatusCodePagesdá corpo às respostas que sairiam vazias (404 de rota, 405).
Uma sutileza: PostConfigure.
services.PostConfigure<ApiBehaviorOptions>(options => options.InvalidModelStateResponseFactory = ...);O AddControllers() registra a fábrica padrão. Um Configure comum poderia rodar antes e ser sobrescrito. O PostConfigure roda depois de todos os Configure, garantindo a última palavra.
Custos e alternativas. O campo type poderia apontar para páginas próprias, explicando cada tipo de erro do negócio (/erros/saldo-insuficiente). O PolicyDesk usa os links padrão da RFC do HTTP, mais simples.
Para praticar. Chame GET /api/rota-inexistente, DELETE /api/apolices (método não permitido) e um POST com Content-Type: text/plain. Confira que as três respostas têm errors e traceId.
Em uma frase. Dar um identificador único a cada requisição e levá-lo para logs, traces e respostas, para ligar tudo o que aconteceu nela.
Analogia. O número de protocolo de um atendimento: com ele, qualquer atendente encontra todo o histórico do seu caso.
O problema. Em um log com milhares de linhas por minuto, como encontrar as que pertencem à requisição de um usuário específico que reclamou de um erro?
A solução. Um identificador gerado no início da requisição, anexado a tudo o que ela produz, e devolvido ao cliente.
No PolicyDesk. O identificador é o trace id do W3C Trace Context, o mesmo usado pelo OpenTelemetry:
public static string Get(HttpContext httpContext) =>
Activity.Current?.TraceId.ToHexString() ?? httpContext.TraceIdentifier;Ele aparece em três lugares:
| Lugar | Para quem |
|---|---|
Cabeçalho X-Trace-Id em toda resposta |
Cliente, suporte |
Campo traceId nos erros |
O cliente, ao reportar um problema |
| Logs e traces exportados | A equipe que opera o sistema |
O fluxo de suporte fica: o usuário informa o traceId → a equipe busca no Aspire Dashboard (ou Jaeger, Grafana...) → vê o trace completo, com as consultas SQL e o log do erro.
Por que reaproveitar o trace id em vez de gerar um GUID próprio? Porque assim é o mesmo valor em todas as ferramentas. Um id próprio teria de ser anexado à mão em cada log e trace.
Custos e alternativas. Se o cliente enviar o próprio identificador (um cabeçalho X-Correlation-Id, por exemplo), é preciso decidir se ele é aceito ou ignorado, e validar o formato. O PolicyDesk segue o padrão W3C e aceita o cabeçalho traceparent, que o OpenTelemetry já entende.
Para praticar. Faça uma requisição com erro, copie o X-Trace-Id e encontre, no Aspire Dashboard, o trace e todos os logs da requisição.
Em uma frase. Representar uma seção de configuração como uma classe tipada, injetável e validável.
Analogia. Um checklist de decolagem: o avião não sai do chão enquanto todos os itens obrigatórios não estiverem conferidos.
O problema. Ler a configuração com configuration["ConnectionStrings:DbConnection"] espalha "textos mágicos" pelo código, sem tipo e sem validação. Uma configuração faltando só é percebida quando o código que a usa roda, talvez horas depois do deploy, com um erro confuso.
A solução. Uma classe para cada grupo de configuração, ligada à seção correspondente, com regras de validação conferidas na subida da aplicação.
No PolicyDesk.
public sealed class DatabaseOptions
{
public const string SectionName = "ConnectionStrings";
public string DbConnection { get; init; } = string.Empty;
}services.AddOptions<DatabaseOptions>()
.Bind(configuration.GetSection(DatabaseOptions.SectionName))
.Validate(options => !string.IsNullOrWhiteSpace(options.DbConnection),
ResourcesMessageException.MISSING_CONNECTION_STRING)
.ValidateOnStart();
services.AddOptions<BusinessOptions>()
.Bind(configuration.GetSection(BusinessOptions.SectionName))
.Validate(options => !string.IsNullOrWhiteSpace(options.TimeZone) && TimeZoneInfo.TryFindSystemTimeZoneById(options.TimeZone, out _),
ResourcesMessageException.INVALID_BUSINESS_TIME_ZONE)
.ValidateOnStart();ValidateOnStart: falhar rápido. Sem ele, a validação só aconteceria no primeiro uso. Com ele, a aplicação nem sobe se a configuração estiver errada, e a mensagem explica como corrigir. No deploy, isso significa falhar na hora, e não às 3 da manhã.
O fuso de negócio segue o mesmo caminho: a BusinessOptions confere que o identificador existe no sistema. Um America/Inexistente derruba a aplicação na subida, com a mensagem do .resx.
Custos e alternativas. Para validações mais ricas, é possível usar Data Annotations ([Required]) com ValidateDataAnnotations(), ou uma classe que implementa IValidateOptions<T>. O PolicyDesk usa Validate com uma expressão, suficiente para duas regras.
Para praticar. Suba a API com Business__TimeZone=America/Inexistente e leia a mensagem no log.
Em uma frase. Tratar "agora" como uma dependência injetada, e não como uma chamada estática (DateTime.UtcNow), para que o tempo possa ser controlado e "hoje" seja calculado no fuso certo.
Analogia. Um ensaio de teatro com um relógio de cena: o diretor pode adiantar o relógio para ensaiar a cena da meia-noite às duas da tarde.
O problema. Uma regra como "a data de ocorrência não pode ser futura", escrita com DateTime.UtcNow.Date, tem dois defeitos: usa o dia em UTC (às 22h em Brasília, já é amanhã), e não pode ser testada em um horário específico sem esperar o relógio.
A solução. O código pede o tempo a um objeto injetado. Em produção, ele lê o relógio real; nos testes, um relógio controlado.
No PolicyDesk.
public sealed class BusinessClock(TimeProvider timeProvider, TimeZoneInfo timeZone)
{
public DateTime UtcNow => timeProvider.GetUtcNow().UtcDateTime;
public DateOnly Today => DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(timeProvider.GetUtcNow(), timeZone).DateTime);
}services.TryAddSingleton(TimeProvider.System);
services.AddSingleton(serviceProvider => new BusinessClock(
serviceProvider.GetRequiredService<TimeProvider>(),
TimeZoneInfo.FindSystemTimeZoneById(serviceProvider.GetRequiredService<IOptions<BusinessOptions>>().Value.TimeZone)));- O
TimeProvideré a abstração de tempo do próprio .NET (8+). Em produção,TimeProvider.System; nos testes,FakeTimeProvider. - O
TryAddSingletonsó registra o relógio real se ninguém tiver registrado outro antes, o que facilita a troca nos testes. - O caso de uso pede
clock.Todaye passa o dia como parâmetro para a entidade e para o repositório. O domínio continua sem depender de relógio:new Policyholder(..., today).
Custos e alternativas. Uma interface própria (IClock, IDateTimeProvider) era o caminho comum antes do .NET 8. O Ambient Context (um SystemTime.Now estático e substituível) é mais curto, mas esconde a dependência e vaza estado entre testes que rodam em paralelo.
Para praticar. Escreva um teste de domínio que prove: às 2h59 UTC do dia 11/03, BusinessClock.Today é 10/03; às 3h, é 11/03. (Depois compare com BusinessClockTests.)
Em uma frase. Declarar os eventos de log como métodos tipados, com o código de log gerado pelo compilador.
Analogia. Um formulário de ocorrência com campos fixos, em vez de um caderno de anotações livres: todos registram as mesmas informações, no mesmo lugar, e ninguém anota o que não deve.
O problema. logger.LogInformation($"Sinistro {id} aprovado") espalhado pelo código tem três defeitos: monta o texto mesmo quando o log está desligado (desperdício), transforma o id em texto solto (difícil de pesquisar), e não deixa claro o que é registrado (um dia alguém loga um CPF).
A solução. Um catálogo de eventos, cada um declarado como um método com atributos; o compilador gera a implementação otimizada.
No PolicyDesk.
// Faixas de EventId: 1xxx sinistros, 2xxx apólices, 3xxx segurados.
internal static partial class ApplicationLogs
{
[LoggerMessage(EventId = 1001, Level = LogLevel.Information,
Message = "Sinistro {ClaimId} registrado na apólice {PolicyId} no valor de {Amount}")]
public static partial void ClaimRegistered(this ILogger logger, Guid claimId, Guid policyId, decimal amount);
}O uso é uma chamada de método comum:
logger.ClaimRegistered(claim.Id, policy.Id, claim.Amount);| Benefício | Explicação |
|---|---|
| Desempenho | O código gerado confere o nível do log antes de tudo e não converte Guid e decimal em objetos à toa. Uma string interpolada seria montada sempre, mesmo com o log desligado |
| Logs estruturados |
{ClaimId} vira um campo pesquisável, e não texto solto |
| Catálogo | Todos os eventos ficam em um arquivo, com EventId único. Dá para criar alertas por EventId = 9001
|
| Proteção de dados | O catálogo deixa explícito o que é registrado: só ids e valores |
O analisador CA1848 aponta qualquer chamada direta a LogInformation(...) e similares. Com avisos tratados como erros, uma chamada direta quebra o build do PolicyDesk. Por isso todos os logs passam pelos catálogos.
Antes do .NET 6, a mesma otimização exigia escrever à mão um LoggerMessage.Define:
private static readonly Action<ILogger, Guid, Exception?> _claimApproved =
LoggerMessage.Define<Guid>(LogLevel.Information, new EventId(1003, "ClaimApproved"),
"Sinistro {ClaimId} aprovado");
public static void ClaimApproved(this ILogger logger, Guid claimId) =>
_claimApproved(logger, claimId, null);O gerador produz esse código a partir da assinatura do método.
Custos e alternativas. Cada evento novo exige um método no catálogo, o que é mais cerimônia que uma linha de log solta. Bibliotecas como o Serilog oferecem logs estruturados com outra API, mas o catálogo continua sendo uma boa prática.
Para praticar. Acrescente um evento para a apólice cancelada que registre também a data de cancelamento. Que faixa de EventId você usou?
Em uma frase. Expor um endpoint que informa se a aplicação e suas dependências estão saudáveis.
Analogia. O enfermeiro que mede os sinais vitais a cada hora: não diagnostica a doença, mas avisa rapidamente que algo está errado.
O problema. Um balanceador de carga não tem como saber, sozinho, se uma instância da API está funcionando. Sem essa informação, ele continua mandando usuários para uma instância que não consegue falar com o banco.
A solução. Um endpoint leve, que confere as dependências essenciais e responde "saudável" ou "não saudável".
No PolicyDesk.
services.AddHealthChecks().AddDbContextCheck<PolicyDeskDbContext>("database");
app.MapHealthChecks("/health");| Situação | Resposta |
|---|---|
| Banco acessível |
200 OK, com o corpo Healthy
|
| Banco fora |
503 Service Unavailable, com o corpo Unhealthy
|
O mesmo endpoint é o smoke test do pipeline de deploy (veja CI/CD e Deploy).
Custos e alternativas. Em produção, é comum separar liveness ("o processo está vivo?") de readiness ("consegue atender agora?"). Assim, uma queda do banco tira a instância do balanceador, mas não faz o orquestrador reiniciá-la em loop. Também é preciso cuidado para o health check não ser caro: ele é chamado o tempo todo.
Para praticar. Pare o contêiner do banco (docker stop) e chame /health. Suba de novo e chame outra vez.
Em uma frase. Permitir que uma operação longa seja interrompida quando o resultado dela não é mais necessário.
Analogia. Cancelar um pedido no restaurante: se o cliente foi embora, a cozinha deveria parar de preparar o prato, mas só para se alguém avisar.
O problema. O cliente fecha a aba do navegador no meio de uma consulta pesada. Sem cancelamento, o servidor continua executando a consulta até o fim, gastando banco e CPU para entregar uma resposta que ninguém vai ler.
A solução. Um "sinal de cancelamento" (CancellationToken) criado pela requisição e repassado a cada operação. Quem recebe o sinal para assim que puder.
No PolicyDesk. O ASP.NET injeta um CancellationToken ligado ao fechamento da conexão (HttpContext.RequestAborted). Ele é repassado do controller até o banco:
Controller(cancellationToken)
→ useCase.ExecuteAsync(id, cancellationToken)
→ repository.GetByIdForUpdateAsync(id, cancellationToken)
→ FirstOrDefaultAsync(..., cancellationToken) ← o Npgsql cancela a consulta no servidor
→ unitOfWork.CommitAsync(cancellationToken)
Se o cliente fecha a conexão, a consulta é cancelada no PostgreSQL. O filtro reconhece o caso e registra 499 (convenção do servidor web Nginx para "o cliente fechou a requisição"), com um log informativo, e não de erro:
case OperationCanceledException when context.HttpContext.RequestAborted.IsCancellationRequested:
logger.RequestCancelled(...);
context.Result = new StatusCodeResult(StatusClientClosedRequest);A condição when é importante: um cancelamento sem o cliente ter desistido (um tempo limite interno, por exemplo) continua sendo tratado como erro 500.
Cooperativo significa que o cancelamento não interrompe nada à força. Cada método decide repassar o sinal, e é por isso que ele precisa ser propagado por todas as camadas.
Custos e alternativas. Cancelar no meio de uma gravação pode deixar operações pela metade, se não forem transacionais. No PolicyDesk, a gravação é uma única transação (SaveChanges), então ou tudo é gravado, ou nada.
Para praticar. Procure um método assíncrono que não receba CancellationToken. Por que ele não precisa?
Em uma frase. Limpar os dados de entrada no ponto em que entram no sistema, para que nenhuma camada interna precise se preocupar com isso.
Analogia. O tapete na entrada de casa: você limpa os pés uma vez, na porta, em vez de passar pano em cada cômodo.
O problema. " Maria da Silva " e "Maria da Silva" são o mesmo nome, mas, sem limpeza, seriam gravados diferentes, quebrariam comparações e passariam por validações de tamanho que não deveriam.
A solução. Um conversor de JSON global que normaliza todo texto recebido, antes de qualquer código da aplicação vê-lo.
No PolicyDesk.
public partial class StringConverter : JsonConverter<string>
{
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var value = reader.GetString()?.Trim();
return value is null ? value : RemoveExtraWhitespace().Replace(value, " ").Trim();
}
[GeneratedRegex(@"\s+")]
private static partial Regex RemoveExtraWhitespace();
}" Maria da Silva " chega ao caso de uso como "Maria da Silva". Isso vale para todos os textos de todas as requisições.
O [GeneratedRegex] é outro gerador de código: a expressão regular é convertida em C# na compilação, sem custo de interpretação durante a execução.
Custos e alternativas. Normalizar tudo pode ser indesejado para campos em que os espaços importam (uma senha, um texto formatado). Nesses casos, seria preciso uma exceção explícita. As normalizações específicas de cada conceito (CPF só com dígitos, e-mail em minúsculas) ficam nos validadores do domínio, porque são regras de negócio.
Para praticar. Cadastre um segurado com o nome " Ana Souza " e confira como ele foi gravado.
Em uma frase. (Nat Pryce) Criar objetos de teste com valores padrão válidos, deixando o teste sobrescrever apenas o que importa para ele.
Analogia. Um pedido de pizza com "a de sempre, mas sem cebola": você só fala a diferença, e todo o resto vem no padrão.
O problema. Para testar "cobertura zero é recusada", o teste precisaria preencher todos os campos da apólice (segurado, descrição, prêmio, datas). O que importa (a cobertura) se perde no meio do resto. E, quando um campo obrigatório novo aparece, dezenas de testes precisam mudar.
A solução. Um método que monta o objeto inteiro, válido, e aceita parâmetros opcionais para os campos que o teste quer controlar.
No PolicyDesk.
public static class RequestPolicyJsonBuilder
{
public static RequestPolicyJson Build(
Guid? policyholderId = null,
string? description = null,
decimal? coverageAmount = null,
decimal? premiumAmount = null,
DateOnly? startDate = null,
DateOnly? endDate = null)
{
var faker = new Faker();
var today = BusinessClockBuilder.DefaultToday;
return new RequestPolicyJson
{
SeguradoId = policyholderId ?? Guid.NewGuid(),
Descricao = description ?? faker.Commerce.ProductName(),
ValorCobertura = coverageAmount ?? faker.Finance.Amount(1000, 100000),
ValorPremio = premiumAmount ?? faker.Finance.Amount(10, 1000),
InicioVigencia = startDate ?? today.AddDays(-1),
FimVigencia = endDate ?? today.AddDays(30)
};
}
}No teste:
var request = RequestPolicyJsonBuilder.Build(coverageAmount: 0);O teste diz só o que importa para ele: "cobertura zero". Todo o resto é válido por padrão. Se um campo obrigatório novo for adicionado ao request, só o builder muda. Os dados aleatórios do Bogus ainda ajudam a revelar suposições escondidas: um teste que só passa com um valor específico vai falhar de vez em quando, e mostrar o problema.
O builder clássico usa uma interface fluente (
.WithCoverage(0).Build()). Em C#, parâmetros opcionais com nome entregam o mesmo resultado com menos código.
Custos e alternativas. Dados aleatórios podem tornar uma falha difícil de reproduzir; nesses casos, vale fixar a semente do Bogus. Para objetos muito complexos, bibliotecas como o AutoFixture geram tudo automaticamente.
Para praticar. Escreva um teste que confirme que uma apólice com fimVigencia igual a inicioVigencia é recusada, usando o builder.
Em uma frase. Uma "fábrica" de objetos de teste prontos, em configurações comuns e com nome.
Analogia. Um ator de reserva já vestido e maquiado para o papel: ele entra em cena pronto, sem esperar a preparação.
O problema. Muitos testes precisam de "um sinistro aprovado" ou "um sinistro negado". Montar esse cenário em cada teste repete código, e cada repetição é uma chance de montar errado.
A solução. Métodos com nome que devolvem objetos em estados prontos e conhecidos.
No PolicyDesk.
// Leva o sinistro até o status pedido passando apenas por transições válidas
public static Claim BuildWithStatus(ClaimStatus status, Guid? policyId = null)
{
var claim = Build(policyId);
if (status == ClaimStatus.Pending)
return claim;
claim.StartAnalysis();
switch (status)
{
case ClaimStatus.Denied: claim.Deny("Not covered"); break;
case ClaimStatus.Approved: claim.Approve(); break;
case ClaimStatus.Paid: claim.Approve(); claim.RegisterPayment(); break;
}
return claim;
}O ponto mais importante. Como Status tem private set, o teste não pode simplesmente atribuir o status. O Object Mother percorre as transições reais. Isso significa que até os testes respeitam a máquina de estados: não existe teste com um sinistro "pago sem aprovação", porque esse estado é impossível de construir.
Custos e alternativas. Object Mothers tendem a crescer com variações ("sinistro aprovado de valor alto em apólice cancelada..."). Quando isso acontece, combiná-lo com Test Data Builders (como o PolicyDesk faz) mantém o número de métodos pequeno.
Para praticar. Escreva um PolicyBuilder.BuildCancelled(). Ele deve chamar Cancel() ou atribuir o status? Por quê?
Em uma frase. Esconder a configuração dos dublês de teste atrás de métodos legíveis e reaproveitáveis.
Analogia. Um controle remoto universal com botões "ligar TV" e "abrir Netflix", em vez de você decorar a sequência de códigos de cada aparelho.
O problema. A sintaxe do Moq (Setup, It.IsAny, ReturnsAsync) se repete em dezenas de testes. Se a assinatura de um repositório mudar (um parâmetro novo, por exemplo), todos esses testes quebram.
A solução. Uma classe por dublê, com métodos que descrevem o comportamento desejado e devolvem a própria classe, para poder encadear chamadas.
No PolicyDesk.
public class ClaimsUpdateOnlyRepositoryBuilder
{
private readonly Mock<IClaimsUpdateOnlyRepository> _repository = new();
public ClaimsUpdateOnlyRepositoryBuilder GetByIdForUpdate(Claim claim)
{
_repository.Setup(r => r.GetByIdForUpdateAsync(claim.Id, It.IsAny<CancellationToken>())).ReturnsAsync(claim);
return this; // permite encadear
}
public IClaimsUpdateOnlyRepository Build() => _repository.Object;
}No teste:
var repository = new ClaimsUpdateOnlyRepositoryBuilder().GetByIdForUpdate(claim).Build();O teste não precisa conhecer o Moq. E, se a assinatura do repositório mudar, o ajuste é feito em um lugar só.
Custos e alternativas. Para dublês muito simples, o builder pode ser exagero. Uma alternativa é escrever dublês à mão (fakes), como um repositório em memória, que dispensa biblioteca de mocks.
Para praticar. Acrescente ao ClaimsReadOnlyRepositoryBuilder um método que simule um repositório que lança exceção. Que tipo de teste precisaria dele?
Em uma frase. Shared Fixture (Gerard Meszaros): compartilhar um recurso caro entre vários testes. Testcontainers: subir dependências reais em contêineres Docker descartáveis, só durante os testes.
Analogia. Um laboratório que monta o equipamento uma vez por dia de experimentos, em vez de montar e desmontar para cada experimento, mas que limpa a bancada entre um e outro.
O problema. Testar com um banco de verdade é lento se cada teste subir o próprio banco. E testar com um banco "de mentira" não prova nada sobre o que só um banco real faz.
A solução. Subir um PostgreSQL real em um contêiner uma vez por grupo de testes, aplicar as migrations reais, e limpar os dados entre os testes.
No PolicyDesk.
public class PolicyDeskApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _database = new PostgreSqlBuilder("postgres:16-alpine")
.WithDatabase("policydesk_integration_tests")
.Build();
public async Task InitializeAsync() => await _database.StartAsync();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DbConnection"] = _database.GetConnectionString()
}));
}
}O IClassFixture<PolicyDeskApplicationFactory> faz o xUnit criar uma fábrica (e um contêiner) por classe de teste.
Por que banco real, e não o provedor InMemory do EF? O provedor InMemory não é um banco relacional. Ele não executa SQL, não tem xmin, não respeita índices parciais, não tem ON CONFLICT nem transações reais. Os comportamentos mais importantes do PolicyDesk (concorrência, upsert, unicidade) simplesmente não existiriam nos testes. A própria documentação do EF Core desaconselha o InMemory para testes.
Trocar peças nos testes. O teste de concorrência troca um caso de uso por uma versão que simula o conflito:
var client = Factory
.WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
services.AddScoped<IApproveClaimUseCase, ConflictingApproveClaimUseCase>()))
.CreateClient();Isso só é possível porque a aplicação depende de interfaces entregues pelo contêiner. A injeção de dependência rende dividendos também nos testes.
Custos e alternativas. Os testes precisam de Docker e levam segundos, não milissegundos. Por isso ficam no topo da pirâmide, em menor número (veja Estratégia de Testes).
Para praticar. Rode os testes de integração com o Docker parado e leia o erro. Depois rode só os testes de domínio e confira que eles não precisam do Docker.
Em uma frase. Organizar cada teste em três blocos: preparar, executar e conferir.
Analogia. Uma receita: ingredientes (preparar), modo de fazer (executar), e "deve ficar dourado por fora" (conferir).
O problema. Um teste em que preparação, execução e verificação se misturam é difícil de ler: não fica claro o que está sendo testado.
A solução. Três blocos, separados por uma linha em branco, sempre na mesma ordem.
No PolicyDesk.
[Fact(DisplayName = nameof(Deve_AprovarEConfirmar_Quando_SinistroEstiverEmAnalise))]
[Trait("Application", "Claims - Approve")]
public async Task Deve_AprovarEConfirmar_Quando_SinistroEstiverEmAnalise()
{
// Arrange
var claim = ClaimBuilder.Build();
claim.StartAnalysis();
var (useCase, unitOfWork) = CreateUseCase(claim);
// Act
await useCase.ExecuteAsync(claim.Id);
// Assert
claim.Status.ShouldBe(ClaimStatus.Approved);
claim.ApprovedAt.ShouldNotBeNull();
unitOfWork.Verify(work => work.CommitAsync(), Times.Once);
}(Os comentários estão aqui para ilustrar; no código real, a separação é feita só com linhas em branco.)
A convenção de nomes, Deve_<Resultado>_Quando_<Condição>, transforma o nome do teste em uma frase de especificação. Uma lista de testes que falharam vira uma lista de regras quebradas:
❌ Deve_LancarValidacaoSemConfirmar_Quando_SinistroNaoEstiverEmAnalise
Custos e alternativas. Outra convenção popular é Given-When-Then, vinda do BDD, que diz a mesma coisa com outras palavras. O importante é a equipe escolher uma e seguir.
Para praticar. Escolha um teste do projeto e identifique as três partes. O Act tem mais de uma linha? Se tiver, o teste está testando mais de uma coisa?
SOLID é um acrônimo, popularizado por Robert C. Martin, para cinco princípios de design orientado a objetos. Eles aparecem espalhados pelos padrões acima; esta tabela os reúne:
| Princípio | Significado | Exemplo no projeto |
|---|---|---|
| S: Single Responsibility | Uma classe, um motivo para mudar | Um caso de uso por operação; o ExceptionFilter só traduz exceções; o ObservabilitySetup só configura telemetria |
| O: Open/Closed | Aberto para extensão, fechado para modificação | Uma operação nova é uma classe nova de caso de uso, sem alterar as existentes. Um tipo novo de erro é uma subclasse nova de PolicyDeskException
|
| L: Liskov Substitution | Um subtipo substitui o tipo base sem surpresas | Qualquer PolicyDeskException pode ser tratada como Exception, com a mensagem correta (testado em ExceptionMessageTests). Os dublês de repositório substituem as implementações reais nos testes |
| I: Interface Segregation | Ninguém depende do que não usa | Os repositórios ReadOnly, WriteOnly e UpdateOnly
|
| D: Dependency Inversion | Dependa de abstrações | O domínio define IUnitOfWork; a infraestrutura implementa. Os casos de uso recebem interfaces |
| Padrão | Onde ver |
|---|---|
| Clean Architecture | A estrutura de src/ e os ProjectReference dos .csproj
|
| Injeção de Dependência |
Application/DependencyInjectionExtension.cs, Infrastructure/DependencyInjectionExtension.cs
|
| Composition Root | Api/Program.cs |
| Domínio rico / Always-Valid | Domain/Entities/*.cs |
| Layer Supertype | Domain/SeedWorks/Entity.cs |
| Máquina de estados | Domain/Entities/Claim.cs |
| Validadores de valor | Domain/Validations/*.cs |
| Use Case / Command | Application/**/*UseCase.cs |
| Notification |
Application/Policyholders/PolicyholderDataValidator.cs, ErrorOnValidationException
|
| DTO | Shared/PolicyDesk.Communication/ |
| Repository / ISP |
Domain/Repositories/, Infrastructure/DataAccess/Repositories/
|
| Unit of Work | Infrastructure/DataAccess/UnitOfWork.cs |
| Optimistic Lock |
Infrastructure/DataAccess/Mappings/*Map.cs (IsRowVersion) |
| Soft Delete |
PolicyholderMap.cs (HasQueryFilter) |
| Upsert | PolicyholdersRepository.RegisterOrReactivateAsync |
| Exception Translation | UnitOfWork.cs |
| Pipeline de Middlewares |
Program.cs, Observability/CorrelationId.cs
|
| Exception Filter | Api/Filters/ExceptionFilter.cs |
| Problem Details | Api/Errors/ProblemDetailsSetup.cs |
| Options Pattern |
Infrastructure/Configuration/DatabaseOptions.cs, BusinessOptions.cs
|
| Relógio injetável |
Domain/SeedWorks/BusinessClock.cs, Infrastructure/DependencyInjectionExtension.cs
|
| Source-Generated Logging |
Application/Logging/ApplicationLogs.cs, Api/Logging/ApiLogs.cs
|
| Normalização na borda | Api/Converters/StringConverter.cs |
| Test Data Builder / Object Mother | tests/src/CommonTestUtilities/ |
| Shared Fixture |
PolicyDeskApplicationFactory.cs, PostgreSqlInfrastructureFixture.cs
|
| Obra | Autor | Padrões relacionados |
|---|---|---|
| Design Patterns: Elements of Reusable Object-Oriented Software | Gamma, Helm, Johnson, Vlissides (GoF) | State, Command, Chain of Responsibility, Decorator |
| Patterns of Enterprise Application Architecture | Martin Fowler | Repository, Unit of Work, Optimistic Offline Lock, DTO, Layer Supertype, Transaction Script |
| Domain-Driven Design | Eric Evans | Entity, Value Object, Aggregate, Repository |
| Implementing Domain-Driven Design | Vaughn Vernon | Referência entre agregados por identidade |
| Clean Architecture | Robert C. Martin | Regra de dependência, SOLID, Screaming Architecture |
| Growing Object-Oriented Software, Guided by Tests | Steve Freeman e Nat Pryce | Test Data Builder |
| xUnit Test Patterns | Gerard Meszaros | Shared Fixture, Object Mother, Test Double |
| .NET Microservices: Architecture for Containerized .NET Applications | Microsoft | SeedWork, DDD em .NET |
| RFC 9457: Problem Details for HTTP APIs | IETF | Problem Details |
| W3C Trace Context | W3C | Correlation ID |
| Anemic Domain Model | Martin Fowler | Domínio rico |
| Notification | Martin Fowler | Notification Pattern |
PolicyDesk · Distribuído sob a licença MIT · Repositório
Comece aqui
Como o sistema é feito
A API
Engenharia
Prática
Consulta