APIs REST resilientes en Spring Boot: validación, manejo de errores y pruebas

java APIs REST resilientes en Spring Boot: validación, manejo de errores y pruebas

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.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión