APIs REST escalables en Spring Boot

Capítulo 3

Tiempo estimado de lectura: 10 minutos

+ Ejercicio

Diseño de recursos y endpoints: buenas prácticas REST

Una API REST escalable en Spring Boot se diseña alrededor de recursos (sustantivos) y operaciones expresadas con verbos HTTP. Esto mejora la mantenibilidad, facilita el consumo por múltiples clientes y reduce cambios incompatibles.

Convenciones recomendadas

  • Rutas en plural: /orders, /customers.
  • Jerarquía cuando hay relación fuerte: /customers/{customerId}/orders.
  • Evitar verbos en la URL: en vez de /orders/create, usar POST /orders.
  • Representaciones: el cuerpo (JSON) describe el estado del recurso.

Verbos HTTP y códigos de estado

OperaciónHTTPEjemploRespuestas típicas
CrearPOSTPOST /orders201 Created (+ Location), 400, 409
ListarGETGET /orders200 OK
ObtenerGETGET /orders/{id}200 OK, 404 Not Found
ReemplazarPUTPUT /orders/{id}200 OK o 204 No Content, 404
Actualizar parcialPATCHPATCH /orders/{id}200 OK o 204, 404
EliminarDELETEDELETE /orders/{id}204 No Content, 404

Para escalabilidad y consistencia, define reglas claras: por ejemplo, POST devuelve siempre 201 con cabecera Location, y DELETE devuelve 204 aunque el recurso ya no tenga cuerpo.

Guía práctica: controlador REST con DTOs, paginación y validación

1) Define DTOs orientados a contrato

Evita exponer entidades internas. Los DTOs son tu contrato público: estables, versionables y con validación.

public record CreateOrderRequest(    @jakarta.validation.constraints.NotNull(message = "customerId es obligatorio")    Long customerId,    @jakarta.validation.constraints.NotEmpty(message = "items no puede estar vacío")    java.util.List<OrderItemRequest> items,    @jakarta.validation.constraints.NotBlank(message = "currency es obligatoria")    String currency) {} public record OrderItemRequest(    @jakarta.validation.constraints.NotNull(message = "productId es obligatorio")    Long productId,    @jakarta.validation.constraints.Positive(message = "quantity debe ser > 0")    Integer quantity) {} public record OrderResponse(    Long id,    Long customerId,    String status,    String currency,    java.time.OffsetDateTime createdAt) {} 

Buenas prácticas de contrato: usa nombres claros, evita campos “técnicos” (por ejemplo, internalNotes), y prefiere tipos explícitos (por ejemplo, OffsetDateTime).

2) Implementa el controlador con ResponseEntity y validación

El controlador orquesta: valida entrada, delega a un servicio de aplicación y devuelve códigos correctos.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

@org.springframework.web.bind.annotation.RestController @org.springframework.web.bind.annotation.RequestMapping("/api/v1/orders") public class OrderController {   private final OrderService orderService;   public OrderController(OrderService orderService) {     this.orderService = orderService;   }   @org.springframework.web.bind.annotation.PostMapping   public org.springframework.http.ResponseEntity<OrderResponse> create(       @org.springframework.validation.annotation.Validated       @jakarta.validation.Valid @org.springframework.web.bind.annotation.RequestBody CreateOrderRequest request,       org.springframework.web.util.UriComponentsBuilder uriBuilder) {     OrderResponse created = orderService.create(request);     java.net.URI location = uriBuilder.path("/api/v1/orders/{id}").buildAndExpand(created.id()).toUri();     return org.springframework.http.ResponseEntity.created(location).body(created);   }   @org.springframework.web.bind.annotation.GetMapping("/{id}")   public OrderResponse getById(@org.springframework.web.bind.annotation.PathVariable Long id) {     return orderService.getById(id);   }   @org.springframework.web.bind.annotation.DeleteMapping("/{id}")   public org.springframework.http.ResponseEntity<Void> delete(@org.springframework.web.bind.annotation.PathVariable Long id) {     orderService.delete(id);     return org.springframework.http.ResponseEntity.noContent().build();   } }

Notas: @Valid dispara Bean Validation. ResponseEntity.created(...) ayuda a devolver 201 con Location. Para GET puedes devolver directamente el DTO.

3) Paginación, filtrado y ordenamiento

Para listados escalables, evita devolver colecciones completas. Usa paginación y parámetros de consulta. Un patrón común: page, size, sort, y filtros específicos del dominio.

@org.springframework.web.bind.annotation.GetMapping public PagedResponse<OrderResponse> list(    @org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page,    @org.springframework.web.bind.annotation.RequestParam(defaultValue = "20") int size,    @org.springframework.web.bind.annotation.RequestParam(required = false) String status,    @org.springframework.web.bind.annotation.RequestParam(required = false) Long customerId,    @org.springframework.web.bind.annotation.RequestParam(defaultValue = "createdAt,desc") String sort) {   return orderService.list(page, size, status, customerId, sort); }

Recomendaciones:

  • Limita size (por ejemplo, máximo 100) para proteger el servicio.
  • Ordenamiento controlado: valida campos permitidos para evitar consultas costosas o inseguras.
  • Filtros explícitos: evita un “query” libre si no lo necesitas; define parámetros por campo.

Un DTO de respuesta paginada típico:

public record PagedResponse<T>(    java.util.List<T> items,    PageMeta meta) {} public record PageMeta(    int page,    int size,    long totalItems,    int totalPages,    boolean hasNext,    boolean hasPrevious) {} 

Estructura de DTOs y mapeo entre capas

Para mantener el controlador delgado y el contrato estable, separa: Controller → Service → Repository, y realiza mapeos en un componente dedicado (mapper). Esto reduce acoplamiento y facilita cambios internos sin romper clientes.

Mapper manual (simple y explícito)

@org.springframework.stereotype.Component public class OrderMapper {   public OrderResponse toResponse(Order order) {     return new OrderResponse(         order.getId(),         order.getCustomerId(),         order.getStatus().name(),         order.getCurrency(),         order.getCreatedAt());   }   public Order toDomain(CreateOrderRequest req) {     Order order = new Order();     order.setCustomerId(req.customerId());     order.setCurrency(req.currency());     order.setStatus(OrderStatus.CREATED);     // items se mapearían aquí     return order;   } }

Ventajas: control total y claridad. En equipos grandes, esto evita “magia” y hace más fácil auditar cambios de contrato.

Reglas prácticas de mapeo

  • No mezcles lógica de negocio en el mapper; solo transformación.
  • El servicio valida reglas de negocio (por ejemplo, “no se puede crear una orden sin stock”).
  • El controlador valida formato/estructura (Bean Validation) y delega.

Validación con Bean Validation: anotaciones y mensajes de error

Bean Validation te permite validar la entrada de forma declarativa. Para APIs escalables, la clave es devolver errores uniformes y accionables.

Validación de parámetros de query y path

@org.springframework.web.bind.annotation.GetMapping public PagedResponse<OrderResponse> list(    @jakarta.validation.constraints.Min(value = 0, message = "page debe ser >= 0")    @org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page,    @jakarta.validation.constraints.Min(value = 1, message = "size debe ser >= 1")    @jakarta.validation.constraints.Max(value = 100, message = "size no puede ser > 100")    @org.springframework.web.bind.annotation.RequestParam(defaultValue = "20") int size) {   return orderService.list(page, size, null, null, "createdAt,desc"); }

Para que estas validaciones se apliquen en parámetros, habilita validación en el controlador con @Validated a nivel de clase:

@org.springframework.validation.annotation.Validated @org.springframework.web.bind.annotation.RestController public class OrderController { ... }

Mensajes de error consistentes

Define mensajes claros y orientados a usuario/cliente. Evita mensajes internos (stack traces, nombres de tablas). Puedes centralizar mensajes con messages.properties si necesitas internacionalización, pero mantén el contrato de error estable.

Manejo de errores uniforme con un esquema estándar

En microservicios, los clientes suelen integrar múltiples APIs. Un esquema de error consistente reduce fricción y acelera diagnósticos. Un formato práctico incluye: timestamp, traceId, status, error, message, path y details (lista de errores de validación o campos).

1) Define el modelo de error

public record ApiError(    java.time.OffsetDateTime timestamp,    String traceId,    int status,    String error,    String message,    String path,    java.util.List<ApiErrorDetail> details) {} public record ApiErrorDetail(    String field,    String issue) {} 

2) Controlador global con @RestControllerAdvice

@org.springframework.web.bind.annotation.RestControllerAdvice public class GlobalExceptionHandler {   @org.springframework.web.bind.annotation.ExceptionHandler(org.springframework.web.bind.MethodArgumentNotValidException.class)   public org.springframework.http.ResponseEntity<ApiError> handleValidation(       org.springframework.web.bind.MethodArgumentNotValidException ex,       jakarta.servlet.http.HttpServletRequest request) {     java.util.List<ApiErrorDetail> details = ex.getBindingResult().getFieldErrors().stream()         .map(fe -> new ApiErrorDetail(fe.getField(), fe.getDefaultMessage()))         .toList();     ApiError body = new ApiError(         java.time.OffsetDateTime.now(),         traceId(),         400,         "Bad Request",         "Validation failed",         request.getRequestURI(),         details);     return org.springframework.http.ResponseEntity.badRequest().body(body);   }   @org.springframework.web.bind.annotation.ExceptionHandler(org.springframework.web.server.ResponseStatusException.class)   public org.springframework.http.ResponseEntity<ApiError> handleStatus(       org.springframework.web.server.ResponseStatusException ex,       jakarta.servlet.http.HttpServletRequest request) {     ApiError body = new ApiError(         java.time.OffsetDateTime.now(),         traceId(),         ex.getStatusCode().value(),         ex.getStatusCode().toString(),         ex.getReason(),         request.getRequestURI(),         java.util.List.of());     return org.springframework.http.ResponseEntity.status(ex.getStatusCode()).body(body);   }   @org.springframework.web.bind.annotation.ExceptionHandler(Exception.class)   public org.springframework.http.ResponseEntity<ApiError> handleGeneric(       Exception ex,       jakarta.servlet.http.HttpServletRequest request) {     ApiError body = new ApiError(         java.time.OffsetDateTime.now(),         traceId(),         500,         "Internal Server Error",         "Unexpected error",         request.getRequestURI(),         java.util.List.of());     return org.springframework.http.ResponseEntity.status(500).body(body);   }   private String traceId() {     // Si usas Micrometer Tracing, aquí podrías leer el traceId actual.     // Como fallback, genera uno.     return java.util.UUID.randomUUID().toString();   } }

Claves de escalabilidad operativa:

  • No devuelvas stack traces al cliente.
  • Incluye traceId para correlacionar logs y trazas distribuidas.
  • Devuelve details solo cuando aporte valor (por ejemplo, validación).

3) Excepciones de dominio y mapeo a HTTP

Para mantener el servicio limpio, lanza excepciones de dominio y tradúcelas en el handler global. Ejemplo: recurso no encontrado y conflicto.

public class NotFoundException extends RuntimeException {   public NotFoundException(String message) { super(message); } } public class ConflictException extends RuntimeException {   public ConflictException(String message) { super(message); } } 
@org.springframework.web.bind.annotation.ExceptionHandler(NotFoundException.class) public org.springframework.http.ResponseEntity<ApiError> handleNotFound(    NotFoundException ex, jakarta.servlet.http.HttpServletRequest request) {   ApiError body = new ApiError(       java.time.OffsetDateTime.now(), traceId(), 404, "Not Found", ex.getMessage(), request.getRequestURI(), java.util.List.of());   return org.springframework.http.ResponseEntity.status(404).body(body); } @org.springframework.web.bind.annotation.ExceptionHandler(ConflictException.class) public org.springframework.http.ResponseEntity<ApiError> handleConflict(    ConflictException ex, jakarta.servlet.http.HttpServletRequest request) {   ApiError body = new ApiError(       java.time.OffsetDateTime.now(), traceId(), 409, "Conflict", ex.getMessage(), request.getRequestURI(), java.util.List.of());   return org.springframework.http.ResponseEntity.status(409).body(body); }

Versionado de API y compatibilidad hacia atrás

El versionado es una herramienta para evolucionar contratos sin romper clientes. En APIs escalables, el objetivo es minimizar versiones y maximizar compatibilidad hacia atrás.

Estrategias comunes de versionado

  • En la URL: /api/v1/orders, /api/v2/orders. Simple de operar y documentar.
  • Por header: Accept: application/vnd.acme.orders.v1+json. Más “REST puro”, pero añade complejidad en gateways y clientes.
  • Por query param: ?version=1. Suele ser menos recomendable por ambigüedad y cache.

En Spring Boot, el versionado por URL es directo:

@org.springframework.web.bind.annotation.RestController @org.springframework.web.bind.annotation.RequestMapping("/api/v1/orders") class OrderControllerV1 { ... } @org.springframework.web.bind.annotation.RestController @org.springframework.web.bind.annotation.RequestMapping("/api/v2/orders") class OrderControllerV2 { ... }

Compatibilidad hacia atrás: reglas prácticas

  • Añadir campos en responses suele ser compatible (clientes robustos ignoran lo desconocido).
  • No renombres ni elimines campos sin una nueva versión.
  • No cambies semántica de un campo (por ejemplo, status de texto a número) sin versionar.
  • Evita cambios en validaciones que vuelvan inválidas solicitudes antes válidas; si es necesario, versiona o introduce transición.
  • Expande enums con cuidado: nuevos valores pueden romper clientes estrictos; documenta y considera tolerancia.

Diseño orientado a contratos (contract-first) en la práctica

Un enfoque orientado a contratos prioriza definir el API (endpoints, DTOs, errores) antes de implementar. Esto reduce retrabajo y alinea a equipos (backend, frontend, integradores). Aunque puedes usar OpenAPI, aquí nos enfocamos en decisiones de contrato dentro del código.

Checklist de contrato para cada endpoint

  • Ruta del recurso y verbos HTTP.
  • Request/Response DTOs (sin exponer entidades).
  • Códigos de estado esperados (incluyendo errores).
  • Esquema de error uniforme (ApiError).
  • Paginación/filtrado/ordenamiento con límites.
  • Reglas de compatibilidad hacia atrás y plan de versionado.

Ejemplo: evolución compatible vs. incompatible

Compatible: en OrderResponse agregas updatedAt opcional.

public record OrderResponse(    Long id, Long customerId, String status, String currency,    java.time.OffsetDateTime createdAt,    java.time.OffsetDateTime updatedAt) {} 

Incompatible: cambias customerId de Long a objeto complejo o renombrarlo a clientId. Eso debería ir a v2 o requerir una estrategia de transición (por ejemplo, mantener ambos campos temporalmente).

Ahora responde el ejercicio sobre el contenido:

Al diseñar un endpoint REST para crear un recurso, ¿qué combinación refleja mejor una práctica escalable y consistente?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Para crear recursos se recomienda POST sobre la colección (p. ej., /orders) y responder 201 Created con Location hacia el recurso creado, evitando verbos en la URL y manteniendo consistencia.

Siguiente capítulo

Persistencia y transacciones en microservicios con Spring Data

Arrow Right Icon
Portada de libro electrónico gratuitaMicroservicios con Spring Boot desde Cero
25%

Microservicios con Spring Boot desde Cero

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.