Pular para o conteúdo

Trilha de Kotlin · parte 59 de 59

Projeto final: uma API completa de ponta a ponta

Uma API de pedidos com hexagonal, JWT, paginação, ProblemDetail, Liquibase, Micrometer e Docker, testada com Testcontainers e chamada com curl.

avançadoPublicada em · 13 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Projeto final: uma API completa de ponta a ponta

Montar uma API completa costuma ser como montar um móvel de loja: cada peça já encaixou no balcão, e o problema aparece quando o parafuso de uma encontra o furo da outra. Esta aula monta uma API de pedidos com as peças das fases anteriores e mostra onde elas se encaixam. A lógica não é nova. O que muda é o número de costuras, e cada uma precisa funcionar sem que as peças se conheçam demais.

Cada conceito aponta para a aula onde foi explicado. Aqui aparece só o trecho que liga uma peça à outra. O projeto completo fica fora do vault e roda. Comandos e saídas foram executados nesta máquina.

o mapa da aula

A seção 1 justifica a arquitetura, a 2 mostra as pastas, e as seções 3 a 6 percorrem o código. A 7 testa, a 8 empacota e a 9 roda com o script fluxo.sh. As seções 10 e 11 trazem erros e exercícios.

Versões usadas: Kotlin 2.4.21, Spring Boot 4.1.2, Spring Framework 7.0.10, Spring Data JPA 4.1.2, Hibernate ORM 7.4.13.Final, Liquibase 5.0.4, Spring Security 7.1.2, Micrometer 1.17.2, Gradle 9.7.0 e PostgreSQL 18.6, com driver 42.7.14. Os testes rodaram no JDK 21.0.8 (Temurin), e o jar também subiu no JDK 25. As divergências com as aulas anteriores estão no fim.

1. por que hexagonal aqui

Uma API de pedidos tem uma regra que não deveria depender de nada: um pedido só recebe cobrança se estiver aberto. Essa regra precisa ser testada sem banco, servidor ou token. Se ela mora na classe JPA ou no controller, o teste vira uma subida completa do ambiente, lenta e frágil.

A arquitetura hexagonal separa o núcleo do que muda. O núcleo define duas portas, interfaces escritas com as palavras do negócio: PedidoRepositorio, para guardar e buscar pedidos, e GatewayPagamento, para cobrar. Os adaptadores implementam as portas. A raiz de composição liga tudo e é a única peça que conhece as implementações concretas. A regra de dependência está em Arquitetura hexagonal com ArchUnit.

Três decisões saem daí.

A primeira: o Pedido do domínio não é a entidade JPA. A entidade carrega anotações, construtor sem argumentos e proxies, e misturar isso com a regra esconde o que importa. As duas classes se traduzem no adaptador, como explica data class como entidade.

A segunda: os controllers continuam bloqueantes. Com JPA, uma função suspend só ajudaria se a espera fosse suspensa de verdade, e o driver JDBC bloqueia a thread. Veja Spring Boot com controllers suspend e Dispatchers e bloqueio.

A terceira: o JWT usa HS256 com segredo compartilhado, para rodar sem um provedor de identidade. Em produção, o emissor fica fora da API e a validação usa chaves públicas, como em Segurança com JWT e OpenAPI.

Camadas da API de pedidos: controller, adaptador JPA e gateway implementam ou usam as portas, que dependem só do domínio, e a raiz de composição liga tudo
figura 1 · Camadas da API de pedidos: o núcleo não aponta para os adaptadores

O olhar começa no cliente, no topo, e desce pelo grupo de adaptadores até a aplicação. As setas pontilhadas são implementações: o adaptador JPA implementa PedidoRepositorio e o gateway implementa GatewayPagamento. O domínio, na base, não aponta para nenhum outro grupo.

2. estrutura de pastas

pedidos/
├── build.gradle.kts
├── settings.gradle.kts
├── gradlew
├── gradle/
├── Dockerfile
├── scripts/
│   ├── token.sh
│   └── fluxo.sh
├── src/main/kotlin/com/exemplo/pedidos/
│   ├── dominio/            Pedido, Centavos, Pagina
│   ├── aplicacao/          portas e ServicoPedidos
│   ├── adaptadores/
│   │   ├── web/            controller, erros e segurança
│   │   ├── persistencia/   entidade JPA e adaptador
│   │   └── pagamento/      gateway simulado e métricas
│   └── composicao/         ConfiguracaoAplicacao
├── src/main/resources/
│   ├── application.yaml
│   └── db/changelog/       changelog do Liquibase
└── src/test/kotlin/com/exemplo/pedidos/
    ├── dominio/            testes com Kotest
    ├── arquitetura/        regras com ArchUnit
    └── adaptadores/web/    teste de integração com Testcontainers

3. domínio e portas

O método que decide a cobrança tem uma ordem que importa. Ele confere o estado antes de qualquer chamada externa, porque cobrar um pedido já pago geraria uma segunda cobrança que a API teria de estornar.

    fun exigirAberto() {
        if (status != StatusPedido.ABERTO) throw TransicaoInvalida(id, status)
    }

    fun aplicarCobranca(aprovada: Boolean): Pedido {
        exigirAberto()
        return copy(status = if (aprovada) StatusPedido.PAGO else StatusPedido.RECUSADO)
    }

As portas usam só tipos do domínio. GatewayPagamento devolve um ResultadoCobranca selado, que obriga quem chama a tratar os dois casos.

interface PedidoRepositorio {
    fun salvar(pedido: Pedido): Pedido
    fun buscar(id: UUID): Pedido?
    fun listar(pagina: Pagina): PaginaPedidos
}

O serviço junta as peças, e a verificação de estado vem antes da cobrança pelo mesmo motivo.

    fun pagar(id: UUID): Pedido {
        val pedido = buscar(id)
        pedido.exigirAberto()
        val resultado = pagamento.cobrar(pedido)
        return repositorio.salvar(pedido.aplicarCobranca(resultado is ResultadoCobranca.Aprovada))
    }

Há uma lacuna que a aula assume. Entre a leitura e a gravação não existe trava, então dois pagamentos simultâneos do mesmo pedido podem passar juntos. A correção fica como exercício.

4. adaptadores de persistência e pagamento

A entidade JPA guarda os tipos do banco. O status vira texto por EnumType.STRING.

class PedidoEntidade(
    @Id var id: UUID = UUID.randomUUID(),
    @Column(nullable = false, length = 80) var cliente: String = "",
    @Column(name = "total_centavos", nullable = false) var totalCentavos: Long = 0,
    @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) var status: StatusPedido = StatusPedido.ABERTO,
    @Column(name = "criado_em", nullable = false) var criadoEm: Instant = Instant.EPOCH,
) {

O esquema vem do Liquibase, num único changelog SQL com a tabela e um índice para a listagem por data.

--liquibase formatted sql

--changeset aula82:1
create table pedido (
    id uuid primary key,
    cliente varchar(80) not null,
    total_centavos bigint not null check (total_centavos >= 0),
    status varchar(20) not null check (status in ('ABERTO', 'PAGO', 'RECUSADO')),
    criado_em timestamp with time zone not null
);
create index pedido_criado_em_idx on pedido (criado_em desc, id);
--rollback drop table pedido;

Duas configurações fecham a ligação com o banco. Com spring.jpa.hibernate.ddl-auto=validate, o Hibernate compara o mapeamento com a tabela na subida e recusa iniciar se houver diferença, então a tabela só muda pelo Liquibase. Com spring.jpa.open-in-view=false, a sessão não fica aberta durante a renderização.

O adaptador não tem @Transactional. O save do Spring Data já abre a própria transação, e a anotação no serviço exigiria Spring no pacote aplicacao. A regra de camadas da seção 7 não cobre essa anotação, então a proibição é uma escolha de desenho desta aula, não um teste. Uma operação que gravasse duas tabelas exigiria mover essa fronteira para o serviço.

A métrica da cobrança fica num decorador. MetricasGatewayPagamento implementa a mesma porta, delega ao gateway e registra o tempo. A raiz de composição faz a ligação:

    fun servicoPedidos(
        repositorio: PedidoRepositorio,
        gateway: GatewayPagamento,
        registro: MeterRegistry,
    ): ServicoPedidos = ServicoPedidos(repositorio, MetricasGatewayPagamento(gateway, registro))

A tag resultado tem só dois valores, aprovada e recusada. O ID do pedido não entra como tag, porque cada pedido criaria uma série nova. Veja Observabilidade com Micrometer e OpenTelemetry.

5. web, erros e segurança

O controller recebe a paginação com limites na borda, e o domínio repete a validação com require.

    fun listar(
        @RequestParam(defaultValue = "0") @Min(0) pagina: Int,
        @RequestParam(defaultValue = "20") @Min(1) @Max(100) tamanho: Int,
    ): PaginaResponse {
        val resultado = servico.listar(Pagina(pagina, tamanho))
        return PaginaResponse(resultado.itens.map(PedidoResponse::de), resultado.total, pagina, tamanho)
    }

A paginação é por deslocamento. A comparação com cursor está em Design avançado de API: paginação, versionamento e idempotência.

Os erros de validação saem como ProblemDetail, pelo mesmo advice:

    fun corpoInvalido(e: MethodArgumentNotValidException): ProblemDetail {
        val campos = e.bindingResult.fieldErrors.associate { it.field to (it.defaultMessage ?: "inválido") }
        return problema(HttpStatus.BAD_REQUEST, "requisicao-invalida", "Requisição inválida", "Há campos inválidos no corpo")
            .apply { setProperty("campos", campos) }
    }

Dois achados desta montagem merecem atenção. Com spring.mvc.problemdetails.enabled=true, o Boot registra um handler próprio para exceções do Spring MVC. Sem @Order(Ordered.HIGHEST_PRECEDENCE) no TratadorDeErros, o erro de validação saía com o título padrão e sem o campo campos. O teste de integração pegou isso antes do curl. Além disso, os 401 e 403 vêm do Spring Security, antes do controller, e não passam pelo advice: saem com corpo vazio e o cabeçalho WWW-Authenticate. O formato da RFC 9457 está em Tratamento de erros com ProblemDetail.

A autorização usa escopos. Cada escopo do token vira uma autoridade com o prefixo SCOPE_, e a regra confere a autoridade completa:

    fun cadeiaDeFiltros(http: HttpSecurity): SecurityFilterChain {
        http.authorizeHttpRequests { a ->
            a.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll()
                .requestMatchers(HttpMethod.GET, "/pedidos/**").hasAuthority("SCOPE_pedidos:ler")
                .requestMatchers(HttpMethod.POST, "/pedidos/**").hasAuthority("SCOPE_pedidos:escrever")
                .anyRequest().authenticated()
        }

O decodificador também confere a audiência, com um validador próprio em jwtDecoder. Por padrão, o resource server não confere aud, como mostra Segurança com JWT e OpenAPI.

6. o fluxo de uma requisição

A figura segue uma cobrança do filtro até a gravação, com as falhas em caminhos próprios.

Pagamento de um pedido: o filtro JWT, a regra de estado do serviço, o gateway e a gravação no PostgreSQL, com 401, 403, 404, 409 e recusa em caminhos separados
figura 2 · Pagamento de um pedido: token, escopo, estado e falhas

A leitura começa no cliente e desce. O filtro JWT decide antes de qualquer código de negócio, e a seta bloqueada leva ao hexágono rosa de 401 ou 403. O ServicoPedidos confere o estado, e outra seta bloqueada leva a 404 ou 409, ambos em ProblemDetail. A consulta que lê o pedido não aparece como seta, porque está dentro do buscar. Na parte de baixo, as duas gravações em verde chegam ao PostgreSQL pelo adaptador JPA, que o serviço chama pela porta.

7. testes em três níveis

O domínio tem testes de propriedade com Kotest. O gerador cobre a faixa inteira de negativos de Long, e não só o -1.

    test("nenhum valor negativo vira Centavos") {
        checkAll(Arb.long(Long.MIN_VALUE..-1L)) { negativo ->
            shouldThrow<IllegalArgumentException> { Centavos(negativo) }
        }
    }

As regras de arquitetura também são testes. A regra da cebola fica assim:

    val arquiteturaCebola: ArchRule = onionArchitecture()
        .domainModels("..dominio..")
        .applicationServices("..aplicacao..")
        .withOptionalLayers(true)
        .adapter("web", "..adaptadores.web..")
        .adapter("persistencia", "..adaptadores.persistencia..")
        .adapter("pagamento", "..adaptadores.pagamento..")
        .ignoreDependency(JavaClass.Predicates.resideInAPackage("..composicao.."), DescribedPredicate.alwaysTrue())

Sem withOptionalLayers(true), a regra falha com a mensagem Layer 'domain service' is empty, porque a camada de serviços de domínio não existe neste projeto. A opção resolve isso.

O teste de integração sobe um PostgreSQL 18.6 com Testcontainers e liga o contêiner ao Spring com @ServiceConnection, sem URL escrita à mão. Os tokens são assinados no próprio teste com Nimbus, então assinatura, emissor e audiência são validados de verdade. Um dos casos:

    fun `pedido inexistente devolve ProblemDetail 404`() {
        val id = UUID.randomUUID()
        mvc.perform(get("/pedidos/$id").header(HttpHeaders.AUTHORIZATION, "Bearer ${token("pedidos:ler")}"))
            .andExpect(status().isNotFound)
            .andExpect(jsonPath("$.title").value("Pedido não encontrado"))
            .andExpect(jsonPath("$.detail").value("pedido $id não encontrado"))
    }

A suíte completa rodou com ./gradlew clean test:

Classe de teste                                   testes  falhas  erros
com.exemplo.pedidos.adaptadores.web.PedidoApiIT       9       0      0
com.exemplo.pedidos.arquitetura.RegrasDeArquiteturaTest       3       0      0
com.exemplo.pedidos.dominio.PedidoTest           4       0      0
total                                           16       0      0

8. empacotamento

O Dockerfile tem dois estágios. O primeiro usa o JDK para compilar e extrair as camadas do jar. O segundo começa do JRE e copia as camadas do menos para o mais volátil.

FROM eclipse-temurin:21-jdk-alpine AS construcao
WORKDIR /obra
COPY gradlew settings.gradle.kts build.gradle.kts ./
COPY gradle ./gradle
COPY src ./src
RUN ./gradlew --no-daemon bootJar -x test \
 && java -Djarmode=tools -jar build/libs/pedidos-0.0.1.jar extract --layers --launcher --destination /obra/camadas

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=construcao /obra/camadas/dependencies/ ./
COPY --from=construcao /obra/camadas/spring-boot-loader/ ./
COPY --from=construcao /obra/camadas/snapshot-dependencies/ ./
COPY --from=construcao /obra/camadas/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Uma mudança de código invalida só a última camada, e as outras vêm do cache.

Imagem em duas etapas: a construção gera o jar e extrai as camadas, e a imagem final copia dependências antes da aplicação, a única camada refeita a cada commit
figura 3 · Imagem em duas etapas: só a última camada muda a cada commit

Medido neste build: a camada dependencies pesou 74,7 MB, a application 283 kB, e a imagem final ficou em 428 MB. O build foi feito num Mac com Apple silicon e não testei linux/amd64.

9. rodar do zero

Os comandos abaixo são os que rodaram. O banco sobe num contêiner com nome próprio e porta restrita ao computador local.

export JAVA_HOME=/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home
cd pedidos && ./gradlew test
./gradlew bootJar
docker run -d --name pg-aula-8-2 -e POSTGRES_USER=pedidos \
  -e POSTGRES_PASSWORD=pedidos_local -e POSTGRES_DB=pedidos \
  -p 127.0.0.1:55482:5432 postgres:18.6
export DB_SENHA=pedidos_local PORT=8082
export JWT_SEGREDO=segredo-do-exemplo-local-com-trinta-e-dois-bytes
$JAVA_HOME/bin/java -jar build/libs/pedidos-0.0.1.jar &
BASE=http://127.0.0.1:8082 scripts/fluxo.sh

A URL do banco vem da configuração do projeto, que não está nesta nota. Ela deve apontar para a porta 55482 publicada acima.

O fluxo.sh gera os tokens com token.sh, que assina um JWT HS256 com openssl, e percorre a API. A saída foi esta:

== 1. sem token

HTTP 401
== 2. token só de leitura tenta criar

HTTP 403
== 3. cria pedido de 120,00
== 4. consulta o pedido
{"id":"bd6c4505-e701-45ca-bf45-7790aff64d63","cliente":"ana","valorCentavos":12000,"status":"ABERTO","criadoEm":"2026-11-05T00:35:46.402147Z"}
HTTP 200
== 5. paga (aprovado: valor abaixo do limite)
{"id":"bd6c4505-e701-45ca-bf45-7790aff64d63","cliente":"ana","valorCentavos":12000,"status":"PAGO","criadoEm":"2026-11-05T00:35:46.402147Z"}
HTTP 200
== 6. paga de novo (conflito)
{"detail":"pedido bd6c4505-e701-45ca-bf45-7790aff64d63 está PAGO e não aceita cobrança","instance":"/pedidos/bd6c4505-e701-45ca-bf45-7790aff64d63/pagamento","status":409,"title":"Transição de pedido inválida","type":"https://exemplo.com/erros/transicao-invalida"}
HTTP 409
== 7. cria pedido acima do limite e paga (recusado)
{"id":"22f9ffe8-4e4d-4172-9ead-2be1675cb1ec","cliente":"bruno","valorCentavos":500000,"status":"RECUSADO","criadoEm":"2026-11-05T00:35:47.042335Z"}
HTTP 200
== 8. pedido inexistente
{"detail":"pedido 00000000-0000-0000-0000-000000000000 não encontrado","instance":"/pedidos/00000000-0000-0000-0000-000000000000","status":404,"title":"Pedido não encontrado","type":"https://exemplo.com/erros/pedido-nao-encontrado"}
HTTP 404
== 9. corpo inválido
{"detail":"Há campos inválidos no corpo","instance":"/pedidos","status":400,"title":"Requisição inválida","type":"https://exemplo.com/erros/requisicao-invalida","campos":{"valorCentavos":"must be greater than or equal to 1","cliente":"must not be blank"}}
HTTP 400
== 10. parâmetro de paginação fora da faixa
{"detail":"Há parâmetros inválidos","instance":"/pedidos","status":400,"title":"Requisição inválida","type":"https://exemplo.com/erros/requisicao-invalida","campos":{"tamanho":"must be less than or equal to 100"}}
HTTP 400
== 11. listagem paginada (página 0, tamanho 1)
{"itens":[{"id":"22f9ffe8-4e4d-4172-9ead-2be1675cb1ec","cliente":"bruno","valorCentavos":500000,"status":"RECUSADO","criadoEm":"2026-11-05T00:35:47.042335Z"}],"total":2,"pagina":0,"tamanho":1}
HTTP 200
== 12. série do timer de cobrança no Prometheus
pedidos_cobranca_seconds_count{resultado="aprovada"} 1
pedidos_cobranca_seconds_count{resultado="recusada"} 1

A última linha mostra a métrica de cobrança, com uma aprovação e uma recusa. O total da listagem é 2 porque o banco estava vazio antes da execução.

10. erros comuns e boas práticas

O primeiro erro é apagar o jar com a aplicação no ar. Rodei ./gradlew clean test enquanto o jar executava, e o clean apagou o arquivo. O kotlin-reflect relê recursos do jar sob demanda, e as requisições seguintes responderam 500 com NoSuchFileException. A saída desse erro não foi copiada para a nota. A boa prática é copiar o jar para uma pasta de execução, como fiz depois.

O segundo erro é gravar e depois falhar na resposta. Nessa mesma execução, ana e bruno foram gravados e commitados, e o cliente recebeu 500 porque a resposta não pôde ser montada. Uma escrita commitada não volta sozinha, e um reenvio criaria outro pedido. O remédio é a chave de idempotência do exercício 1.

O terceiro erro é confiar só nos testes de lógica e não testar a borda. O problema de ordem dos handlers só apareceu porque há um teste que lê o corpo do ProblemDetail.

O quarto erro é pôr @Transactional no pacote de aplicação por comodidade. O atalho funciona e quebra a regra que o ArchUnit deveria proteger.

11. exercícios

Estes exercícios não têm resposta nesta nota, de propósito.

Exercício 1: aceite um cabeçalho Idempotency-Key no POST /pedidos. Guarde a chave com o pedido e devolva o mesmo pedido quando a chave chegar de novo. A técnica está em Design avançado de API: paginação, versionamento e idempotência.

Exercício 2: impeça dois pagamentos simultâneos. Adicione uma coluna versao com @Version em PedidoEntidade, converta a falha de concorrência em 409 e escreva um teste que dispara dois pagamentos em paralelo.

Exercício 3: troque o HS256 por RS256, validando com chave pública. O caminho está em Segurança com JWT e OpenAPI.

Exercício 4: depois de pagar, publique um evento PedidoPago por um adaptador novo, sem mudar o ServicoPedidos. Compare com Mensageria com Kafka.

12. glossário

Adaptador: classe que implementa uma porta ou usa uma tecnologia concreta.

Porta: interface que o núcleo define para o que precisa do mundo externo.

Raiz de composição: parte que conhece as implementações e as liga, normalmente na inicialização.

ProblemDetail: corpo de erro da RFC 9457, com type, title, status, detail e campos próprios.

Escopo: permissão do token, que o Spring transforma em autoridade com o prefixo SCOPE_.

o que não foi confirmado ou medido

Não testei concorrência no pagamento. A lacuna da seção 3 fica para o exercício 2.

Kafka, RestClient, OpenAPI e tracing distribuído não entram. O Kafka e o RestClient aparecem como extensão nos exercícios e nas aulas linkadas, porque a montagem já era extensa.

A suíte rodou só no JDK 21. No JDK 25 testei apenas o jar, com uma criação e uma listagem.

A imagem foi construída e executada num Mac com Apple silicon, sem teste em linux/amd64.

O segredo HS256 é de exemplo. Não há rotação, revogação nem expiração configurável além de uma hora.

A recusa é simulada por um limite de valor. Não há gateway real nem falha de rede.

Fontes

Consultadas em 04/11/2026. As versões foram lidas do gradle dependencies do próprio projeto. Testcontainers 2.0.5, Kotest 6.2.6 e ArchUnit 1.5.1 foram conferidos no Maven Central. Na versão 2.0.5, a classe PostgreSQLContainer do pacote org.testcontainers.containers está marcada como depreciada, e usei a do pacote org.testcontainers.postgresql.

A documentação oficial do Spring não foi consultada nesta sessão. Os comportamentos citados foram verificados executando o código.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?