Skip to content

Tutorial Nova Funcionalidade

rodrigosdk edited this page Nov 11, 2026 · 2 revisions

Tutorial: construindo uma funcionalidade do zero

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.


O requisito

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

Antes de programar: o que esse requisito muda no negócio?

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.


O caminho

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]
Loading

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.

TDD em uma frase

Vamos escrever os testes antes do código, no ciclo do TDD (Test-Driven Development):

  1. 🔴 Red: escreva um teste para algo que ainda não existe. Ele falha (ou nem compila).
  2. 🟢 Green: escreva o código mais simples que faça o teste passar.
  3. 🔵 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.


Passo 1: o teste de domínio (🔴)

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 depois Deny(...)). Você não precisa, e nem conseguiria, forçar o status, porque ele tem private set.


Passo 2: o comportamento na entidade (🟢)

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.


Passo 3: as mensagens

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"

Passo 4: o objeto de entrada

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.


Passo 5: o teste do caso de uso (🔴)

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 no CommonTestUtilities. Eles configuram os dublês do Moq em uma linha legível.
  • Todo caminho de erro confere Times.Never no CommitAsync.

Passo 6: o caso de uso (🟢)

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).

Perguntas para refletir

  • Por que IClaimsUpdateOnlyRepository, e não o de leitura? A entidade precisa estar rastreada para que o CommitAsync gere o UPDATE. Uma busca com AsNoTracking não gravaria nada.
  • Por que a apólice também é buscada "para alteração"? Porque RegisterClaim toca o UpdatedAt dela. Isso muda a versão (xmin) da apólice, e protege contra duas operações simultâneas que, juntas, estourariam a cobertura. Uma delas recebe 409. (Veja Persistência › Concorrência.)
  • O SumCommittedAmountAsync conta 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.

Passo 7: o registro na injeção de dependência

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.


Passo 8: o evento de log

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.)


Passo 9: o endpoint

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 no ExceptionFilter.
  • Os atributos ProducesResponseType documentam as respostas possíveis no OpenAPI, e aparecem no Swagger.
  • O CancellationToken vem 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.


Passo 10: o teste de integração

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.


Checklist final

  • Testes de domínio, de aplicação e de integração passando
  • dotnet build sem 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 .resx e na tabela de Tratamento de Erros
  • O evento de log na tabela de Observabilidade
  • O CI verde no pull request (build, testes e CodeQL)

Desafios extras

Em ordem crescente de dificuldade:

  1. 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?
  2. Limite de reaberturas. Um sinistro só pode ser reaberto duas vezes. Onde mora essa regra?
  3. ETag e If-Match. Exponha a versão (xmin) como um cabeçalho ETag no GET e exija If-Match nas transições, respondendo 412 Precondition Failed quando a versão for diferente. Isso estende a concorrência otimista até o cliente: o caso de "abri a tela há 10 minutos".
  4. Paginação por cursor. Troque a paginação por offset de GET /api/apolices por uma paginação keyset (?depois=<id>). O que muda na ordenação e no contrato de resposta?
  5. Value Object Cpf. Substitua string Cpf por um tipo próprio, que só pode existir se for válido, e ajuste o mapeamento com HasConversion.
  6. Pagamento parcial. Permita pagar um sinistro aprovado em parcelas, até o valor total. Que novos estados e entidades isso exige?

Resumo

  • 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.

Para aprofundar

Clone this wiki locally