Hasta ahora hemos construido la arquitectura por capas (Controller, Service, DAO/Repository) instanciando y conectando las dependencias de forma manual. En este tema utilizaremos Spring Boot, el framework estándar de la industria Java, para delegar esta tarea a su contenedor de IoC y exponer nuestra aplicación como una API REST profesional.
Un framework proporciona la infraestructura necesaria (enrutamiento HTTP, servidor web embebido, gestión de beans, filtros) para que el desarrollador se concentre únicamente en implementar la lógica de negocio.
Crear una aplicación web tradicional en Java requería configurar archivos de despliegue complejos, compilar un archivo .war y desplegarlo manualmente en un servidor externo (como Apache Tomcat). Spring Boot simplifica este proceso integrando un servidor Tomcat embebido dentro del propio ejecutable .jar de la aplicación.
La forma estándar de inicializar un proyecto Spring Boot es a través de Spring Initializr. Al configurar un nuevo proyecto seleccionamos:
Dependencias principales:
La clase principal anotada con @SpringBootApplication es la encargada de arrancar el servidor embebido y la aplicación mediante el método main:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BookstoreApiApplication {
public static void main(String[] args) {
SpringApplication.run(BookstoreApiApplication.class, args);
}
}
Por defecto, el servidor web escucha en el puerto 8080. Para cambiar este comportamiento, basta con incluir la siguiente propiedad en el archivo src/main/resources/application.properties:
server.port=8081
Para comprender cómo procesa Spring Boot las peticiones HTTP, es necesario entender la especificación base del desarrollo web en Java: los Servlets.
Un Servlet (definido en la especificación jakarta.servlet) es una clase Java encargada de atender peticiones HTTP (HttpServletRequest) y generar respuestas HTTP (HttpServletResponse).
En lugar de requerir la creación de un Servlet independiente para cada ruta del sistema, Spring Boot implementa el patrón de diseño Front Controller a través de una clase central llamada DispatcherServlet.
El flujo de ejecución de una petición sigue estos pasos:
GET /api/v1/books).DispatcherServlet intercepta la petición entrante.@RestController y qué método están configurados para responder a esa ruta y verbo HTTP.JSON de la petición en un objeto Java (Record / DTO).JSON y lo envía al cliente dentro de la respuesta HTTP.
Antes de que una petición alcance el DispatcherServlet (y, por extensión, nuestros controladores), es posible interceptarla mediante Filtros HTTP.
Los filtros forman una cadena de responsabilidad (Filter Chain) y se utilizan para procesar aspectos transversales a la aplicación, tales como:
Extendiendo de la clase OncePerRequestFilter, garantizamos que el filtro se ejecute exactamente una vez por cada petición HTTP:
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class AuditLogFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
long startTime = System.currentTimeMillis();
// Permite que la petición continúe hacia el DispatcherServlet / Controller
filterChain.doFilter(request, response);
long duration = System.currentTimeMillis() - startTime;
IO.printf("[%s] %s -> Estado: %d (%d ms)%n",
request.getMethod(), request.getRequestURI(), response.getStatus(), duration);
}
}
Para exponer recursos como endpoints REST, creamos clases anotadas con @RestController y @RequestMapping.
@RestController: Define la clase como un controlador web REST e indica a Spring que el valor devuelto por sus métodos debe serializarse directamente en el cuerpo de la respuesta HTTP (habitualmente en formato JSON).@RequestMapping(“/api/v1/books”): Define la ruta base para todos los endpoints contenidos en esa clase.@GetMapping: Asocia el método a peticiones GET (lectura).@PostMapping: Asocia el método a peticiones POST (creación).@PutMapping: Asocia el método a peticiones PUT (actualización completa).@DeleteMapping: Asocia el método a peticiones DELETE (eliminación).@PathVariable: Extrae valores incrustados en la propia URL (ejemplo: /api/v1/books/{id}).@RequestParam: Extrae parámetros de consulta pasados tras el símbolo ? (ejemplo: /api/v1/books?page=2).@RequestBody: Deserializa automáticamente el cuerpo JSON de la petición entrante en un objeto Java (Record / DTO).
En el diseño de APIs REST, el controlador debe devolver tanto los datos formateados (DTOs) como el código de estado HTTP correspondiente. Para lograr un control explícito sobre la respuesta se utiliza la clase ResponseEntity<T>.
import com.fpmislata.bookstoreapi.controller.dto.BookCreateRequest;
import com.fpmislata.bookstoreapi.controller.dto.BookResponse;
import com.fpmislata.bookstoreapi.service.BookService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/books")
public class BookController {
private final BookService bookService;
// Inyección de dependencias por constructor realizada automáticamente por Spring
public BookController(BookService bookService) {
this.bookService = bookService;
}
// GET /api/v1/books -> 200 OK con el listado
@GetMapping
public ResponseEntity<List<BookResponse>> getAll() {
List<BookResponse> books = bookService.findAll();
return ResponseEntity.ok(books); // Estado 200 OK
}
// GET /api/v1/books/12 -> 200 OK si existe
@GetMapping("/{id}")
public ResponseEntity<BookResponse> getById(@PathVariable Long id) {
BookResponse book = bookService.findById(id);
return ResponseEntity.ok(book); // Estado 200 OK
}
// POST /api/v1/books -> 201 Created con el objeto creado
@PostMapping
public ResponseEntity<BookResponse> create(@RequestBody BookCreateRequest request) {
BookResponse createdBook = bookService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(createdBook); // Estado 201 Created
}
// DELETE /api/v1/books/12 -> 204 No Content
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
bookService.delete(id);
return ResponseEntity.noContent().build(); // Estado 204 No Content
}
}
Spring detecta y gestiona de forma automática el ciclo de vida de nuestras clases mediante el escaneo de anotaciones:
| Anotación | Capa de la Arquitectura | Descripción |
|---|---|---|
@RestController ''| Presentación | Expone endpoints REST y serializa las respuestas en JSON. |
| ''@Service | Negocio | Contiene la lógica de negocio del sistema. |
@Repository | Persistencia | Implementa el acceso a datos (DAOs / Repositorios). |
@Component | Genérica | Para componentes o utilidades transversales (Filtros, Mapeadores). |
Al aplicar @Service en la capa de negocio y @Repository en la capa de persistencia, el contenedor de IoC de Spring resuelve e inyecta las dependencias automáticamente a través del constructor del @RestController, eliminando por completo la necesidad de instanciarlas manualmente.
Los apartados anteriores muestran el happy path de la API: el Service encuentra los datos y el controlador devuelve la respuesta correcta. En un sistema real, el flujo puede fallar en cualquier capa (recurso inexistente, datos inválidos, error técnico) y la respuesta HTTP debe reflejar ese fallo con el código de estado adecuado.
Si una excepción sale del método del controlador sin capturarse, el DispatcherServlet la intercepta y Spring Boot genera una respuesta de error genérica (la clásica Whitelabel Error Page) con estado 500 Internal Server Error, sin información útil para el cliente de la API.
La solución ingenua sería envolver cada llamada al Service en bloques try-catch dentro del @RestController. Es un antipatrón: duplica código en todos los endpoints y mezcla la responsabilidad de presentación con la gestión de errores.
Como se explicó en el tema 05, la arquitectura moderna crea excepciones personalizadas no verificadas (RuntimeException) y las lanza desde las capas inferiores. La excepción interrumpe el flujo normal y vuela hasta un único punto donde se traduce a una respuesta HTTP.
Spring Boot centraliza esa traducción en una clase anotada con @RestControllerAdvice (variante de @ControllerAdvice que serializa directamente la respuesta en JSON), registrada como bean junto al resto de componentes del contenedor de IoC. Sus métodos, anotados con @ExceptionHandler, se invocan automáticamente cuando una excepción escapa de cualquier controlador:
@ExceptionHandler(TipoExcepcion.class): Declara el tipo de excepción que captura cada método.BookNotFoundException activa su propio manejador y no el genérico de Exception.ResponseEntity con el estado y el cuerpo adecuados, igual que haría el propio controlador.La capa de negocio lanza la excepción de dominio cuando el recurso no existe:
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(Long id) {
super("No existe el libro con id " + id);
}
}
// En el Service: la excepción interrumpe el flujo y "vuela" hasta el manejador global
public BookResponse findById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}
El cuerpo de la respuesta de error se modela como un record:
public record ApiErrorResponse(String message) {
}
Y el manejador centraliza la traducción de excepciones a respuestas HTTP:
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
// GET /api/v1/books/99 con un id inexistente -> 404 Not Found
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<ApiErrorResponse> handleNotFound(BookNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiErrorResponse(e.getMessage()));
}
// Datos de entrada inválidos -> 400 Bad Request
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiErrorResponse> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(new ApiErrorResponse(e.getMessage()));
}
// Captura final: cualquier error no previsto -> mensaje genérico
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleUnexpected(Exception e) {
return ResponseEntity.internalServerError().body(new ApiErrorResponse("Error interno del servidor"));
}
}
@ExceptionHandler(Exception.class)) y devolviendo un mensaje genérico (“Error interno del servidor”) con estado 500; los detalles reales quedan en el log del servidor.
Correspondencia entre excepciones y respuestas:
| Excepción | Estado HTTP | Origen |
|---|---|---|
BookNotFoundException | 404 Not Found | Negocio: recurso inexistente |
IllegalArgumentException | 400 Bad Request | Negocio: datos de entrada inválidos |
Exception | 500 Internal Server Error | Cualquier error técnico no previsto |
Además de las excepciones de dominio, Spring Boot lanza las suyas propias durante el procesamiento de la petición: HttpMessageNotReadableException cuando el cuerpo JSON no se puede deserializar, MethodArgumentNotValidException cuando falla la validación de los datos de entrada, etc. Se tratan igual que las nuestras: declarando en el manejador un @ExceptionHandler para cada tipo. Su tratamiento detallado se aborda al presentar la validación de datos.
En apartados anteriores ya hemos mostrado información por consola a mano (IO.printf en el filtro de auditoría). Para una aplicación real, la consola se queda corta: los mensajes no se guardan, se mezclan con la salida del propio framework y todos se ven igual por importante o trivial que sea. Un sistema de logs (o sistema de registro) resuelve esto: la aplicación emite eventos de log de forma uniforme —qué ocurre, cuándo y con qué severidad— y es la configuración, no el código, la que decide qué se registra y dónde.
Cada suceso que la aplicación registra de forma intencionada es un evento de log. Todos los eventos comparten una estructura mínima:
| Componente | Descripción |
|---|---|
| Nivel | La severidad del suceso: permite filtrar o destacar cada mensaje sin tocar el código |
| Marca de tiempo | Cuándo se produjo; ordena los sucesos de una ejecución |
| Origen | El componente que lo emitió (la clase propietaria del logger) |
| Mensaje | La descripción del suceso, con el contexto que lo identifica |
El nivel es la pieza central del sistema. De más a menos severidad:
| Nivel | Uso |
|---|---|
ERROR | Fallo que impide completar la operación (error técnico no previsto) |
WARN | Incidencia recuperable o sospechosa (dato degradado, reintento) |
INFO | Suceso normal relevante: arranque, petición atendida, operación de negocio |
DEBUG | Detalle de diagnóstico con el que depurar en desarrollo |
TRACE | Información aún más minuciosa; solo se activa para casos muy concretos |
En el ecosistema Java el registro se divide en dos piezas que se combinan:
java.util.logging.El código solo conoce la fachada; la implementación se cambia por configuración, sin retocar ninguna clase. Es el mismo principio que ya aplicamos al programar contra interfaces.
| Pieza | Papel | En Spring Boot |
|---|---|---|
| SLF4J | Fachada: la API con la que escribe el código | Incluida por defecto |
| Logback | Implementación por defecto | Incluida por defecto |
| Log4j2 | Implementación alternativa | Sustituible por configuración |
Cada clase declara su logger y emite sus eventos con el nivel que les corresponde; el nombre del logger —por defecto la propia clase— identifica el origen. El manejador global del apartado 7 es el punto idóneo: al cliente le llega el mensaje genérico y la traza completa queda registrada con nivel ERROR:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// Captura final: cualquier error no previsto -> mensaje genérico al cliente
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleUnexpected(Exception e) {
log.error("Error técnico no controlado", e); // la traza completa queda en el log
return ResponseEntity.internalServerError().body(new ApiErrorResponse("Error interno del servidor"));
}
}
Esto completa la nota del apartado 7: el detalle técnico (trazas, SQL, clases) nunca viaja al cliente, pero sí queda en el log del servidor para diagnosticar después.
Los mensajes de SLF4J usan marcadores {} en lugar de concatenación: el texto se formatea solo si el nivel está activo, lo que evita construir cadenas para eventos que no se van a registrar. El filtro de auditoría del apartado 3 pasa de imprimir por consola a registrar con nivel:
log.info("{} {} -> {} ({} ms)", request.getMethod(), request.getRequestURI(), response.getStatus(), duration);
En application.properties se declara el nivel por paquete. Los paquetes del framework son muy verbosos, así que se recortan para que sus mensajes no oculten los nuestros:
# Nivel raíz y ajuste por paquete logging.level.root=INFO logging.level.org.springframework=WARN logging.level.com.fpmislata.bookstoreapi=DEBUG
El mismo código muestra más o menos detalle según la configuración: el paquete del proyecto en DEBUG para el desarrollo, INFO en producción, sin volver a compilar. También con propiedades se añade una salida a fichero:
# Registro a fichero además de en consola logging.file.name=logs/bookstore.log
Cuando la aplicación se despliega fuera de tu equipo, este registro se convierte en la principal ventana de diagnóstico: guardar el log en producción y correlacionar cada mensaje con la petición que lo originó es el siguiente paso del ciclo, ya en 2.º DAW.