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, usarPOST /orders. - Representaciones: el cuerpo (JSON) describe el estado del recurso.
Verbos HTTP y códigos de estado
| Operación | HTTP | Ejemplo | Respuestas típicas |
|---|---|---|---|
| Crear | POST | POST /orders | 201 Created (+ Location), 400, 409 |
| Listar | GET | GET /orders | 200 OK |
| Obtener | GET | GET /orders/{id} | 200 OK, 404 Not Found |
| Reemplazar | PUT | PUT /orders/{id} | 200 OK o 204 No Content, 404 |
| Actualizar parcial | PATCH | PATCH /orders/{id} | 200 OK o 204, 404 |
| Eliminar | DELETE | DELETE /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.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
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,
statusde 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).