Cuando recibes una petición HTTP en una aplicación Spring Boot, lo primero que entra en juego es el controlador. Es la puerta de entrada: recibe la petición, delega el trabajo en la capa de servicio y devuelve una respuesta. Nada más. Si tu controlador hace algo distinto a eso, algo está mal.
El controlador en la arquitectura MVC
Spring Boot sigue el patrón MVC (Model-View-Controller). El controlador ocupa la C: coordina sin ejecutar lógica de negocio. El modelo representa los datos, la vista (o el JSON en una API REST) es lo que llega al cliente.
En una API REST moderna, la vista casi siempre es JSON. Eso simplifica bastante las cosas: el controlador recibe una petición, llama al servicio, serializa la respuesta y listo.
@RestController vs @Controller
@Controller es la anotación base. Por sí sola, Spring espera que el método devuelva el nombre de una vista (una plantilla Thymeleaf, por ejemplo). Si quieres devolver datos directamente en el cuerpo de la respuesta, necesitas añadir @ResponseBody al método.
@RestController combina @Controller y @ResponseBody en una sola anotación. Úsala cuando construyas una API REST pura que devuelve JSON o XML. Es lo habitual en el 90% de los proyectos actuales.
// Para APIs REST: devuelve JSON directamente
@RestController
public class ProductoController { ... }
// Para aplicaciones MVC con vistas (Thymeleaf, Freemarker...)
@Controller
public class ProductoViewController { ... }
Mapeo de rutas: @RequestMapping y sus variantes
@RequestMapping es la anotación genérica para mapear rutas HTTP. Admite el método HTTP como parámetro:
@RequestMapping(value = "/productos", method = RequestMethod.GET)
public List<ProductoDTO> listar() { ... }
Pero es más cómodo usar las anotaciones específicas por método:
@GetMappingpara lecturas@PostMappingpara creación@PutMappingpara actualización completa@PatchMappingpara actualización parcial@DeleteMappingpara borrado
A nivel de clase puedes usar @RequestMapping("/api/v1/productos") para definir el prefijo común de todos los endpoints del controlador.
@PathVariable y @RequestParam
Son dos formas distintas de pasar datos en la URL, y conviene tenerlas claras.
@PathVariable extrae un segmento de la ruta:
@GetMapping("/productos/{id}")
public ProductoDTO obtener(@PathVariable Long id) {
return productoService.findById(id);
}
La petición sería GET /productos/42. El 42 queda vinculado al parámetro id.
@RequestParam extrae parámetros de la query string:
@GetMapping("/productos")
public List<ProductoDTO> buscar(
@RequestParam(required = false) String nombre,
@RequestParam(defaultValue = "0") int pagina) {
return productoService.buscar(nombre, pagina);
}
La petición sería GET /productos?nombre=teclado&pagina=2. Usa @PathVariable para identificadores de recursos y @RequestParam para filtros o paginación.
@RequestBody y validación con Bean Validation
@RequestBody deserializa el cuerpo JSON de la petición a un objeto Java. Jackson hace el trabajo pesado:
@PostMapping("/productos")
public ResponseEntity<ProductoDTO> crear(@RequestBody @Valid ProductoRequest request) {
ProductoDTO creado = productoService.crear(request);
return ResponseEntity.status(HttpStatus.CREATED).body(creado);
}
Añadiendo @Valid, Spring activa Bean Validation (JSR-380) sobre el objeto recibido. En el DTO de entrada puedes anotar los campos:
public class ProductoRequest {
@NotBlank(message = "El nombre es obligatorio")
private String nombre;
@DecimalMin(value = "0.01", message = "El precio debe ser mayor que cero")
private BigDecimal precio;
@NotNull
@Min(0)
private Integer stock;
}
Si la validación falla, Spring lanza MethodArgumentNotValidException, que puedes capturar con @ControllerAdvice.
ResponseEntity: control total sobre la respuesta
Devolver el objeto directamente funciona para casos simples, pero pierdes control sobre el código HTTP y las cabeceras. ResponseEntity<T> te da ese control:
@GetMapping("/productos/{id}")
public ResponseEntity<ProductoDTO> obtener(@PathVariable Long id) {
return productoService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
Úsalo cuando necesites devolver códigos como 201 Created, 204 No Content o 404 Not Found, o cuando quieras añadir cabeceras personalizadas a la respuesta.
Gestión centralizada de errores con @ControllerAdvice
Poner bloques try-catch en cada método del controlador es un error de diseño. Spring tiene @ControllerAdvice para gestionar excepciones de forma centralizada:
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductoNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ProductoNotFoundException ex) {
ErrorResponse error = new ErrorResponse("NOT_FOUND", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String mensaje = ex.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(new ErrorResponse("VALIDATION_ERROR", mensaje));
}
}
Con esto, tus controladores quedan limpios y el manejo de errores está en un único lugar.
Separación de responsabilidades
El controlador no debe contener lógica de negocio. Ni cálculos, ni acceso directo a repositorios, ni transformaciones complejas. Solo coordina: recibe, delega, devuelve.
Si te encuentras con métodos de controlador que superan las 20 líneas, es señal de que algo que debería estar en el servicio ha acabado en el controlador. Al estudiar los patrones de diseño en Java aplicables a la capa de controladores verás que este principio aparece de forma recurrente.
Ejemplo completo: controlador CRUD para Producto
@RestController
@RequestMapping("/api/v1/productos")
public class ProductoController {
private final ProductoService productoService;
public ProductoController(ProductoService productoService) {
this.productoService = productoService;
}
@GetMapping
public List<ProductoDTO> listar() {
return productoService.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<ProductoDTO> obtener(@PathVariable Long id) {
return productoService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<ProductoDTO> crear(@RequestBody @Valid ProductoRequest request) {
ProductoDTO creado = productoService.crear(request);
URI location = URI.create("/api/v1/productos/" + creado.getId());
return ResponseEntity.created(location).body(creado);
}
@PutMapping("/{id}")
public ResponseEntity<ProductoDTO> actualizar(
@PathVariable Long id,
@RequestBody @Valid ProductoRequest request) {
return ResponseEntity.ok(productoService.actualizar(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> eliminar(@PathVariable Long id) {
productoService.eliminar(id);
return ResponseEntity.noContent().build();
}
}
Fíjate en varios detalles: la dependencia se inyecta por constructor (no por campo con @Autowired), cada método delega inmediatamente en el servicio y el controlador nunca toca el repositorio directamente.
Buenas prácticas
Versiona tu API desde el principio. Poner /api/v1/ en el prefijo del controlador te ahorra problemas cuando necesites hacer cambios que rompan la compatibilidad. Con Java 25 LTS, la versión de referencia para Spring Boot 3, el soporte a largo plazo facilita mantener varias versiones de API en producción.
Usa DTOs, no entidades. Exponer tus entidades JPA directamente en la API tiene varios problemas: referencias circulares en la serialización, campos internos que no deberían ser públicos y acoplamiento entre la API y el modelo de datos. Crea clases específicas de request y response.
No expongas la capa de persistencia. El controlador no debe saber que existe JPA, Hibernate ni ningún repositorio concreto. Solo habla con servicios.
Mantén los métodos cortos. Un método de controlador bien hecho cabe en menos de diez líneas. Si crece más, refactoriza hacia el servicio.
Los controladores son la cara pública de tu aplicación. Mantenerlos limpios y con responsabilidades claras hace que el resto de capas sean más fáciles de probar y mantener.
Imagen: Pexels / Markus Spiske
