APIs REST resilientes en Spring Boot: validación, manejo de errores y pruebas
Te muestro un patrón pragmático para construir APIs REST con Spring Boot: validación de entrada con Jakarta Validation, manejo centralizado de errores (HTTP Problem / JSON) y pruebas unitarias/integración rápidas. El objetivo es tener respuestas consistentes, trazables y fáciles de testear.
1) DTOs y validación
Usa anotaciones de validación en los DTOs y deja que Spring valide automáticamente los parámetros del controlador.
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class UserCreateRequest {
@NotBlank(message = "El nombre es obligatorio")
@Size(max = 100)
private String name;
@Email(message = "Correo inválido")
@NotBlank
private String email;
// getters y setters
}
En el controlador, añade @Valid y captura errores con BindingResult solo si necesitas lógica adicional; en la mayoría de los casos deja que el @ControllerAdvice los transforme:
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
@Validated
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<UserDto> create(@Valid @RequestBody UserCreateRequest req) {
UserDto created = userService.createUser(req);
return ResponseEntity.status(201).body(created);
}
}
2) Excepciones del dominio y errores centralizados
Define excepciones claras para el dominio y un @ControllerAdvice que transforme todo a un formato consistente (ej. RFC 7807 / Problem JSON o una estructura propia).
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {
super(message);
}
}
public class ConflictException extends RuntimeException {
public ConflictException(String message) { super(message); }
}
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<Map<String,Object>> handleNotFound(NotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of(
"type", "https://example.com/probs/not-found",
"title", "Recurso no encontrado",
"detail", ex.getMessage()
));
}
@ExceptionHandler(ConflictException.class)
public ResponseEntity<Map<String,Object>> handleConflict(ConflictException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("title","Conflicto","detail",ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String,Object>> handleValidation(MethodArgumentNotValidException ex) {
var errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(f -> f.getField(), f -> f.getDefaultMessage()));
return ResponseEntity.badRequest()
.body(Map.of(
"title", "Validación fallida",
"errors", errors
));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String,Object>> fallback(Exception ex) {
// log con stacktrace en producción (usa un logger)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("title","Error interno","detail", "Algo salió mal"));
}
}
Ventajas: respuestas consistentes, trazabilidad y menos if/else en controladores.
3) Servicio y consistencia
Haz que tu capa de servicio lance excepciones de dominio y que el controlador sólo se encargue de la deserialización y del código HTTP.
import org.springframework.stereotype.Service;
@Service
public class UserService {
// repository inyectado (JPA, JdbcTemplate, etc.)
private final UserRepository repo;
public UserService(UserRepository repo) { this.repo = repo; }
public UserDto createUser(UserCreateRequest req) {
if (repo.existsByEmail(req.getEmail())) {
throw new ConflictException("Email ya registrado");
}
UserEntity e = new UserEntity(req.getName(), req.getEmail());
UserEntity saved = repo.save(e);
return UserDto.from(saved);
}
}
4) Pruebas rápidas
Unit tests con MockMvc para el controlador y tests de servicio por separado. Mantén los tests pequeños y deterministas.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
MockMvc mvc;
@MockBean
UserService userService;
@Test
void createShouldReturn201() throws Exception {
String body = "{\"name\":\"Juan\",\"email\":\"juan@example.com\"}";
when(userService.createUser(org.mockito.ArgumentMatchers.any())).thenReturn(new UserDto(1L,"Juan","juan@example.com"));
mvc.perform(post("/api/users")
.contentType("application/json")
.content(body))
.andExpect(status().isCreated());
}
}
Para integración, añade Testcontainers (DB real) y prueba flujos completos: creación, actualización, error 404.
5) Buenas prácticas y consideraciones
- Devuelve códigos HTTP correctos: 201 para creación, 204 para borrado sin cuerpo, 404/409 según el dominio.
- Valida tamaño y formato de entrada: previene ataques y sobrecarga.
- Evita exponer stacktraces en producción; loggea con correlación (traceId).
- Define contratos (OpenAPI) y genera ejemplos para clients. Mantén el contrato como fuente de verdad.
- Usa validación adicional en servicios para invariantes que no cubre el DTO (ej. idempotencia, límites de negocio).
Implementa métricas y trazas (Micrometer + OpenTelemetry) para monitorizar errores y latencias en producción.
Siguiente paso práctico: añade Testcontainers para tu base de datos y crea contratos de integración (PACT o contract tests) para asegurar que clients y servicio concuerdan. Consejo avanzado: expón errores en formato Problem JSON con un campo instance que contenga el path y el traceId — facilita correlacionar logs con peticiones en sistemas distribuidos.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación