Trilha de Kotlin · parte 43 de 59
Design avançado de API: paginação, versionamento e idempotência
Paginação por deslocamento e por cursor, versões de API, Idempotency-Key e ETag em Spring Boot, com requisições reais e as falhas que cada escolha esconde.
avançadoPublicada em · 11 min de leitura
Design avançado de API: paginação, versionamento e idempotência
Uma pizzaria com telefone instável ensina boa parte deste assunto. Se a linha cai depois do pedido, o cliente liga de novo, e a atendente precisa saber se o pedido já entrou. A analogia tem limite: a API só conhece o que chega na requisição.
Esta aula continua a API de pedidos de Spring Boot com controllers suspend e de Tratamento de erros com ProblemDetail, com os pedidos numa lista em memória. GraphQL, gRPC, rate limiting e HATEOAS ficam de fora.
o mapa da aula
As seções 1 a 3 tratam de paginação, a 4 de versões, a 5 de idempotência e a 6 de ETag.
Versões desta aula: Spring Boot 4.1.2, a versão padrão do Spring Initializr em 04/11/2026, com Spring Framework 7.0.10, Jackson 3.1.7, Kotlin 2.4.21 e Gradle 9.7.0. As aulas anteriores declaram Spring Boot 4.1.1 e Kotlin 2.3.21; a diferença não foi testada.
1. o problema de devolver tudo
Devolver a lista inteira obriga o servidor a montar um JSON cada vez maior. Paginar entrega um pedaço e um jeito de pedir o próximo. A técnica muda conforme a resposta descreve o próximo pedaço: pela posição na lista, ou por um item já recebido. O Spring Data tem Pageable, usado com persistência (fase 7).
2. paginação por deslocamento
No deslocamento, o cliente pede página e tamanho, e o servidor pula (página - 1) × tamanho itens. A resposta traz os itens, o total, a página e o tamanho. O defeito aparece quando a lista muda entre uma leitura e outra, porque a posição de cada item depende de quantos itens existem antes dele. O repositório ordena por id decrescente.
import org.springframework.stereotype.Component
data class Pedido(val id: Long, val cliente: String, val valor: Int, val versao: Int)
data class NovoPedido(val cliente: String, val valor: Int)
@Component
class RepositorioPedidos {
private val lista = mutableListOf<Pedido>()
private var ultimoId = 0L
@Synchronized fun criar(novo: NovoPedido) = Pedido(++ultimoId, novo.cliente, novo.valor, 1).also { lista.add(it) }
@Synchronized fun buscar(id: Long) = lista.find { it.id == id }
@Synchronized fun remover(id: Long) = lista.removeIf { it.id == id }
@Synchronized fun atualizarSe(id: Long, condicao: (Pedido) -> Boolean, novo: NovoPedido): Pedido? {
val i = lista.indexOfFirst { it.id == id }
if (i < 0 || !condicao(lista[i])) return null
return lista[i].copy(cliente = novo.cliente, valor = novo.valor, versao = lista[i].versao + 1).also { lista[i] = it }
}
@Synchronized fun pagina(pagina: Int, tamanho: Int) = lista.sortedByDescending { it.id }.drop((pagina - 1) * tamanho).take(tamanho)
@Synchronized fun depoisDe(cursor: Long, limite: Int) = lista.filter { it.id < cursor }.sortedByDescending { it.id }.take(limite)
@Synchronized fun total() = lista.size
}
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.context.request.WebRequest
import java.util.concurrent.ConcurrentHashMap
data class PaginaPorDeslocamento(val itens: List<Pedido>, val total: Int, val pagina: Int, val tamanho: Int)
data class PaginaPorCursor(val itens: List<Pedido>, val proximoCursor: Long?)
data class PedidoV2(val id: Long, val comprador: String, val valor: Int)
private data class Registro(val entrada: NovoPedido, val pedido: Pedido)
fun etag(p: Pedido) = "\"v${p.versao}\""
@RestController
@RequestMapping("/pedidos")
class PedidoController(private val repo: RepositorioPedidos) {
private val chaves = ConcurrentHashMap<String, Registro>()
@GetMapping("/offset")
fun porDeslocamento(@RequestParam(defaultValue = "1") pagina: Int, @RequestParam(defaultValue = "5") tamanho: Int) =
PaginaPorDeslocamento(repo.pagina(pagina, tamanho), repo.total(), pagina, tamanho)
@GetMapping("/cursor")
fun porCursor(@RequestParam(required = false) apos: Long?, @RequestParam(defaultValue = "5") limite: Int): ResponseEntity<PaginaPorCursor> {
val itens = repo.depoisDe(apos ?: Long.MAX_VALUE, limite)
val proximo = itens.lastOrNull()?.id?.takeIf { itens.size == limite }
val resposta = ResponseEntity.ok()
if (proximo != null) resposta.header(HttpHeaders.LINK, "</pedidos/cursor?apos=$proximo&limite=$limite>; rel=\"next\"")
return resposta.body(PaginaPorCursor(itens, proximo))
}
@PostMapping
fun criar(@RequestHeader("Idempotency-Key", required = false) chave: String?, @RequestBody entrada: NovoPedido): ResponseEntity<Any> {
val anterior = chave?.let { chaves[it] }
if (anterior != null && anterior.entrada != entrada) {
val erro = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, "Idempotency-Key já usada com outro corpo")
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_CONTENT).body(erro)
}
if (anterior != null) return ResponseEntity.status(HttpStatus.CREATED).body(anterior.pedido)
val pedido = repo.criar(entrada)
if (chave != null) chaves[chave] = Registro(entrada, pedido)
return ResponseEntity.status(HttpStatus.CREATED).body(pedido)
}
@GetMapping("/{id}")
fun buscar(@PathVariable id: Long, requisicao: WebRequest): ResponseEntity<Pedido> {
val pedido = repo.buscar(id) ?: return ResponseEntity.notFound().build()
if (requisicao.checkNotModified(etag(pedido))) return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build()
return ResponseEntity.ok().eTag(etag(pedido)).body(pedido)
}
@GetMapping("/{id}", version = "2.0+")
fun buscarV2(@PathVariable id: Long): ResponseEntity<PedidoV2> {
val p = repo.buscar(id) ?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(PedidoV2(p.id, p.cliente, p.valor))
}
@PutMapping("/{id}")
fun atualizar(@PathVariable id: Long, @RequestHeader(HttpHeaders.IF_MATCH, required = false) seAtender: String?, @RequestBody entrada: NovoPedido): ResponseEntity<Any> {
if (repo.buscar(id) == null) return ResponseEntity.notFound().build()
val novo = repo.atualizarSe(id, { seAtender == null || seAtender == etag(it) }, entrada)
?: return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build()
return ResponseEntity.ok().eTag(etag(novo)).body(novo)
}
@DeleteMapping("/{id}")
fun remover(@PathVariable id: Long): ResponseEntity<Void> =
if (repo.remover(id)) ResponseEntity.noContent().build() else ResponseEntity.notFound().build()
}
O script roda contra um servidor recém-iniciado.
#!/bin/bash
B="$1"
P() { curl -s -X POST "$B/pedidos" -H 'Content-Type: application/json' -d "$1"; }
ids() { jq -c '[.itens[].id]'; }
for i in $(seq 1 10); do P "{\"cliente\":\"C$i\",\"valor\":$((i*100))}" > /dev/null; done
echo "1. deslocamento, pagina 1:"; curl -s "$B/pedidos/offset?pagina=1&tamanho=4" | ids
echo "2. cursor, pagina 1:"; curl -s -D h.txt "$B/pedidos/cursor?limite=4" | ids; grep -i '^link' h.txt
P '{"cliente":"C11","valor":1100}' > /dev/null
echo "3. entra o 11; deslocamento, pagina 2:"; curl -s "$B/pedidos/offset?pagina=2&tamanho=4" | ids
echo "4. cursor depois do 7:"; curl -s "$B/pedidos/cursor?apos=7&limite=4" | ids
echo "5. le a pagina 1, remove o 10, le de novo:"; curl -s "$B/pedidos/offset?pagina=1&tamanho=4" | ids
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "$B/pedidos/10"
curl -s "$B/pedidos/offset?pagina=2&tamanho=4" | ids; curl -s "$B/pedidos/cursor?apos=8&limite=4" | ids
echo "6. mesmo POST sem chave, duas vezes:"
P '{"cliente":"Dani","valor":500}' | jq -c .id; P '{"cliente":"Dani","valor":500}' | jq -c .id
K='Idempotency-Key: "pedido-eva-1"'
echo "7. mesma chave e mesmo corpo:"
for v in 700 700; do curl -s -X POST "$B/pedidos" -H "$K" -H 'Content-Type: application/json' -d "{\"cliente\":\"Eva\",\"valor\":$v}" -w ' %{http_code}\n'; done
echo "8. mesma chave, outro corpo:"
curl -s -X POST "$B/pedidos" -H "$K" -H 'Content-Type: application/json' -d '{"cliente":"Eva","valor":900}' -w ' %{http_code}\n'
echo "9. condicional sobre o pedido 3, sem e com If-None-Match:"
curl -s -o /dev/null -w "%{http_code}\n" "$B/pedidos/3"
curl -s -o /dev/null -w "%{http_code}\n" -H 'If-None-Match: "v1"' "$B/pedidos/3"
echo "10. gravacoes a partir da versao 1:"
curl -s -o /dev/null -w "Ana: %{http_code}\n" -X PUT -H 'If-Match: "v1"' -H 'Content-Type: application/json' -d '{"cliente":"Ana","valor":310}' "$B/pedidos/3"
curl -s -o /dev/null -w "Bia: %{http_code}\n" -X PUT -H 'If-Match: "v1"' -H 'Content-Type: application/json' -d '{"cliente":"Bia","valor":320}' "$B/pedidos/3"
curl -s -o /dev/null -w "Caio: %{http_code}\n" -X PUT -H 'Content-Type: application/json' -d '{"cliente":"Caio","valor":330}' "$B/pedidos/3"
curl -s "$B/pedidos/3"; echo
echo "11. versoes do pedido 3:"
curl -s "$B/pedidos/3"; echo
curl -s -H 'API-Version: 2.0' "$B/pedidos/3"; echo
curl -s -w ' %{http_code}\n' -H 'API-Version: 3.0' "$B/pedidos/3"
1. deslocamento, pagina 1:
[10,9,8,7]
2. cursor, pagina 1:
[10,9,8,7]
Link: </pedidos/cursor?apos=7&limite=4>; rel="next"
3. entra o 11; deslocamento, pagina 2:
[7,6,5,4]
4. cursor depois do 7:
[6,5,4,3]
5. le a pagina 1, remove o 10, le de novo:
[11,10,9,8]
204
[6,5,4,3]
[7,6,5,4]
6. mesmo POST sem chave, duas vezes:
12
13
7. mesma chave e mesmo corpo:
{"id":14,"cliente":"Eva","valor":700,"versao":1} 201
{"id":14,"cliente":"Eva","valor":700,"versao":1} 201
8. mesma chave, outro corpo:
{"detail":"Idempotency-Key já usada com outro corpo","instance":"/pedidos","status":422,"title":"Unprocessable Content"} 422
9. condicional sobre o pedido 3, sem e com If-None-Match:
200
304
10. gravacoes a partir da versao 1:
Ana: 200
Bia: 412
Caio: 200
{"id":3,"cliente":"Caio","valor":330,"versao":3}
11. versoes do pedido 3:
{"id":3,"cliente":"Caio","valor":330,"versao":3}
{"id":3,"comprador":"Caio","valor":330}
{"timestamp":"2026-11-04T23:16:48.133Z","status":400,"error":"Bad Request","path":"/pedidos/3"} 400
A página 2 começou com o 7, que a página 1 já tinha mostrado. O pedido 11 entrou no topo, e a posição 4 passou a ser o 7. Na remoção, o 10 saiu de cima. A página 1 tinha sido lida antes, e a página 2 lida depois começou no 6. O 7 mudou de posição e ficou de fora das duas leituras: esse é o pulo do deslocamento. Com cursor, nada se repetiu nem pulou. O mesmo script em JDK 25 deu saída idêntica.
Com um escritor em paralelo, três execuções (servidor novo, 400 inserções). O código desse escritor não está nesta nota:
execução 1: deslocamento repetiu 8 ids, cursor repetiu 0
execução 2: deslocamento repetiu 9 ids, cursor repetiu 0
execução 3: deslocamento repetiu 9 ids, cursor repetiu 0
3. paginação por cursor
Na paginação por cursor, a resposta traz o último id entregue, e a chamada seguinte pede os itens com id menor que ele. Uma inserção no topo não empurra a página seguinte, porque o servidor não conta posições. Em troca, o cliente não pula para a página 50, e o total não sai de graça: o exemplo devolve proximoCursor. A chave de ordenação precisa ser única e estável, e o id é as duas coisas.
O cabeçalho Link (RFC 8288) traz a próxima página com rel="next", como no passo 2.
4. versionamento
Na URL, como em /v2/pedidos, a versão aparece em qualquer log, mas duplica rotas. No cabeçalho, como API-Version: 2.0, a URL fica estável, mas quem esquece o cabeçalho cai na versão padrão. No tipo de mídia, como Accept: application/vnd.loja.v2+json, a versão entra na negociação de conteúdo. Só o cabeçalho foi rodado aqui, com suporte nativo do Spring Framework 7.
O atributo version marca o mapeamento, e configureApiVersioning define o cabeçalho e a versão padrão. Um mapeamento sem versão casa com qualquer versão, mas perde para um mais específico. 2.0+ casa com a 2.0 e com as suportadas acima dela.
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@SpringBootApplication
class LojaApplication : WebMvcConfigurer {
override fun configureApiVersioning(configurer: ApiVersionConfigurer) {
configurer.useRequestHeader("API-Version").setDefaultVersion("1.0").addSupportedVersions("1.0")
}
}
fun main(args: Array<String>) {
runApplication<LojaApplication>(*args)
}
O passo 11 mostra o efeito: sem cabeçalho, a forma antiga; com API-Version: 2.0, a forma nova, em que cliente virou comprador; com 3.0, 400.
Um detalhe pegou. As versões suportadas vêm, por padrão, das versões declaradas nos mapeamentos, e 2.0+ não acrescenta 2.5 a elas. Por isso a 2.5 respondeu 400 nesta configuração. O exercício 2 corrige isso.
Mudança compatível não pede versão nova. Adicionar um campo opcional não quebra quem não o conhece. Renomear cliente para comprador quebra, e aí a versão nova se justifica.
Pergunta de entrevistaPaginação por offset ou por cursor: como você escolhe?
Offset é simples e permite pular para uma página específica, mas repete ou pula itens quando a lista muda entre uma leitura e outra. Cursor usa o último item recebido como ponto de partida, então inserções no topo não causam repetição. Em troca, o cliente não salta para a página 50, e o total custa mais para calcular. Para feeds e listas que mudam, uso cursor. Para uma tela administrativa com poucas linhas, offset basta.
5. idempotência
Segundo a RFC 9110, o POST não é idempotente, e o cliente não deve repeti-lo automaticamente, a menos que saiba que a original nunca foi aplicada. Por isso um POST que cria pedido, repetido depois de um timeout, pode duplicar o pedido. O passo 6 mostra isso: o mesmo corpo, sem chave, gerou os pedidos 12 e 13.
A correção usual é o cliente enviar uma chave em Idempotency-Key. O servidor guarda chave, corpo e resposta. A mesma chave com o mesmo corpo recebe a resposta guardada, como no passo 7, em que as duas chamadas devolveram o id 14. A mesma chave com outro corpo recebe 422, como no passo 8. O código guarda isso num ConcurrentHashMap em memória.
A figura mostra as duas perguntas do servidor: a chave já foi vista, e o corpo é igual.
O rascunho draft-ietf-httpapi-idempotency-key-header ainda não é RFC. Na revisão 08, de 22 de outubro de 2026, é um Active Internet-Draft do grupo httpapi, válido até 25 de abril de 2027. Ele recomenda (SHOULD) 409 para requisição concorrente com a mesma chave. O exemplo não implementa esse 409, e a corrida entre chamadas simultâneas não foi testada.
6. requisições condicionais
O ETag é um validador opaco da versão de uma representação, aqui "v1" (RFC 9110, seção 8.8.3). If-None-Match com o ETag atual faz o GET responder 304 sem corpo (seção 15.4.5), o que poupa banda. If-Match faz a gravação valer só se a versão ainda for a que o cliente leu; senão, a resposta é 412 (seção 15.5.13). O Spring cobre o 304 com checkNotModified. A checagem do If-Match fica no atualizarSe, dentro do mesmo bloqueio que troca a versão.
Os passos 9 e 10 mostram o problema: Ana e Bia partiram da versão 1. Ana gravou e subiu para a versão 2, e Bia recebeu 412. Caio gravou sem If-Match, e sobrescreveu a Ana sem aviso. Essa é a atualização perdida.
Pergunta de entrevistaComo você garante que um POST não crie o mesmo pedido duas vezes?
O cliente envia uma chave de idempotência no cabeçalho, e o servidor guarda a chave, o corpo e a resposta. Se a mesma chave chegar com o mesmo corpo, devolve a resposta guardada, sem criar outro pedido. Se chegar com um corpo diferente, recusa com 422. A chave precisa de expiração, e a gravação da operação e o registro da chave precisam ser atômicos, senão duas requisições simultâneas podem passar.
erros comuns e boas práticas
O primeiro erro é usar deslocamento em lista que muda. O segundo é criar versão para cada mudança, quando um campo opcional não quebra ninguém. O terceiro é aceitar a chave sem comparar o corpo, porque a chave reutilizada devolveria a resposta de outro pedido. O quarto é tratar 412 como erro do servidor, quando é o retorno normal de uma gravação concorrente.
exercícios
- Guiado. Depois do script de exemplo, o servidor tem os pedidos 1 a 9, 11, 12, 13 e 14. Antes de rodar, preveja a página 2 por deslocamento (tamanho 4) se o pedido 14 for removido. A resposta é
[8,7,6,5]: o 9 cai na posição 3, que a página 1 já cobria. A saída real, depois do DELETE do pedido 14 e da leitura da página 2:
estado antes:
[14,13,12,11,9,8,7,6,5,4,3,2,1]
DELETE /pedidos/14 -> 204
pagina 2 de 4 por deslocamento:
[8,7,6,5]
- Mudança de versão. Em
LojaApplication, troqueaddSupportedVersions("1.0")poraddSupportedVersions("1.0", "2.5"), suba de novo e leia comAPI-Version: 2.5. A resposta é 200 com a formaPedidoV2, porque 2.5 passa a ser suportada e2.0+a casa. A saída:
== exercicio 2: addSupportedVersions("1.0", "2.5")
POST /pedidos (sem chave) -> 201
API-Version 1.5 -> {"timestamp":"2026-11-04T23:07:22.053Z","status":400,"error":"Bad Request","path":"/pedidos/1"} HTTP 400
API-Version 2.0 -> {"id":1,"comprador":"Ana","valor":10} HTTP 200
API-Version 2.5 -> {"id":1,"comprador":"Ana","valor":10} HTTP 200
API-Version 3.0 -> {"timestamp":"2026-11-04T23:07:22.148Z","status":400,"error":"Bad Request","path":"/pedidos/1"} HTTP 400
glossário
Paginação por deslocamento: o cliente pede uma página por número e tamanho, e o servidor conta as posições.
Método idempotente: várias requisições iguais têm o efeito de uma só (RFC 9110, seção 9.2.2).
Idempotency-Key: cabeçalho com uma chave que o cliente gera para identificar uma escrita.
o que não foi confirmado ou medido
O custo de OFFSET em banco não foi medido, pois não há banco. A corrida da chave, o 409 e a expiração das chaves não foram testados. Versão por caminho e por tipo de mídia não foram rodadas. As repetições vêm de três execuções, para a ordem de grandeza, e a economia de bytes do 304 não foi medida.
Os trechos não trazem package. No projeto, o código ficou no pacote loja, porque o pacote padrão impediu o Spring de subir. Nenhuma saída mostrada tem posição de código.
Fontes
Consultadas em 04/11/2026:
- Spring Framework, API versioning e configuração de versionamento: https://docs.spring.io/spring-framework/reference/web/webmvc-versioning.html e https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/api-version.html
- Spring Framework, Javadoc de
ApiVersionConfigurer: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.html - Spring Framework, HTTP caching, com ETag e
checkNotModified: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-caching.html - Spring Data Commons,
Pageable: https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/Pageable.html - Spring Initializr, versão padrão do Spring Boot: https://start.spring.io/metadata/client
- RFC 9110, HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110.html
- RFC 8288, Web Linking: https://www.rfc-editor.org/rfc/rfc8288.html
- Draft
draft-ietf-httpapi-idempotency-key-header, revisão 08: https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
Verificações locais, em 04/11/2026: compilação com Gradle 9.7.0 e Kotlin 2.4.21, e jar iniciado com java -jar em localhost, em JDK 21.0.8 e JDK 25.