// projeto em produção

MyCEP.API — .NET 8

API REST de consulta de CEP com cache-aside (PostgreSQL → ViaCEP), resiliência com Polly + Refit, erros RFC 7807 e testes de integração com Testcontainers. A demo mostra o contrato público; a faixa de observabilidade mostra o cache-aside acontecendo de verdade no log.

MyCEP.API — .NET 8demo · 0:43narrado

// o que esta demonstração mostra

  • Um endpoint, um contrato público: schema de sucesso e de erro declarados no OpenAPI.
  • Endereço normalizado com código IBGE, SIAFI e DDD; cache público de 24h e headers de segurança por padrão.
  • Cache-aside no log: consulta começa no banco, sai para o ViaCEP no miss e grava antes de responder.
  • Resiliência visível: a chamada externa passa por pipeline de retry com tempo de execução medido.

// case study de engenharia

Problema, arquitetura, decisões e o que foi medido — tudo rastreável às fontes listadas no fim. Sem números inventados.

Problema

Dado um CEP, devolver o endereço completo numa API pública — dependendo de um provedor externo gratuito e sem SLA (ViaCEP), exposta a abuso e com custo por requisição que precisa ficar previsível. Construída como estudo de práticas de produção, não como CRUD de exemplo.

Contexto

A demo roda no Fly.io (scale-to-zero) com PostgreSQL serverless no Neon; a pasta infra/terraform mantém a stack AWS completa (VPC, EC2, RDS privado, IAM/OIDC, SSM) como referência de infraestrutura como código. CEP é dado imutável — o que abre espaço para cache em mais de uma camada.

Arquitetura

Cliente → Caddy (:80, TLS-ready) → ASP.NET Core API (:8080). Fluxo de uma requisição: validação do CEP (8 dígitos) → busca no PostgreSQL → em miss, chamada resiliente ao ViaCEP (Refit + retry/backoff/circuit breaker/timeout) → persistência com índice único parcial → resposta. Erros seguem RFC 7807 (Problem Details) com o status correto (400 / 404 / 429 / 500).

Fluxo cache-aside da MyCEP.APIO cliente chama GET /api/ZipCodeLookup/cep; a API valida o CEP (8 dígitos) e consulta o PostgreSQL. Em cache hit, responde direto. Em miss, chama o ViaCEP via Refit com retry, backoff, circuit breaker e timeout, persiste o resultado protegida por índice único parcial em zipcode (WHERE deleted_at IS NULL) e então responde. Erros seguem RFC 7807 (400, 404, 429, 500) e a resposta carrega Cache-Control public max-age 86400.ClienteGET /ZipCodeLookup/{cep}ASP.NET Core APIvalida CEP · 60 req/min/IPPostgreSQLcache persistenteViaCEPexterno · sem SLA1 · hit?2 · miss3 · persiste4 · responde// contratoProblemDetails (RFC 7807):400 · 404 · 429 · 500Cache-Control: max-age=86400// concorrênciaíndice único parcial em zipcodeWHERE deleted_at IS NULL2 requisições simultâneas → 1 linha// resiliênciaRefit + Pollyretry · backoffcircuit breaker · timeout// provado por testeintegração com PostgreSQL real (Testcontainers): corrida de inserção do mesmo CEP · ViaCEP fora do ar
diagrama SVG inline — sem biblioteca, herda o tema da página
  • Core/API

    Controllers e middleware de erro (ProblemDetails).

  • Core/Features

    Casos de uso e DTOs de resposta.

  • Core/Shared/Domain

    Entidades de domínio e contratos (repositório, serviço).

  • Core/Shared/Infrastructure

    EF Core 9 + Npgsql, gateway ViaCEP (Refit), IoC, migrations.

Decisões e trade-offs

  1. decisão 01

    Cache-aside com o próprio PostgreSQL como cache persistente: primeiro o banco; em miss, busca no ViaCEP, persiste e passa a servir localmente nas próximas chamadas.

    trade-off
    Cada miss numa API pública faz fan-out para terceiro e escreve no banco — é o que justifica o rate limiting e o cache HTTP como camadas complementares.
    evidência
    README ("O que é" e "Decisões técnicas que valem destaque")
  2. decisão 02

    Concorrência resolvida no banco: índice único parcial (WHERE deleted_at IS NULL) em zipcode + tratamento de conflito — duas requisições simultâneas do mesmo CEP geram uma linha, não duplicatas.

    em vez de
    Coordenação na aplicação (verificar-e-inserir sem garantia do banco).
    trade-off
    O conflito de unicidade passa a ser caminho esperado do código, não exceção — e precisa de teste de concorrência real para provar.
    evidência
    README ("Concorrência") · teste de integração de corrida de inserção com Testcontainers
  3. decisão 03

    Chamada ao ViaCEP via Refit com AddStandardResilienceHandler (Microsoft.Extensions.Http.Resilience / Polly): retry + backoff + circuit breaker + timeout — porque o ViaCEP é gratuito e sem SLA.

    trade-off
    Resiliência tem preço em latência no caminho de falha (retries e backoff antes de desistir); o cenário "ViaCEP fora do ar" é coberto por teste de integração.
    evidência
    README ("Resiliência" e "Testes") · clipe mycep-obs (pipeline de retry com tempo medido)
  4. decisão 04

    Contrato de erro único: ProblemDetails (RFC 7807) em toda a superfície, com o schema de sucesso e de erro declarados no OpenAPI; em 5xx o detalhe interno fica só no log, nunca no corpo.

    trade-off
    O cliente recebe menos detalhe em 5xx — por design, para não vazar internals.
    evidência
    README ("Contrato de erro") · clipe mycep-demo
  5. decisão 05

    Rate limiting nativo do ASP.NET Core (60 req/min/IP) numa API pública anônima que escreve por miss e chama terceiro.

    trade-off
    Teto fixo por IP (sem distinção de cliente — a API não tem autenticação); o 429 faz parte do contrato RFC 7807.
    evidência
    README ("Abuso / custo" e "Endpoints")
  6. decisão 06

    Cache-Control: public, max-age=86400 na resposta — CEP é imutável; CDN e browser absorvem a maioria dos round-trips.

    trade-off
    Uma eventual correção de endereço leva até 24 h para chegar a clientes com a resposta em cache.
    evidência
    README ("Cache HTTP") · clipe mycep-demo (cache público de 24h)
  7. decisão 07

    Testes de integração com PostgreSQL real via Testcontainers, não in-memory — incluindo a corrida de inserção e o provedor fora do ar.

    em vez de
    Provider in-memory do EF Core.
    trade-off
    Exigem Docker rodando; por isso a suíte unitária é separada e roda rápido sem Docker (dotnet test test/MyCEP.API.UnitTest).
    evidência
    README ("Testes")
  8. decisão 08

    Infraestrutura como código com deploy via OIDC (sem access keys) e senha do banco gerada e guardada no SSM Parameter Store; o ambiente publicado, porém, roda no Fly.io + Neon.

    trade-off
    A stack AWS é referência validada, não o ambiente que atende a demo — e o README declara isso.
    evidência
    README ("Infra como código" e nota de deploy)

Implementação

  • Aceita 01001000 ou 01001-000; resposta normalizada com ibgeCode, areaCode (DDD) e siafiCode.
  • Headers de segurança por padrão e cache público de 24 h visíveis na resposta (faixa demo).
  • Health checks: /health (liveness + readiness, valida o PostgreSQL) e /health/live (não toca o banco).
  • Contêiner multi-stage Alpine non-root; Caddy como proxy TLS-ready; docker compose up --build sobe API + PostgreSQL e aplica migrations.
  • CI/CD: testes como gate antes do deploy; scans de dependências (dotnet list --vulnerable), imagem (Trivy) e segredos (gitleaks); Dependabot.

Testes

xUnit + Moq + FluentAssertions + Testcontainers, em duas suítes: unitária (casos de uso e regras de domínio) e integração (API ponta a ponta com PostgreSQL real).

  • Integração com PostgreSQL efêmero via Testcontainers — não in-memory.
  • Corrida de inserção do mesmo CEP → uma única linha (índice único parcial + tratamento de conflito).
  • ViaCEP fora do ar → caminho de falha resiliente coberto.
  • Cobertura com quality gate (run-coverage.ps1) no workflow test-coverage.

Observabilidade

O cache-aside é verificável no log, não só no diagrama — é o que a faixa de observabilidade mostra.

  • A consulta começa no PostgreSQL, sai para o ViaCEP no miss — via Refit, com pipeline de retry do Polly e tempo de execução medido — e grava o resultado antes de responder.
  • Em 5xx, o detalhe interno vai para o log; o cliente recebe ProblemDetails.
  • Health checks separados para liveness e readiness (dependência do banco explícita).

Resultados e aprendizados

  • Duas requisições simultâneas do mesmo CEP geram uma linha — provado por teste de concorrência contra PostgreSQL real.
  • Provedor externo sem SLA tratado como risco de primeira classe: retry, backoff, circuit breaker, timeout e teste do caminho de falha.
  • Contrato público único de sucesso e erro (OpenAPI + RFC 7807), incluindo 429 do rate limiting.
  • Aprendizado: cache em camadas (banco como cache persistente + Cache-Control de 24 h) e proteção contra abuso são decisões de custo, não só de performance.

Fontes

Tudo o que está escrito acima é rastreável a estes arquivos e repositórios.

eduardo.valente © 2026 // engenharia em produção