Repository navigation
Tutorial Nova Funcionalidade
Para quem é este artigo: para quem já leu os outros artigos e quer praticar, e para quem aprende melhor fazendo. É o artigo que amarra todos os outros.
Pré-requisitos: o projeto rodando (Primeiros Passos) e uma leitura de Arquitetura e Modelo de Domínio.
O que você vai aprender: a atravessar todas as camadas do sistema, na ordem certa, começando pelos testes (TDD), e a perceber como uma regra nova pode esbarrar em uma regra antiga.
⚠️ Esta funcionalidade não existe no código. É um exercício. Faça em uma branch própria:git switch -c feature/reabrir-sinistro.
Reabrir sinistro negado. Um sinistro negado pode ser reaberto para uma nova análise, desde que seja informada uma justificativa. Ao reabrir, o sinistro volta para "em análise" e o motivo da negação anterior é apagado.
O endpoint desejado:
POST /api/sinistros/{id}/reabertura
{ "justificativa": "Documentação complementar enviada pelo segurado." }
| Resposta | Quando |
|---|---|
204 |
Reaberto |
400 |
Justificativa vazia; o sinistro não está negado; ou não há saldo na cobertura |
404 |
Sinistro inexistente |
409 |
Conflito de concorrência |
Pare um minuto e pense nas regras que já existem. Uma delas é a C4.1: a soma dos sinistros não negados de uma apólice não pode passar da cobertura. Um sinistro negado não conta no saldo. Se ele for reaberto, volta a contar.
Imagine: cobertura de R$ 10.000. O sinistro A, de R$ 8.000, foi negado. Depois disso, o sinistro B, de R$ 8.000, foi aceito, porque o saldo estava livre. Se A for reaberto agora, a apólice passa a ter R$ 16.000 comprometidos em uma cobertura de R$ 10.000.
Por isso, a resposta 400 da tabela inclui "não há saldo na cobertura". Esse tipo de interação entre uma regra nova e uma antiga é o que mais causa defeitos em sistemas reais, e é o motivo de começar pelo negócio, não pelo código.
flowchart LR
A[1. Teste de domínio] --> B[2. Entidade]
B --> C[3. Mensagens]
C --> D[4. Objeto de entrada]
D --> E[5. Teste do caso de uso]
E --> F[6. Caso de uso]
F --> G[7. Injeção de dependência]
G --> H[8. Log]
H --> I[9. Endpoint]
I --> J[10. Teste de integração]
A ordem vai de dentro para fora, seguindo a regra de dependência da Arquitetura: primeiro o domínio, que não depende de nada; por último a API, que depende de tudo.
Vamos escrever os testes antes do código, no ciclo do TDD (Test-Driven Development):
- 🔴 Red: escreva um teste para algo que ainda não existe. Ele falha (ou nem compila).
- 🟢 Green: escreva o código mais simples que faça o teste passar.
- 🔵 Refactor: melhore o código, com os testes garantindo que nada quebrou.
Escrever o teste primeiro obriga a pensar em como o código vai ser usado antes de pensar em como implementá-lo.
tests/src/PolicyDesk.Domain.Tests/Entities/ClaimsTests.cs
[Fact(DisplayName = nameof(Deve_VoltarParaAnalise_Quando_ReabrirSinistroNegado))]
[Trait("Domain", "Claims - Reopen")]
public void Deve_VoltarParaAnalise_Quando_ReabrirSinistroNegado()
{
var claim = ClaimBuilder.BuildWithStatus(ClaimStatus.Denied);
claim.Reopen();
claim.Status.ShouldBe(ClaimStatus.InAnalysis);
claim.DenialReason.ShouldBeNull();
claim.UpdatedAt.ShouldNotBeNull();
}
[Theory(DisplayName = nameof(Deve_LancarRegraDeDominio_Quando_ReabrirSinistroNaoNegado))]
[Trait("Domain", "Claims - Reopen")]
[InlineData(ClaimStatus.Pending)]
[InlineData(ClaimStatus.InAnalysis)]
[InlineData(ClaimStatus.Approved)]
[InlineData(ClaimStatus.Paid)]
public void Deve_LancarRegraDeDominio_Quando_ReabrirSinistroNaoNegado(ClaimStatus status)
{
var claim = ClaimBuilder.BuildWithStatus(status);
Should.Throw<DomainRuleException>(() => claim.Reopen());
}O primeiro teste descreve o caminho feliz. O segundo, com [Theory], garante que todos os outros status são recusados. É comum testar só o caminho feliz e esquecer os proibidos, mas são eles que protegem a máquina de estados.
O código ainda não compila, porque Reopen() não existe. Isso é o 🔴.
💡 O
ClaimBuilder.BuildWithStatus(ClaimStatus.Denied)leva o sinistro até "negado" passando pelas transições reais (StartAnalysis()e depoisDeny(...)). Você não precisa, e nem conseguiria, forçar o status, porque ele temprivate set.
src/Backend/PolicyDesk.Domain/Entities/Claim.cs
public void Reopen()
{
EnsureStatus(ClaimStatus.Denied, ResourcesMessageException.CLAIM_MUST_BE_DENIED_FOR_REOPENING);
Status = ClaimStatus.InAnalysis;
DenialReason = null;
UpdatedAt = DateTime.UtcNow;
}O formato é o mesmo de Approve() e Deny(): primeiro a guarda (EnsureStatus), depois a mudança. Seguir o padrão existente deixa o código previsível para quem vier depois.
Uma decisão de design: e a justificativa? O requisito não pede para guardá-la, então ela não entra na entidade, e é validada só no caso de uso. Se o requisito mudasse para "guardar a justificativa", ela viraria um parâmetro, Reopen(string justification), e seria validada aqui também, como acontece com o motivo em Deny(reason).
E o saldo da cobertura? Ele envolve outra entidade, a apólice. Como vimos no Modelo de Domínio, regras entre agregados são coordenadas pelo caso de uso, e a apólice já tem o método certo para isso: Policy.RegisterClaim. Vamos usá-lo no passo 6.
Atualize o diagrama de estados em Entendendo o negócio: Negado --> EmAnalise: reabrir.
src/Shared/PolicyDesk.Exception/ResourcesMessageException.resx
<data name="CLAIM_MUST_BE_DENIED_FOR_REOPENING" xml:space="preserve">
<value>Somente sinistros negados podem ser reabertos.</value>
</data>
<data name="CLAIM_REOPEN_JUSTIFICATION_REQUIRED" xml:space="preserve">
<value>A justificativa da reabertura é obrigatória.</value>
</data>E as mesmas chaves, em inglês, em ResourcesMessageException.en.resx:
<data name="CLAIM_MUST_BE_DENIED_FOR_REOPENING" xml:space="preserve">
<value>Only denied claims can be reopened.</value>
</data>
<data name="CLAIM_REOPEN_JUSTIFICATION_REQUIRED" xml:space="preserve">
<value>The reopening justification is required.</value>
</data>Se a chave faltar no arquivo em inglês, nada quebra: quem pedir Accept-Language: en recebe o texto em português. Por isso, confira os dois arquivos. (Veja Tratamento de Erros › Localização.)
A IDE regenera o ResourcesMessageException.Designer.cs, e os testes do passo 1 passam a compilar e passar. Esse é o 🟢:
dotnet test tests/src/PolicyDesk.Domain.Tests --filter "Domain=Claims - Reopen"src/Shared/PolicyDesk.Communication/Requests/RequestReopenClaimJson.cs
namespace PolicyDesk.Communication.Requests;
public class RequestReopenClaimJson
{
public string Justificativa { get; set; } = string.Empty;
}Por que uma classe nova, e não reaproveitar a RequestDenyClaimJson? Porque são contratos diferentes, que podem evoluir de forma diferente. Um objeto por operação mantém cada contrato honesto sobre o que pede. A propriedade é em português, como todo o contrato da API.
tests/src/PolicyDesk.Application.Tests/Claims/Reopen/ReopenClaimUseCaseTests.cs
public class ReopenClaimUseCaseTests
{
[Fact(DisplayName = nameof(Deve_ReabrirEConfirmar_Quando_SinistroEstiverNegadoEHouverSaldo))]
[Trait("Application", "Claims - Reopen")]
public async Task Deve_ReabrirEConfirmar_Quando_SinistroEstiverNegadoEHouverSaldo()
{
var policy = PolicyBuilder.Build(coverageAmount: 10_000);
var claim = ClaimBuilder.BuildWithStatus(ClaimStatus.Denied, policy.Id);
var (useCase, unitOfWork) = CreateUseCase(claim, policy, committedAmount: 0);
await useCase.ExecuteAsync(claim.Id, new RequestReopenClaimJson { Justificativa = "Novos documentos" });
claim.Status.ShouldBe(ClaimStatus.InAnalysis);
unitOfWork.Verify(work => work.CommitAsync(), Times.Once);
}
[Fact(DisplayName = nameof(Deve_LancarRegraSemConfirmar_Quando_NaoHouverSaldoNaCobertura))]
[Trait("Application", "Claims - Reopen")]
public async Task Deve_LancarRegraSemConfirmar_Quando_NaoHouverSaldoNaCobertura()
{
var policy = PolicyBuilder.Build(coverageAmount: 10_000);
var claim = ClaimBuilder.BuildWithStatus(ClaimStatus.Denied, policy.Id);
var (useCase, unitOfWork) = CreateUseCase(claim, policy, committedAmount: 10_000);
await Should.ThrowAsync<DomainRuleException>(() =>
useCase.ExecuteAsync(claim.Id, new RequestReopenClaimJson { Justificativa = "Novos documentos" }));
claim.Status.ShouldBe(ClaimStatus.Denied);
unitOfWork.Verify(work => work.CommitAsync(), Times.Never);
}
[Fact(DisplayName = nameof(Deve_LancarValidacaoSemConfirmar_Quando_JustificativaForVazia))]
[Trait("Application", "Claims - Reopen")]
public async Task Deve_LancarValidacaoSemConfirmar_Quando_JustificativaForVazia()
{
var policy = PolicyBuilder.Build();
var claim = ClaimBuilder.BuildWithStatus(ClaimStatus.Denied, policy.Id);
var (useCase, unitOfWork) = CreateUseCase(claim, policy);
await Should.ThrowAsync<ErrorOnValidationException>(() =>
useCase.ExecuteAsync(claim.Id, new RequestReopenClaimJson { Justificativa = " " }));
unitOfWork.Verify(work => work.CommitAsync(), Times.Never);
}
[Fact(DisplayName = nameof(Deve_LancarNaoEncontradoSemConfirmar_Quando_SinistroNaoExistir))]
[Trait("Application", "Claims - Reopen")]
public async Task Deve_LancarNaoEncontradoSemConfirmar_Quando_SinistroNaoExistir()
{
var (useCase, unitOfWork) = CreateUseCase();
await Should.ThrowAsync<NotFoundException>(() =>
useCase.ExecuteAsync(Guid.NewGuid(), new RequestReopenClaimJson { Justificativa = "x" }));
unitOfWork.Verify(work => work.CommitAsync(), Times.Never);
}
private static (ReopenClaimUseCase, Mock<IUnitOfWork>) CreateUseCase(
Claim? claim = null, Policy? policy = null, decimal committedAmount = 0)
{
var claimsUpdate = new ClaimsUpdateOnlyRepositoryBuilder();
if (claim is not null)
claimsUpdate.GetByIdForUpdate(claim);
var policiesUpdate = new PoliciesUpdateOnlyRepositoryBuilder();
if (policy is not null)
policiesUpdate.GetByIdForUpdate(policy);
var claimsRead = new ClaimsReadOnlyRepositoryBuilder().SumCommittedAmount(committedAmount);
var unitOfWork = UnitOfWorkBuilder.Build();
var useCase = new ReopenClaimUseCase(
claimsUpdate.Build(),
claimsRead.Build(),
policiesUpdate.Build(),
unitOfWork.Object,
NullLogger<ReopenClaimUseCase>.Instance);
return (useCase, unitOfWork);
}
}O que observar:
- O teste mais importante é o segundo: sem saldo, nada muda. O sinistro continua negado e nada é gravado.
- Os builders de repositório (
ClaimsUpdateOnlyRepositoryBuilder,PoliciesUpdateOnlyRepositoryBuilder,ClaimsReadOnlyRepositoryBuilder) já existem noCommonTestUtilities. Eles configuram os dublês do Moq em uma linha legível. - Todo caminho de erro confere
Times.NevernoCommitAsync.
A interface, em src/Backend/PolicyDesk.Application/Claims/Reopen/IReopenClaimUseCase.cs:
namespace PolicyDesk.Application.Claims.Reopen;
public interface IReopenClaimUseCase
{
Task ExecuteAsync(Guid claimId, RequestReopenClaimJson request, CancellationToken cancellationToken = default);
}E a implementação, em ReopenClaimUseCase.cs, na mesma pasta:
namespace PolicyDesk.Application.Claims.Reopen;
internal class ReopenClaimUseCase(
IClaimsUpdateOnlyRepository claimsUpdateOnlyRepository,
IClaimsReadOnlyRepository claimsReadOnlyRepository,
IPoliciesUpdateOnlyRepository policiesUpdateOnlyRepository,
IUnitOfWork unitOfWork,
ILogger<ReopenClaimUseCase> logger) : IReopenClaimUseCase
{
public async Task ExecuteAsync(Guid claimId, RequestReopenClaimJson request, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(request.Justificativa))
throw new ErrorOnValidationException(ResourcesMessageException.CLAIM_REOPEN_JUSTIFICATION_REQUIRED);
var claim = await claimsUpdateOnlyRepository.GetByIdForUpdateAsync(claimId, cancellationToken)
?? throw new NotFoundException(ResourcesMessageException.CLAIM_NOT_FOUND);
var policy = await policiesUpdateOnlyRepository.GetByIdForUpdateAsync(claim.PolicyId, cancellationToken)
?? throw new NotFoundException(ResourcesMessageException.POLICY_NOT_FOUND);
var committedAmount = await claimsReadOnlyRepository.SumCommittedAmountAsync(policy.Id, cancellationToken);
policy.RegisterClaim(claim.Amount, committedAmount);
claim.Reopen();
await unitOfWork.CommitAsync(cancellationToken);
logger.ClaimReopened(claim.Id);
}
}O roteiro é o mesmo de todos os casos de uso: validar → carregar → delegar ao domínio → confirmar → registrar o log. Consistência é uma forma de documentação: quem leu um caso de uso entende todos.
Repare na ordem dentro do "delegar": policy.RegisterClaim(...) vem antes de claim.Reopen(). Se não houver saldo, a exceção acontece antes de o sinistro mudar. Como nada é gravado sem o CommitAsync, mesmo a outra ordem seria segura, mas validar antes de mudar é um bom hábito (veja "atomicidade em memória" no Modelo de Domínio).
-
Por que
IClaimsUpdateOnlyRepository, e não o de leitura? A entidade precisa estar rastreada para que oCommitAsyncgere oUPDATE. Uma busca comAsNoTrackingnão gravaria nada. -
Por que a apólice também é buscada "para alteração"? Porque
RegisterClaimtoca oUpdatedAtdela. Isso muda a versão (xmin) da apólice, e protege contra duas operações simultâneas que, juntas, estourariam a cobertura. Uma delas recebe409. (Veja Persistência › Concorrência.) -
O
SumCommittedAmountAsyncconta o próprio sinistro reaberto? Não: ele ainda está negado no banco quando a soma é feita. Por isso o valor dele é somado à parte. -
Por que a classe é
internal? Ninguém fora do projeto deve criá-la diretamente; o acesso é pela interface.
src/Backend/PolicyDesk.Application/DependencyInjectionExtension.cs
// Claims
services.AddScoped<IReopenClaimUseCase, ReopenClaimUseCase>();Esquecer este passo não quebra o build. O erro só aparece na primeira requisição ao endpoint: Unable to resolve service for type IReopenClaimUseCase. É por isso que o teste de integração do passo 10 existe: ele pega esse tipo de esquecimento.
src/Backend/PolicyDesk.Application/Logging/ApplicationLogs.cs
[LoggerMessage(EventId = 1006, Level = LogLevel.Information, Message = "Sinistro {ClaimId} reaberto")]
public static partial void ClaimReopened(this ILogger logger, Guid claimId);O número segue a faixa 1xxx dos sinistros, no próximo livre. E a justificativa não vai para o log: é um texto livre, e o usuário pode ter escrito qualquer coisa nele, inclusive dados pessoais. (Veja Observabilidade › Logs de negócio.)
src/Backend/PolicyDesk.Api/Controllers/ClaimsController.cs
[HttpPost("sinistros/{id:guid}/reabertura")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest, ProblemJson.ContentType)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound, ProblemJson.ContentType)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict, ProblemJson.ContentType)]
public async Task<IActionResult> Reopen(
[FromRoute] Guid id,
[FromBody] RequestReopenClaimJson request,
[FromServices] IReopenClaimUseCase useCase,
CancellationToken cancellationToken)
{
await useCase.ExecuteAsync(id, request, cancellationToken);
return NoContent();
}- O controller não tem
try/catch, não valida nada e não conhece regras. As exceções viram respostas HTTP noExceptionFilter. - Os atributos
ProducesResponseTypedocumentam as respostas possíveis no OpenAPI, e aparecem no Swagger. - O
CancellationTokenvem do ASP.NET e é cancelado se o cliente desistir da requisição.
Por que POST /reabertura, e não PATCH com { "status": "EmAnalise" }? A API modela ações de negócio como sub-recursos (/analise, /aprovacao, /pagamento). Um PATCH no status deixaria o cliente pedir qualquer transição, e misturaria regras diferentes em um só endpoint.
tests/src/PolicyDesk.Api.IntegrationTests/Claims/Workflow/ClaimWorkflowTests.cs
[Fact(DisplayName = nameof(Deve_PermitirNovaAprovacao_Quando_SinistroNegadoForReaberto))]
[Trait("Integration", "Claims - Workflow")]
public async Task Deve_PermitirNovaAprovacao_Quando_SinistroNegadoForReaberto()
{
var claim = await RegisterClaimForNewPolicy();
await Post($"/api/sinistros/{claim.Id}/analise", new { });
await Post($"/api/sinistros/{claim.Id}/negacao", new RequestDenyClaimJson { Motivo = "Faltam documentos" });
var reopen = await Post($"/api/sinistros/{claim.Id}/reabertura",
new RequestReopenClaimJson { Justificativa = "Documentos enviados" });
var approve = await Post($"/api/sinistros/{claim.Id}/aprovacao", new { });
reopen.StatusCode.ShouldBe(HttpStatusCode.NoContent);
approve.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var saved = await FindClaim(claim.Id);
saved.Status.ShouldBe(ClaimStatus.Approved);
saved.DenialReason.ShouldBeNull();
}Este teste exercita tudo junto: roteamento, leitura do JSON, injeção de dependência, caso de uso, entidade, EF Core e PostgreSQL. Se você esqueceu o passo 7, é aqui que descobre.
Como exercício, escreva também o teste de ponta a ponta do saldo: cobertura de 10.000, sinistro A de 8.000 negado, sinistro B de 8.000 aceito, e a reabertura de A recebendo 400.
- Testes de domínio, de aplicação e de integração passando
-
dotnet buildsem avisos - A regra nova na tabela de Entendendo o negócio
- O diagrama de estados atualizado
- O endpoint documentado na Referência da API
- As mensagens nos dois
.resxe na tabela de Tratamento de Erros - O evento de log na tabela de Observabilidade
- O CI verde no pull request (build, testes e CodeQL)
Em ordem crescente de dificuldade:
-
Histórico de reaberturas. Guarde a justificativa e a data de cada reabertura. Isso exige uma nova entidade (ou um owned type do EF Core), uma migration, e uma decisão: o histórico faz parte do agregado
Claim? - Limite de reaberturas. Um sinistro só pode ser reaberto duas vezes. Onde mora essa regra?
-
ETag e
If-Match. Exponha a versão (xmin) como um cabeçalhoETagnoGETe exijaIf-Matchnas transições, respondendo412 Precondition Failedquando a versão for diferente. Isso estende a concorrência otimista até o cliente: o caso de "abri a tela há 10 minutos". -
Paginação por cursor. Troque a paginação por offset de
GET /api/apolicespor uma paginação keyset (?depois=<id>). O que muda na ordenação e no contrato de resposta? -
Value Object
Cpf. Substituastring Cpfpor um tipo próprio, que só pode existir se for válido, e ajuste o mapeamento comHasConversion. - Pagamento parcial. Permita pagar um sinistro aprovado em parcelas, até o valor total. Que novos estados e entidades isso exige?
- Uma funcionalidade atravessa as camadas de dentro para fora: domínio, aplicação, API.
- O TDD começa pelo teste, o que obriga a pensar no uso antes da implementação.
- Toda regra nova deve ser confrontada com as regras existentes; aqui, a reabertura esbarra no saldo da cobertura.
- Regras entre agregados são coordenadas pelo caso de uso, mas decididas pelas entidades.
- O teste de integração pega o que os outros não pegam, como um registro esquecido na injeção de dependência.
- Test-Driven Development: By Example, Kent Beck.
- Releia Design Patterns › Use Case com o seu
ReopenClaimUseCaseaberto ao lado.
PolicyDesk · Distribuído sob a licença MIT · Repositório
Comece aqui
Como o sistema é feito
A API
Engenharia
Prática
Consulta