API hexagonal em PHP 8.3+ para consulta e simulação de pagamento de débitos veiculares. Construída sobre Slim 4 + PHP-DI, segue DDD-lite, Ports & Adapters, SOLID e Clean Code. PHPStan max, Pint (PSR-12) e PHPUnit cobrindo todos os cenários de borda.
- Arquitetura
- Estrutura de pastas
- Como executar
- Como rodar testes
- Como validar os cenários de borda
- Decisões arquiteturais
- Trade-offs
- Como adicionar um novo provedor
- Como adicionar um novo tipo de débito
Quatro camadas, com dependências apontando sempre para dentro:
Presentation → Application → Domain ← Infrastructure
- Domain: regras puras de negócio (juros, pagamentos, validações). Define ports (interfaces).
- Application: orquestra casos de uso, manipula DTOs imutáveis.
- Infrastructure: implementa adapters (HTTP, parsers, clock, log, retry, circuit breaker).
- Presentation: HTTP/JSON via Slim 4 + middlewares PSR-15.
| Padrão | Onde |
|---|---|
| Adapter | ProviderAClient/ProviderBClient + JsonResponseParser/XmlResponseParser |
| Strategy | InterestCalculator (IPVA, Multa) + InterestCalculatorRegistry |
| Decorator | ResilientProvider envolvendo qualquer VehicleDebtProvider com retry + CB |
| Chain of Responsibility | ProviderOrchestrator (fallback) |
| Value Object | Plate, Money, DueDate, DebtType, PaymentScope, ... |
| DTO imutável | ConsultDebtsInput/Output, ProviderResponse, RawDebtData |
| Factory | PaymentOptionsFactory |
| Clock Provider | Clock interface + FixedClock/SystemClock |
| Registry | InterestCalculatorRegistry |
| PSR-15 Middleware | CorrelationId, PayloadSize, JsonBodyParser, ErrorHandler |
src/
├── Domain/
│ ├── Clock/ Clock interface
│ ├── Shared/ValueObject/Money.php BCMath, HALF_UP, escala 10 interna
│ ├── Vehicle/ Plate VO + InvalidPlate exception
│ ├── Debt/
│ │ ├── Entity/Debt.php
│ │ ├── ValueObject/{DebtType, DueDate}
│ │ ├── Strategy/{InterestCalculator, IpvaInterestCalculator, FineInterestCalculator, InterestCalculatorRegistry}
│ │ ├── Service/{DebtUpdater, UpdatedDebt}
│ │ └── Exception/{UnknownDebtType, InvalidDueDate, NoInterestCalculatorForType}
│ ├── Payment/
│ │ ├── Method/{PaymentMethod, PixPaymentMethod, CreditCardPaymentMethod}
│ │ ├── Service/PaymentOptionsFactory.php
│ │ └── ValueObject/{PaymentScope, PaymentOption, PaymentMethodQuote, Installment}
│ └── Provider/
│ ├── Contract/VehicleDebtProvider.php
│ ├── ValueObject/{ProviderResponse, RawDebtData}
│ └── Exception/{ProviderUnavailable, AllProvidersUnavailable, CircuitOpen}
├── Application/
│ ├── DTO/ ConsultDebtsInput/Output, DebtOutputData, ...
│ ├── Service/{ProviderOrchestrator, CanonicalDebtMapper}
│ └── UseCase/ConsultDebts/ConsultDebtsUseCase.php
├── Infrastructure/
│ ├── Clock/{SystemClock, FixedClock}
│ ├── Http/{HttpClient, GuzzleHttpClient}
│ ├── Logging/PlateMasker.php
│ ├── Config/AppConfig.php
│ ├── Provider/
│ │ ├── ProviderA/{ProviderAClient, JsonResponseParser}
│ │ └── ProviderB/{ProviderBClient, XmlResponseParser}
│ └── Resilience/{RetryPolicy, CircuitBreaker, ResilientProvider, Sleeper, UsleepSleeper}
├── Presentation/
│ └── Http/{Controller, Middleware, Request, Response, Exception}
└── Bootstrap/{ContainerFactory, AppFactory}
tests/
├── Unit/ (53 testes)
├── Integration/ (15 testes - fallback, HTTP E2E, use case completo)
└── Support/ FakeHttpClient, NullSleeper
composer install
cp .env.example .env
make serve # ou: php -S 0.0.0.0:8881 -t public public/index.phpA API sobe em http://localhost:8881.
make docker-build
make docker-up
# logs: docker compose logs -f api
# parar: make docker-downCLOCK_FIXED_AT=2024-05-10T00:00:00Z php scripts/smoke-test.phpSaída esperada (recortada):
{
"placa": "ABC1234",
"debitos": [
{"tipo":"IPVA","valor_original":"1500.00","valor_atualizado":"1800.00","vencimento":"2024-01-10","dias_atraso":121},
{"tipo":"MULTA","valor_original":"300.50","valor_atualizado":"555.93","vencimento":"2024-02-15","dias_atraso":85}
],
"resumo": {"total_original":"1800.50","total_atualizado":"2355.93"},
"pagamentos": {"opcoes":[ ... TOTAL, SOMENTE_IPVA, SOMENTE_MULTA ... ]}
}make test # PHPUnit
make stan # PHPStan max
make lint # Pint --test
make ci # lint + stan + testResultado esperado:
- 68 testes / 143 assertions OK
- PHPStan max — sem erros
- Pint passed
Todos cobertos por testes automatizados em tests/:
| Cenário | Local |
|---|---|
| Cálculo de juros IPVA (com teto 20%) | tests/Unit/Domain/Debt/InterestCalculatorsTest.php::test_ipva_interest_capped |
| Cálculo de juros IPVA abaixo do teto | ::test_ipva_interest_below_cap |
| Cálculo de juros MULTA sem teto | ::test_fine_interest_uncapped |
| Débito não vencido (dias_atraso ≤ 0) | ::test_ipva_zero_when_not_overdue, ::test_fine_zero_when_not_overdue |
| Tipo de débito desconhecido → HTTP 422 | tests/Integration/HttpEndpointTest.php::test_unknown_debt_type_returns_422 |
| Parser JSON (Provider A) | tests/Unit/Infrastructure/Provider/JsonResponseParserTest.php |
| Parser XML (Provider B) | tests/Unit/Infrastructure/Provider/XmlResponseParserTest.php |
XML autofechado <debts/> |
XmlResponseParserTest::test_supports_self_closed_empty_debts_element |
| Validação de placa (Mercosul + antigo) | tests/Unit/Domain/Vehicle/PlateTest.php |
| Arredondamento HALF_UP | tests/Unit/Domain/Shared/MoneyTest.php::test_rounding_half_up |
| PMT (1x/6x/12x) | tests/Unit/Domain/Payment/CreditCardPaymentMethodTest.php |
| PIX (5% desconto) | tests/Unit/Domain/Payment/PixPaymentMethodTest.php |
| Geração TOTAL / SOMENTE_TIPO | tests/Unit/Domain/Payment/PaymentOptionsFactoryTest.php |
| Fallback automático entre providers | tests/Integration/ProviderFallbackTest.php::test_falls_back_to_second_provider_when_first_fails |
| Todos providers falham → HTTP 503 | ::test_throws_when_all_providers_fail + HttpEndpointTest::test_all_providers_unavailable_returns_503 |
| Placa inválida → HTTP 400 | HttpEndpointTest::test_invalid_plate_returns_400 |
| Campos desconhecidos → HTTP 400 | HttpEndpointTest::test_unknown_fields_return_400 |
| Healthcheck | HttpEndpointTest::test_healthcheck_endpoint |
| Mascaramento de placa nos logs (LGPD) | tests/Unit/Infrastructure/Logging/PlateMaskerTest.php |
Documentada explicitamente no ProviderOrchestrator. Justificativa:
- Previsibilidade: zero ambiguidade sobre qual valor é "verdadeiro".
- Latência baixa: não esperamos todos os providers.
- Compatível com Circuit Breaker: provider quebrado é pulado.
- Alternativas descartadas:
- Quorum/voting: requer regra de tie-break não definida no escopo.
- Merge por prioridade: aumenta acoplamento entre adapters e mistura "verdades" externas.
- Escala interna 10 para acumular sem perda composta.
- Escala de saída 2 com HALF_UP implementado manualmente (BCMath não tem HALF_UP nativo).
- Toda operação monetária passa por
MoneyVO —floaté proibido em cálculos. - Saída JSON sempre
string.
Clockinterface no Domain.FixedClock(padrão de produção neste exercício, fixado em2024-05-10T00:00:00Z) eSystemClock.- Tornar tempo injetável é pré-requisito de testabilidade — sem isso, juros não são determinísticos.
- Adicionar tipo novo de débito = adicionar 1 enum + 1 calculator + 1 binding DI. Zero
if/elseespalhado. InterestCalculatorRegistry::for(DebtType)lançaNoInterestCalculatorForTypeException(LogicException — bug de programação, não erro de cliente).
ResilientProvider envolve qualquer VehicleDebtProvider:
- Verifica circuit breaker (
closed/open/half_open). - Tenta até
RETRY_MAX_ATTEMPTScom backoff exponencial + jitter. - Registra sucesso/falha no CB.
Isso permite plugar resiliência sem alterar o adapter.
RawDebtData(transporte do provider) →Debt(domínio).CanonicalDebtMapperé o único ponto que converte. Domínio nunca vê JSON/XML.
PayloadSizeMiddleware(1 MiB) antes deJsonBodyParser— evita parsear payloads grandes.ConsultDebtsRequestrejeita campos desconhecidos.PlateMaskeraplicaABC***4em logs estruturados.Correlation-Idaceito do cliente ou gerado (UUID v4).
Mapeados em ErrorHandlerMiddleware:
| Exceção | HTTP | Body |
|---|---|---|
InvalidPlateException |
400 | {"error":"invalid_plate"} |
BadRequestException (unknown_fields) |
400 | {"error":"unknown_fields"} |
UnknownDebtTypeException |
422 | {"error":"unknown_debt_type","type":"X"} |
AllProvidersUnavailableException |
503 | {"error":"all_providers_unavailable"} |
Throwable (fallback) |
500 | {"error":"internal_server_error"} |
| Decisão | Trade-off |
|---|---|
| Slim 4 + PHP-DI (não Laravel/Symfony) | Menos magia, mais código de wiring, mas core ~30 classes. Latência mínima. |
| Circuit breaker in-memory | Funciona perfeitamente em processo CLI/PHP-FPM por request. Para múltiplas réplicas seria necessário Redis ou similar. |
FixedClock como default |
Atende o requisito mas exige CLOCK_MODE=system para produção real. Trade explícito por testabilidade. |
| Sem cache | Não solicitado pelo escopo; ports já existem (HttpClient, providers como interface) para inserir cache decorator no futuro. |
| Sem autenticação | Fora de escopo; middleware adicional triviamente plugável (PSR-15). |
assert() no Container |
Para destravar PHPStan max sem perder segurança em produção; pode ser desligado via zend.assertions=-1. |
| HALF_UP manual em BCMath | Mais verboso, mas conformidade total com a regra "HALF_UP, 2 casas". |
| PMT cobra parcela arredondada × N | Pode haver discrepância de 1 centavo entre valor_parcela × n e valor_total (esperado em PMT). Mantemos precisão interna alta para minimizar. |
- Crie
Infrastructure/Provider/ProviderC/. - Implemente um
ProviderCClient implements VehicleDebtProvidere seu parser (SomeResponseParser) que devolveProviderResponsecanônico. - Registre no
ContainerFactory:ProviderCClient::class => static function (ContainerInterface $c): ProviderCClient { /* ... */ },
- Adicione
Cao registro dentro de'providers.ordered'. - Atualize
PROVIDER_ORDER=A,B,Cno.env.
Zero alteração em Domain, Application, Use Case, regras de juros, regras de pagamento.
- Adicione o valor ao enum
Domain\Debt\ValueObject\DebtType. - Crie
Domain\Debt\Strategy\XInterestCalculator implements InterestCalculator. - Registre no
InterestCalculatorRegistry(viaContainerFactory). - Zero alteração em
Debt,DebtUpdater, providers, parsers, controllers.
| Método | Rota | Descrição |
|---|---|---|
GET |
/healthcheck |
Status + versão |
POST |
/v1/debts |
Consulta débitos por placa |
Especificação completa em openapi.yaml.
Veja .env.example. Principais:
| Variável | Default | Descrição |
|---|---|---|
CLOCK_MODE |
fixed |
fixed ou system |
CLOCK_FIXED_AT |
2024-05-10T00:00:00Z |
Instante UTC (ISO 8601) |
PROVIDER_ORDER |
A,B |
Ordem de fallback |
RETRY_MAX_ATTEMPTS |
3 |
Tentativas por provider |
CIRCUIT_BREAKER_FAILURE_THRESHOLD |
5 |
Falhas até abrir CB |
CIRCUIT_BREAKER_OPEN_SECONDS |
30 |
Tempo em open |
MAX_PAYLOAD_BYTES |
1048576 |
Limite de payload (1 MiB) |