Conceitos de API que Todo Engenheiro de Software Deve Saber
1. Visão geral
Chamar uma API é fácil — enviar requisição, ler JSON, pronto. Mas projetar uma API que outro time, ou você mesmo depois de 6 meses, ainda consiga usá-la sem precisar ler código para adivinhar o comportamento — isso é uma história completamente diferente. Este artigo compila os conceitos de API que qualquer engenheiro de backend deve dominar: desde os fundamentos HTTP, os estilos arquiteturais (REST, GraphQL, gRPC, webhook, WebSocket), até design sustentável, segurança, confiabilidade e documentação. Depois de ler, você terá um checklist concreto para revisar sua própria API antes de disponibilizá-la para outros usarem.
2. Fundamentos HTTP: método, código de status, requisição/resposta
Antes de falar sobre REST ou GraphQL, você deve usar a semântica HTTP básica corretamente. É aqui que muitas APIs “parecem REST”, mas na verdade estão completamente erradas.
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
ResponseEntity<Order> getOrder(@PathVariable Long id) {
return orderService.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
Order created = orderService.create(request);
return ResponseEntity.created(URI.create("/api/orders/" + created.id())).body(created);
}
@PutMapping("/{id}")
ResponseEntity<Order> replaceOrder(@PathVariable Long id, @RequestBody Order order) {
return ResponseEntity.ok(orderService.replace(id, order));
}
@DeleteMapping("/{id}")
ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
orderService.cancel(id);
return ResponseEntity.noContent().build();
}
}
Alguns pontos fáceis de errar:
GETdeve ser seguro (não muda estado) e idempotente. Se seu endpointGETincrementa um contador ou registra algo importante, você está violando um contrato implícito com o cliente (e com proxy/cache de CDN).PUTsubstitui todo o recurso,PATCHatualiza apenas as partes alteradas. UsarPUTpara atualizar um único campo é uma violação de semântica, embora o código ainda funcione.- Códigos de status devem refletir o significado correto:
201 Createdao criar (com headerLocation),204 No Contentquando deletado com sucesso sem corpo de resposta,409 Conflictquando há conflito de estado — não apenas400ou500para tudo.
Observação: Retornar
200 OKpara toda resposta com campo"success": falseno corpo é um anti-pattern comum. Cliente, balanceador de carga, ferramenta de APM — todos confiam no código de status para decidir retry ou alerta. Esconder o erro no corpo quebra toda essa cadeia.
3. Escolher o estilo de API: REST, GraphQL, gRPC, webhook, WebSocket
Cada estilo arquitetural resolve um problema diferente. Escolher errado não é um erro técnico — é uma falha em entender o caso de uso.
3.1. REST — padrão para CRUD via HTTP
Adequado quando os recursos são claros (Order, Customer, Invoice) e os clientes são diversos (web, mobile, third-party). O exemplo na seção 2 é exatamente um REST padrão.
3.2. GraphQL — quando o cliente precisa de flexibilidade de campos
type Order {
id: ID!
customer: Customer!
total: Float!
items: [OrderItem!]!
}
type Query {
order(id: ID!): Order
}
@Controller
class OrderGraphQlController {
private final OrderService orderService;
OrderGraphQlController(OrderService orderService) {
this.orderService = orderService;
}
@QueryMapping
Order order(@Argument Long id) {
return orderService.findById(id).orElse(null);
}
}
O cliente escolhe quais campos buscar ({ order(id: 1) { total } } em vez de precisar chamar 3 endpoints REST). Em troca, você perde cache HTTP baseado em URL, e precisa lidar com N+1 queries por conta própria (usando DataLoader). GraphQL funciona bem com apps que têm UIs complexas, múltiplos clientes precisando de dados diferentes — não se adequa a APIs simples com poucos clientes.
3.3. gRPC — quando você precisa de alto desempenho entre serviços internos
service PaymentService {
rpc Charge (ChargeRequest) returns (ChargeResponse);
}
message ChargeRequest {
string order_id = 1;
int64 amount_cents = 2;
}
gRPC usa HTTP/2 + Protobuf (binário, mais compacto que JSON), suporta streaming bidirecional. Perfeito para comunicação service-to-service em microserviços, mas difícil de debugar manualmente (não é legível com curl comum) e não funciona para APIs públicas acessadas por navegadores.
3.4. Webhook — quando o receptor precisa ser notificado de forma proativa
@PostMapping("/webhooks/payment-status")
ResponseEntity<Void> handlePaymentWebhook(@RequestBody PaymentStatusEvent event,
@RequestHeader("X-Signature") String signature) {
if (!signatureValidator.isValid(event, signature)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
paymentEventHandler.handle(event);
return ResponseEntity.ok().build();
}
Webhook inverte a direção da chamada: o servidor do PaymentService chama de volta para seu servidor quando há um evento. Sempre verifique a assinatura (HMAC) para evitar requisições falsas, e sempre retorne 200 rapidamente e processe de forma assíncrona — o remetente geralmente tenta novamente se houver timeout ou erro.
3.5. WebSocket — quando você precisa de comunicação real-time bidirecional
Use para chat, rastreamento ao vivo, dashboards com atualização contínua. Não adequado para simples request-response porque a sobrecarga de manter a conexão aberta consome mais recursos que REST.
4. Design sustentável: nomenclatura, paginação, versionamento, resposta de erro
4.1. Nomenclatura consistente
Use substantivos no plural para coleções (/orders, não /order ou /getOrders), substantivos aninhados para mostrar relacionamento (/customers/{id}/orders). Evite misturar camelCase e snake_case na mesma API.
4.2. Paginação
@GetMapping
Page<Order> listOrders(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return orderService.findAll(PageRequest.of(page, size));
}
GET /api/orders?page=1&size=20
Baseado em offset (page/size) é fácil de entender mas lento com tabelas grandes, e é fácil retornar registros duplicados/faltantes quando há insert/delete durante a paginação. Baseado em cursor (?after=order_1234) é mais estável com grandes datasets, em troca, é mais complexo implementar “jump para página 10”.
4.3. Versionamento
@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 { /* ... */ }
Versionamento via caminho URI é fácil de ler e rotear através de gateway. Versionamento via header (Accept: application/vnd.myapp.v2+json) é mais limpo em termos de URL mas difícil de debugar no navegador. Qual abordagem escolher não é tão importante quanto ter uma estratégia de deprecação clara: notifique com antecedência, mantenha a versão antiga funcionando tempo suficiente, retorne headers Deprecation e Sunset.
4.4. Padronização de resposta de erro
@ExceptionHandler(OrderNotFoundException.class)
ResponseEntity<ProblemDetail> handleNotFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setProperty("orderId", ex.getOrderId());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Pedido 99 não encontrado",
"orderId": 99
}
ProblemDetail (suportado nativamente no Spring Boot 3.x, seguindo RFC 7807) fornece um formato de erro consistente em todo o sistema — o cliente só precisa analisar um schema único, sem ter que adivinhar qual campo contém a mensagem de erro.
5. Segurança: API key, OAuth2, JWT, scope
5.1. API key — simples, adequada para service-to-service com baixa confiança e baixo risco
@Component
class ApiKeyFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String apiKey = req.getHeader("X-Api-Key");
if (apiKey == null || !apiKeyRepository.isValid(apiKey)) {
res.setStatus(HttpStatus.UNAUTHORIZED.value());
return;
}
chain.doFilter(req, res);
}
}
API key não diferencia quem está chamando em nome de quem — apenas autentica o app, não o usuário. Não a use para proteger dados pessoais do usuário final.
5.2. OAuth2 + JWT — quando você precisa de autorização baseada em scope
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.nonladev.com
@PreAuthorize("hasAuthority('SCOPE_orders:write')")
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
return ResponseEntity.ok(orderService.create(request));
}
JWT carrega claims (user id, scope, exp) que podem ser auto-verificados usando chave pública, não precisa consultar o servidor de auth a cada requisição — é por isso que escala melhor que sessões tradicionais. Mas JWT não pode ser revogado facilmente antes de expirar: se a conta do usuário for bloqueada, o token antigo ainda será válido até exp. Solução comum: manter exp curto (5–15 minutos) com refresh token, ou verificar blacklist na camada de gateway.
Observação: Scope apenas diz “o que este token pode fazer”, não substitui a verificação de regra de negócio. Por exemplo: o usuário A tem
orders:writemas não tem permissão para editar o pedido do usuário B. Sempre verifique propriedade na camada de serviço.
6. Confiabilidade: timeout, retry, idempotência, rate limit, cache
6.1. Timeout e retry
@Bean
RestClient paymentClient() {
return RestClient.builder()
.baseUrl("https://payment.internal")
.requestFactory(ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofSeconds(2))
.withReadTimeout(Duration.ofSeconds(3))
.buildRequestFactory())
.build();
}
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
@Retry(name = "paymentService", fallbackMethod = "chargeFallback")
Charge charge(ChargeRequest request) {
return paymentClient.post().uri("/charge").body(request).retrieve().body(Charge.class);
}
Não definir timeout = thread fica preso indefinidamente quando downstream falha, puxando todo o seu serviço para baixo em um efeito dominó. Retry sem cuidado causa uma tempestade de requisições que derruba downstream mais ainda — sempre combine retry com backoff exponencial e circuit breaker.
6.2. Idempotency key para operações financeiras de escrita
@PostMapping("/charges")
ResponseEntity<Charge> charge(@RequestHeader("Idempotency-Key") String key,
@RequestBody ChargeRequest request) {
return idempotencyStore.findByKey(key)
.map(existing -> ResponseEntity.ok(existing))
.orElseGet(() -> {
Charge charge = paymentService.charge(request);
idempotencyStore.save(key, charge);
return ResponseEntity.ok(charge);
});
}
Se o cliente reenvia a mesma requisição (devido a timeout ou falha de rede) com a mesma Idempotency-Key, receberá o resultado anterior e não será cobrado duas vezes. Este é o conceito mais importante para qualquer API com efeitos colaterais financeiros.
@Test
void shouldReturnSameChargeForDuplicateIdempotencyKey() {
ChargeRequest request = new ChargeRequest("order-1", 10_000L);
Charge first = client.charge("idem-key-1", request);
Charge second = client.charge("idem-key-1", request);
assertThat(second).isEqualTo(first);
assertThat(paymentGateway.chargeCallCount()).isEqualTo(1);
}
6.3. Rate limit e cache
@Bean
Bucket4jConfiguration rateLimitConfig() {
return Bucket4jConfiguration.builder()
.capacity(100)
.refillGreedy(100, Duration.ofMinutes(1))
.build();
}
@Cacheable(value = "orders", key = "#id")
Order findById(Long id) {
return orderRepository.findById(id).orElseThrow();
}
Rate limit protege o sistema de ser sobrecarregado por um cliente com falha (intencional ou não). Cache reduz carga no downstream mas você precisa considerar TTL e invalidação de cache — cache obsoleto para dados de pedido em processamento pode causar desentendimentos de estado mais graves do que não usar cache.
7. Documentação, observabilidade e contract testing
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
@Operation(summary = "Criar novo pedido", responses = {
@ApiResponse(responseCode = "201", description = "Pedido criado com sucesso")
})
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) { /* ... */ }
Spec OpenAPI gerada automaticamente a partir de annotations ajuda a spec não ficar “fora de sincronia” com o código real — problema clássico quando doc é escrita manualmente e depois o código muda mas a doc não é atualizada.
Contract testing (Spring Cloud Contract, Pact) garante que consumer e provider não quebrem o contrato ao fazer deploy independente:
@Test
void providerShouldHonorOrderContract() {
Order order = orderService.findById(1L).orElseThrow();
assertThat(order.id()).isEqualTo(1L);
assertThat(order.status()).isIn("PENDING", "PAID", "CANCELLED");
}
Combinado com observability (logging estruturado, trace ID entre serviços via header traceparent, métricas de latência/taxa de erro por endpoint), a equipe de operações da API terá informações suficientes para debugar em produção em vez de adivinhar através de logs desconexos.
8. Conclusão
O design de API não está em escolher o framework correto — está em uma série de pequenas decisões: qual método, qual código de status, que tipo de paginação, quem pode chamar com qual scope, quantas tentativas antes de parar. Cada decisão individual parece simples, mas juntas elas determinam se sua API é fácil de usar ou é uma dor de cabeça para cada team que se integra. Dominar os conceitos acima — semântica HTTP, escolher a arquitetura certa para o caso de uso, segurança em camadas, padrões de confiabilidade e documentação sincronizada com o código — é a base para projetar uma API que outras pessoas realmente confiam em usar a longo prazo.