Conceptos de API que Todo Ingeniero debe Conocer: explicación detallada

Este artículo tiene un vídeo corto:TikTok

1. Descripción general

Llamar una API es fácil — envía una solicitud, lee JSON, listo. Pero diseñar una API que otro equipo, o tú mismo en 6 meses, siga usando sin tener que leer el código para adivinar el comportamiento — eso es otra cosa. Este artículo resume los conceptos de API que cualquier ingeniero de backend debe dominar: desde los fundamentos de HTTP, los estilos de arquitectura (REST, GraphQL, gRPC, webhooks, WebSocket), hasta diseño sostenible, seguridad, confiabilidad y documentación. Después de leer, tendrás un checklist específico para revisar tu propia API antes de hacerla pública para que otros la usen.

2. Fundamentos de HTTP: método, código de estado, solicitud/respuesta

Antes de hablar de REST o GraphQL, debes usar correctamente la semántica HTTP básica. Este es el lugar donde muchas APIs parecen REST pero en realidad están completamente mal.

@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();
    }
}

Algunos puntos fáciles de equivocarse:

  • GET debe ser segura (no cambia el estado) e idempotente. Si tu endpoint GET incrementa un contador o registra un log importante, estás violando el contrato implícito con el cliente (y con proxy/CDN caching).
  • PUT reemplaza el recurso completo, PATCH solo actualiza la parte que cambió. Usar PUT para actualizar un campo es semánticamente incorrecto, aunque el código siga funcionando.
  • El código de estado debe reflejar correctamente el significado: 201 Created cuando creas un nuevo recurso (con el header Location), 204 No Content cuando eliminas exitosamente sin devolver un body, 409 Conflict cuando hay conflicto de estado — no simplemente 400 o 500 para cualquier error.

Nota: Devolver 200 OK para cada respuesta con un campo "success": false en el body es un anti-patrón común. El cliente, el equilibrador de carga, las herramientas APM dependen del código de estado para decidir si reintentar o alertar — ocultar errores en el body rompe toda esa cadena.

3. Elige el estilo de API: REST, GraphQL, gRPC, webhook, WebSocket

Cada estilo de arquitectura resuelve un problema diferente. Elegir mal no es un error técnico — es un error de comprensión del caso de uso.

3.1. REST — predeterminado para CRUD sobre HTTP

Apropiado cuando los recursos son claros (Order, Customer, Invoice) y los clientes son diversos (web, mobile, third-party). El ejemplo de la sección 2 es exactamente REST estándar.

3.2. GraphQL — cuando el cliente necesita flexibilidad en los 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);
    }
}

El cliente elige qué campos necesita ({ order(id: 1) { total } } en lugar de tener que llamar a 3 endpoints REST). A cambio, pierdes el caching HTTP basado en URL y tienes que manejar el problema N+1 (usando DataLoader). GraphQL es apropiado para aplicaciones con UI compleja, múltiples clientes que necesitan datos diferentes — no para APIs simples con pocos clientes.

3.3. gRPC — cuando necesitas alto rendimiento entre servicios internos

service PaymentService {
  rpc Charge (ChargeRequest) returns (ChargeResponse);
}

message ChargeRequest {
  string order_id = 1;
  int64 amount_cents = 2;
}

gRPC usa HTTP/2 + Protobuf (binario, más compacto que JSON), soporta streaming bidireccional. Muy apropiado para comunicación entre servicios en microservicios, pero difícil de depurar manualmente (no es legible con curl) y no es adecuado para APIs públicas del navegador.

3.4. Webhook — cuando el receptor necesita ser notificado proactivamente

@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 invierte la dirección de la llamada: el servidor de PaymentService te llama cuando hay un evento. Siempre verifica la firma (HMAC) para evitar solicitudes falsas, y siempre devuelve 200 rápidamente y procesa asincrónico — el remitente generalmente reintentar si hay timeout o error.

3.5. WebSocket — cuando necesitas comunicación real-time bidireccional

Úsalo para chat, seguimiento en vivo, tableros que se actualizan continuamente. No es apropiado para request-response simple porque el overhead de mantener la conexión abierta consume más recursos que REST.

4. Diseño sostenible: nombres, paginación, versionado, respuesta de error

4.1. Nombres consistentes

Usa sustantivos plurales para colecciones (/orders, no /order o /getOrders), sustantivos anidados para mostrar relaciones (/customers/{id}/orders). Evita mezclar camelCase y snake_case en la misma API.

4.2. Paginación

@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

Offset-based (page/size) es fácil de entender pero lento con tablas grandes y propenso a registros duplicados/faltantes si hay inserts/deletes durante la paginación. Cursor-based (?after=order_1234) es más estable con conjuntos de datos grandes, pero más complejo de implementar para “ir a la página 10”.

4.3. Versionado

@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 { /* ... */ }

El versionado por ruta URI es fácil de leer, fácil de enrutar con gateway. El versionado por header (Accept: application/vnd.myapp.v2+json) es más limpio en la URL pero difícil de depurar en el navegador. Qué forma elijas es menos importante que tener una estrategia de deprecación clara: notificar con anticipación, mantener la versión antigua ejecutándose el tiempo suficiente, devolver headers Deprecation y Sunset.

4.4. Respuesta de error estandarizada

@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": "Order 99 not found",
  "orderId": 99
}

ProblemDetail (soportado nativamente en Spring Boot 3.x, según RFC 7807) produce un formato de error consistente en todo el sistema — el cliente solo necesita parsear un esquema único, sin tener que adivinar qué campo contiene el mensaje de error.

5. Seguridad: API key, OAuth2, JWT, scope

5.1. API key — simple, apropiada para service-to-service con baja confianza y bajo riesgo

@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);
    }
}

Una API key no puede distinguir quién está llamando en nombre de quién — solo autentica la aplicación, no al usuario. No la uses para proteger datos personales de usuarios finales.

5.2. OAuth2 + JWT — cuando necesitas autorización basada en 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 lleva claims (id de usuario, scope, exp) que se pueden verificar automáticamente con la clave pública, sin necesidad de preguntar al servidor de autenticación cada vez — esta es la razón por la que escala mejor que las sesiones tradicionales. Pero JWT no se puede revocar fácilmente antes de que expire: si se bloquea la cuenta de un usuario, el token anterior sigue siendo válido hasta que exp. La solución común es: usar exp corta (5–15 minutos) con refresh token, o verificar una lista negra en la capa de gateway.

Nota: Scope solo dice “qué está permitido hacer con este token”, no reemplaza verificar la regla de negocio (por ejemplo: el usuario A tiene orders:write pero no tiene permiso para editar la orden del usuario B). Siempre verifica la propiedad en la capa de servicio.

6. Confiabilidad: timeout, retry, idempotencia, rate limit, cache

6.1. Timeout y 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);
}

No configurar timeout = el hilo se cuelga indefinidamente cuando downstream se cae, causando que tu servicio colapse por efecto dominó. Reintentar sin cuidado causa una tormenta de solicitudes que daña aún más downstream — siempre combina retry con exponential backoff y circuit breaker.

6.2. Idempotency key para operaciones que escriben dinero

@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);
            });
}

Si el cliente reenvía la misma solicitud (por timeout, problema de red) con la misma Idempotency-Key, recibirá el resultado anterior sin ser cargado dos veces. Este es el concepto más importante para cualquier API con efectos secundarios financieros.

@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 y 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 el sistema de ser sobrecargado por un cliente defectuoso (accidental o intencionalmente). El caché reduce la carga en downstream pero debes considerar TTL e invalidación de caché — el caché obsoleto de datos de orden en procesamiento puede causar una mala interpretación del estado más grave que no tener caché.

7. Documentación, observabilidad y contract testing

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.6.0</version>
</dependency>
@Operation(summary = "Crear una nueva orden", responses = {
        @ApiResponse(responseCode = "201", description = "Orden creada exitosamente")
})
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) { /* ... */ }

La especificación OpenAPI autogenerada a partir de anotaciones ayuda a que la especificación no “desvíe” del código real — el problema clásico cuando la documentación se escribe a mano y el código cambia pero se olvida actualizar.

Contract testing (Spring Cloud Contract, Pact) asegura que el consumidor y el proveedor no rompan el contrato cuando se despliegan de forma independiente:

@Test
void providerShouldHonorOrderContract() {
    Order order = orderService.findById(1L).orElseThrow();

    assertThat(order.id()).isEqualTo(1L);
    assertThat(order.status()).isIn("PENDING", "PAID", "CANCELLED");
}

Combinado con observabilidad (structured logging, trace ID entre servicios mediante header traceparent, métricas de latencia/tasa de error por endpoint), el equipo de operaciones tiene información suficiente para depurar en producción en lugar de adivinar a través de registros dispersos.

8. Conclusión

El diseño de API no se trata de elegir el framework correcto — se trata de una serie de pequeñas decisiones: qué método, qué código de estado, qué tipo de paginación, quién está autorizado con qué scope, cuántos reintentos antes de detener. Cada decisión individual parece simple, pero en conjunto determinan si tu API es fácil de usar o una fuente de dolores de cabeza para todos los equipos que se integren. Dominar los conceptos anteriores — semántica HTTP, elegir la arquitectura correcta para el caso de uso, seguridad por capas, patrones de confiabilidad y documentación sincronizada con el código — es la base para diseñar APIs en las que otros realmente confían y usan a largo plazo.

system-designapispring-bootrestsecurity