Llevas un tiempo aprendiendo Java y tienes claro que quieres hacer backend. Buscas "cómo crear una API REST con Java" y te aparece Spring Boot en todos lados. Lo instalas, sigues un tutorial, copias el código... y no entiendes qué está pasando. Eso no sirve de nada.
Este tutorial es distinto. Vas a construir una API real de gestión de tareas, pero antes de cada bloque de código vas a entender por qué existe ese código y qué hace exactamente. Sin eso, en cuanto cambies algo se rompe y no sabes dónde mirar.
Qué vas a construir
- Una API REST completa — los endpoints que un frontend consumiría
- Base de datos H2 integrada (arranca con la app, sin instalar nada aparte)
- CRUD completo: crear, leer, actualizar y eliminar tareas
- Validaciones que devuelven mensajes de error claros
- Manejo centralizado de errores
- Tests unitarios básicos
Lo que necesitas antes de empezar
- Java 21 instalado — compruébalo en la terminal con
java -version - IntelliJ IDEA (recomendado) o VS Code con el Extension Pack for Java
- Postman o Thunder Client — para probar los endpoints cuando los tengas
- Entender clases, métodos y herencia básica en Java — si no, empieza por el tutorial de Java para principiantes
Qué es Spring Boot y por qué existe
Antes de Spring Boot, montar un servidor web con Java implicaba configurar a mano el servidor Tomcat, declarar todos los componentes en archivos XML, gestionar las versiones de cada librería para que no chocaran entre sí... y eso antes de escribir una sola línea de tu lógica. Era fácil pasarse días en la configuración y no llegar al código real.
Spring Boot resuelve eso con un principio llamado autoconfiguración: detecta qué librerías tienes en el proyecto y configura todo automáticamente con valores sensatos. Si tienes la librería de base de datos H2, Spring Boot levanta una base de datos en memoria sin que le digas nada. Si tienes Spring Web, arranca un servidor Tomcat integrado. Tú solo escribes la lógica.
Importante: Spring Boot no es un framework diferente a Spring. Es Spring con la configuración automática encima. Todo lo que funciona en Spring funciona igual en Spring Boot.
Paso 1: Crear el proyecto
Para crear un proyecto Spring Boot no empiezas con un archivo en blanco. Existe una herramienta oficial llamada Spring Initializr en start.spring.io que genera el esqueleto completo del proyecto con todas las dependencias configuradas.
Antes de rellenar el formulario, conviene entender qué significa cada campo:
- Project: Maven — Maven es la herramienta que gestiona las dependencias y compila el proyecto. Es como npm para JavaScript. Existe Gradle como alternativa, pero Maven es lo que más vas a ver en empresas con proyectos Java.
- Group — es el identificador de tu organización en formato de dominio invertido. Si tu empresa es
studycodepro.com, el grupo escom.studycodepro. Para proyectos personales puedes usarcom.tuapellidoo lo que quieras. - Artifact — el nombre de tu proyecto. Se convierte en el nombre del JAR que se genera al compilar.
- Packaging: Jar — el resultado de compilar será un archivo
.jarque incluye el servidor Tomcat dentro. Puedes ejecutarlo en cualquier máquina que tenga Java con un simplejava -jar, sin instalar nada más.
Configura el formulario así:
Configuración en start.spring.io
- Project: Maven
- Language: Java
- Spring Boot: 3.5.14 (última estable de la rama 3.x)
- Group: com.studycodepro
- Artifact: taskmanager
- Package name: com.studycodepro.taskmanager
- Packaging: Jar
- Java: 21
Luego añade estas dependencias (hay un buscador en la parte derecha del formulario). Cada una tiene un propósito concreto:
- Spring Web — incluye el servidor Tomcat integrado y todo lo necesario para crear endpoints HTTP. Sin esto no hay API.
- Spring Data JPA — JPA es el estándar de Java para trabajar con bases de datos relacionales sin escribir SQL a mano. Spring Data JPA añade encima métodos automáticos para las consultas más comunes.
- H2 Database — una base de datos que vive en la memoria RAM de tu proceso. Arranca y se destruye con la aplicación. Perfecta para desarrollo porque no necesitas instalar MySQL ni PostgreSQL.
- Spring Boot DevTools — recarga la aplicación automáticamente cuando guardas un archivo. Ahorra tiempo durante el desarrollo.
- Validation — permite validar los datos que llegan en las peticiones HTTP (que el título no esté vacío, que el email tenga formato correcto, etc.) con simples anotaciones.
- Lombok — genera automáticamente los getters, setters, constructores y más a partir de anotaciones. Elimina decenas de líneas de código repetitivo.
Pulsa Generate, descarga el ZIP, descomprímelo y ábrelo en tu IDE.
Al abrirlo verás esta estructura de carpetas:
taskmanager/
├── src/
│ ├── main/
│ │ ├── java/com/studycodepro/taskmanager/
│ │ │ └── TaskmanagerApplication.java ← punto de entrada
│ │ └── resources/
│ │ └── application.properties ← configuración
│ └── test/
│ └── java/com/studycodepro/taskmanager/
├── pom.xml ← dependencias Maven
└── mvnw / mvnw.cmd ← Maven Wrapper
El archivo mvnw (Maven Wrapper) es importante: es un script que descarga automáticamente la versión correcta de Maven si no la tienes instalada. Siempre usa ./mvnw en lugar de mvn para garantizar que se usa la versión que el proyecto espera.
Paso 2: Primer endpoint y entender las anotaciones
Antes de tocar la base de datos vamos a arrancar el servidor y hacer que responda a una petición HTTP. Esto te confirmará que el proyecto está bien configurado y te permite entender cómo funciona el sistema de anotaciones de Spring Boot.
Crea una carpeta controller dentro de com/studycodepro/taskmanager/ y dentro crea este archivo:
package com.studycodepro.taskmanager.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "¡Hola desde Spring Boot!";
}
}
Dos anotaciones nuevas que necesitas entender bien porque las verás en cada controller:
- @RestController — le dice a Spring que esta clase va a manejar peticiones HTTP y que el valor que devuelven sus métodos debe enviarse directamente como cuerpo de la respuesta (en JSON o texto plano). Sin esta anotación, Spring no sabe que esta clase existe como controlador web.
- @GetMapping("/hello") — asocia el método
hello()a las peticiones GET que lleguen a la URL/hello. Cuando alguien haceGET http://localhost:8080/hello, Spring ejecuta este método y devuelve lo que retorna.
Para arrancar la aplicación, ejecuta en la terminal desde la raíz del proyecto:
# En Mac/Linux
./mvnw spring-boot:run
# En Windows
mvnw.cmd spring-boot:run
Verás en la consola una línea parecida a Started TaskmanagerApplication in 2.3 seconds. Eso significa que el servidor está corriendo. Abre el navegador y ve a http://localhost:8080/hello. Deberías ver el texto de respuesta.
Si ves un error en la consola en lugar de ese mensaje, casi siempre es porque el puerto 8080 está ocupado por otra aplicación. Puedes cambiarlo en application.properties con server.port=8081.
Paso 3: El modelo de datos — qué es una Entity
Hasta ahora el servidor responde a peticiones pero no guarda nada. Para eso necesitamos una base de datos y un modelo que represente los datos que vamos a almacenar.
En Spring Boot el puente entre tus clases Java y las tablas de la base de datos se llama JPA (Java Persistence API). La idea es simple: tú defines una clase Java normal con sus campos, le añades ciertas anotaciones, y JPA crea automáticamente la tabla en la base de datos y se encarga de las operaciones SQL. No escribes INSERT INTO ni SELECT * FROM a mano.
Una clase marcada con @Entity se llama entidad y representa exactamente una tabla. Cada instancia de esa clase es una fila de esa tabla. Cada campo de la clase es una columna.
Antes de escribir la entidad, configura Lombok en el IDE o verás errores aunque el código sea correcto:
⚠️ Lombok necesita el plugin en el IDE
En IntelliJ IDEA: File → Settings → Plugins, busca "Lombok" e instálalo. También activa Enable annotation processing en Settings → Build → Compiler → Annotation Processors. Sin esto el IDE subraya en rojo métodos que sí existen — los genera Lombok en tiempo de compilación, pero el IDE no los ve sin el plugin.
Lombok elimina el código repetitivo que Java obliga a escribir. Sin él, una clase con 5 campos implica escribir 5 getters, 5 setters, un constructor vacío, un constructor con parámetros, equals, hashCode y toString — fácilmente 80 líneas que no aportan nada. Con Lombok son 5 anotaciones:
Las anotaciones Lombok que usaremos
- @Data — genera todos los getters, setters,
equals,hashCodeytoString - @NoArgsConstructor — constructor vacío sin parámetros. JPA lo exige obligatoriamente para poder crear instancias al leer filas de la base de datos
- @AllArgsConstructor — constructor con todos los campos como parámetros
- @Builder — permite crear objetos con una sintaxis más legible:
Task.builder().title("Mi tarea").build() - @RequiredArgsConstructor — constructor solo con los campos
final. Lo usaremos en el Service y el Controller para inyección de dependencias
Crea la carpeta model dentro de tu paquete y escribe la entidad Task:
package com.studycodepro.taskmanager.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
@Entity
@Table(name = "tasks")
@Data // getters + setters + equals + hashCode + toString
@NoArgsConstructor // constructor vacío — obligatorio para JPA
@AllArgsConstructor // constructor con todos los campos
@Builder // Task.builder().title("...").build()
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "El título es obligatorio")
@Size(min = 3, max = 100, message = "El título debe tener entre 3 y 100 caracteres")
@Column(nullable = false)
private String title;
@Size(max = 500, message = "La descripción no puede exceder 500 caracteres")
private String description;
@Builder.Default
@Column(name = "is_completed")
private boolean completed = false;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
🎯 Anotaciones importantes:
- @Entity: Le dice a JPA que esta clase es una tabla
- @Id: Campo clave primaria
- @GeneratedValue: Auto-incremento
- @NotBlank/@Size: Validaciones automáticas
- @Column: Personalizar nombre de columna
Paso 4: El Repository — cómo hablas con la base de datos
Ya tienes la entidad que define la estructura de los datos. Ahora necesitas una forma de guardarlos, buscarlos y borrarlos. En Java clásico escribirías las queries SQL a mano. Con Spring Data JPA no hace falta.
El Repository es una interfaz — no una clase, una interfaz — que extiende de JpaRepository. Con eso solo ya tienes gratis los métodos más comunes: save(), findAll(), findById(), deleteById()... Spring genera la implementación en tiempo de arranque, tú no escribes ni una línea de SQL.
El truco más útil es que puedes añadir métodos siguiendo una convención de nombres y Spring entiende qué query debe generar. Se llaman derived query methods: Spring lee el nombre del método, lo descompone en partes y construye el SQL. Por ejemplo, findByCompleted(true) se traduce a SELECT * FROM tasks WHERE is_completed = true. findByTitleContainingIgnoreCase("spring") se convierte en un LIKE sin importar mayúsculas.
Crea la carpeta repository y añade:
package com.studycodepro.taskmanager.repository;
import com.studycodepro.taskmanager.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface TaskRepository extends JpaRepository<Task, Long> {
// ✅ Spring genera automáticamente estos métodos por el nombre
List<Task> findByCompleted(boolean completed);
List<Task> findByTitleContainingIgnoreCase(String title);
long countByCompleted(boolean completed);
// 🎯 Métodos que ya tienes gratis por heredar de JpaRepository:
// save(task) - Crear/actualizar
// findAll() - Obtener todas
// findById(id) - Buscar por ID
// deleteById(id) - Eliminar por ID
// count() - Contar total
}
Fíjate en JpaRepository<Task, Long>: los dos tipos entre los ángulos son la entidad que gestiona este repositorio y el tipo del campo @Id. Si el id fuera un String en lugar de Long, aquí pondría String.
Paso 5: El Service — dónde va la lógica real
El Repository sabe hablar con la base de datos. El Controller sabe recibir peticiones HTTP. Pero entonces, ¿dónde va la lógica de tu aplicación — las reglas de negocio, las comprobaciones, las transformaciones?
En el Service. Y no es capricho: hay una razón práctica. Si metes la lógica en el Controller, en cuanto quieras reutilizarla desde otro sitio (otro endpoint, un job programado, un test) tienes un problema. El Service es una clase independiente que cualquiera puede llamar sin saber nada de HTTP.
Esta separación en tres capas (Controller → Service → Repository) es el patrón estándar en cualquier proyecto Spring Boot. Cada capa tiene una responsabilidad única y no se mete en la de las demás. Aquí es donde la mayoría de principiantes se atasca al principio — parece burocracia pero a las dos semanas te das cuenta de por qué existe.
Crea la carpeta service y añade:
package com.studycodepro.taskmanager.service;
import com.studycodepro.taskmanager.model.Task;
import com.studycodepro.taskmanager.repository.TaskRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class TaskService {
private final TaskRepository taskRepository;
// Obtener todas las tareas
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
// Buscar tarea por ID
public Optional<Task> getTaskById(Long id) {
return taskRepository.findById(id);
}
// Crear nueva tarea — @CreationTimestamp en la Entity se encarga del timestamp
public Task createTask(Task task) {
return taskRepository.save(task);
}
// Actualizar tarea existente — @UpdateTimestamp actualiza automáticamente
public Optional<Task> updateTask(Long id, Task taskDetails) {
return taskRepository.findById(id)
.map(task -> {
task.setTitle(taskDetails.getTitle());
task.setDescription(taskDetails.getDescription());
task.setCompleted(taskDetails.isCompleted());
return taskRepository.save(task);
});
}
// Eliminar tarea
public boolean deleteTask(Long id) {
if (taskRepository.existsById(id)) {
taskRepository.deleteById(id);
return true;
}
return false;
}
public List<Task> getCompletedTasks() {
return taskRepository.findByCompleted(true);
}
public List<Task> getPendingTasks() {
return taskRepository.findByCompleted(false);
}
public List<Task> searchTasks(String title) {
return taskRepository.findByTitleContainingIgnoreCase(title);
}
}
Constructor injection con Lombok:
@RequiredArgsConstructor genera un constructor con todos los campos final. Spring ve ese constructor y sabe que tiene que inyectar el TaskRepository ahí. No necesitas @Autowired ni hacer new TaskRepository().
El campo final también te da una ventaja: si te olvidas de inicializarlo, el compilador te avisa. Con @Autowired en campo el error aparece en tiempo de ejecución.
Paso 6: El Controller — los endpoints de la API
El Controller es la capa más externa: recibe las peticiones HTTP, llama al Service para que haga el trabajo, y devuelve la respuesta al cliente. No tiene lógica propia — si te encuentras escribiendo condiciones de negocio aquí, es señal de que ese código debería estar en el Service.
Cada método del controller corresponde a un endpoint. Las anotaciones definen la URL y el verbo HTTP:
- @GetMapping — para peticiones GET (obtener datos)
- @PostMapping — para peticiones POST (crear)
- @PutMapping — para peticiones PUT (actualizar completo)
- @DeleteMapping — para peticiones DELETE (borrar)
ResponseEntity es el tipo de retorno que te da control total sobre la respuesta HTTP: el cuerpo, el código de estado (200, 201, 404...) y los headers si los necesitas. Un método que devuelve ResponseEntity.ok(lista) envía un 200 con la lista en JSON. Uno que devuelve ResponseEntity.notFound().build() envía un 404 vacío.
Crea la clase en la carpeta controller:
package com.studycodepro.taskmanager.controller;
import com.studycodepro.taskmanager.model.Task;
import com.studycodepro.taskmanager.service.TaskService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
@RestController
@RequestMapping("/api/tasks")
@CrossOrigin(origins = "*") // Solo para desarrollo local — en producción especifica el dominio real
@RequiredArgsConstructor
public class TaskController {
private final TaskService taskService;
// 📋 GET /api/tasks - Obtener todas las tareas
@GetMapping
public ResponseEntity<List<Task>> getAllTasks() {
List<Task> tasks = taskService.getAllTasks();
return ResponseEntity.ok(tasks);
}
// 🔍 GET /api/tasks/{id} - Obtener tarea por ID
@GetMapping("/{id}")
public ResponseEntity<Task> getTaskById(@PathVariable Long id) {
return taskService.getTaskById(id)
.map(task -> ResponseEntity.ok(task))
.orElse(ResponseEntity.notFound().build());
}
// ➕ POST /api/tasks - Crear nueva tarea
@PostMapping
public ResponseEntity<Task> createTask(@Valid @RequestBody Task task) {
Task createdTask = taskService.createTask(task);
return ResponseEntity.status(HttpStatus.CREATED).body(createdTask);
}
// ✏️ PUT /api/tasks/{id} - Actualizar tarea
@PutMapping("/{id}")
public ResponseEntity<Task> updateTask(@PathVariable Long id,
@Valid @RequestBody Task taskDetails) {
return taskService.updateTask(id, taskDetails)
.map(task -> ResponseEntity.ok(task))
.orElse(ResponseEntity.notFound().build());
}
// 🗑️ DELETE /api/tasks/{id} - Eliminar tarea
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, String>> deleteTask(@PathVariable Long id) {
Map<String, String> response = new HashMap<>();
if (taskService.deleteTask(id)) {
response.put("message", "Tarea eliminada correctamente");
return ResponseEntity.ok(response);
} else {
response.put("error", "Tarea no encontrada");
return ResponseEntity.notFound().build();
}
}
// 📊 GET /api/tasks/completed - Tareas completadas
@GetMapping("/completed")
public ResponseEntity<List<Task>> getCompletedTasks() {
return ResponseEntity.ok(taskService.getCompletedTasks());
}
// ⏳ GET /api/tasks/pending - Tareas pendientes
@GetMapping("/pending")
public ResponseEntity<List<Task>> getPendingTasks() {
return ResponseEntity.ok(taskService.getPendingTasks());
}
// 🔍 GET /api/tasks/search?title=algo - Buscar tareas
@GetMapping("/search")
public ResponseEntity<List<Task>> searchTasks(@RequestParam String title) {
return ResponseEntity.ok(taskService.searchTasks(title));
}
}
⚠️ Sobre @CrossOrigin(origins = "*")
Permitir cualquier origen es útil para desarrollo local, pero en producción es un riesgo de seguridad. Cuando despliegues, cambia "*" por el dominio real de tu frontend: @CrossOrigin(origins = "https://tu-dominio.com")
Paso 7: Configurar la base de datos H2
Spring Boot ya detecta que tienes H2 en el proyecto y levanta una base de datos en memoria sin que hagas nada. Pero hay cosas que conviene configurar: dónde está la consola web para inspeccionarla, qué hace Hibernate con las tablas al arrancar, y que muestre las queries SQL en el log para que puedas ver exactamente qué está ejecutando.
Abre src/main/resources/application.properties y añade:
# Puerto del servidor
server.port=8080
# Configuración de base de datos H2
spring.datasource.url=jdbc:h2:mem:taskdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA/Hibernate
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# H2 Console (para ver la base de datos en el navegador)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
Tres cosas que vale la pena entender de esa configuración:
jdbc:h2:mem:taskdb—memsignifica en memoria. La base de datos vive en la RAM del proceso y desaparece cuando paras el servidor. Los datos no se guardan entre reinicios. Para desarrollo es perfecto, arrancas siempre con un estado limpio.ddl-auto=create-drop— Hibernate crea las tablas al arrancar basándose en tus entidades y las elimina al parar. Cómodo para desarrollo. Nunca uses este valor en producción o perderás todos los datos cada vez que reinicies el servidor.show-sql=true— imprime en la consola cada query SQL que ejecuta Hibernate. Al principio es muy útil para entender qué está pasando. En producción lo deberías desactivar.h2-console.enabled=true— activa una interfaz web enhttp://localhost:8080/h2-consoledonde puedes ver las tablas y ejecutar queries directamente. Útil para verificar que los datos se están guardando bien.
⚠️ create-drop en producción borra todos tus datos
Es el error más doloroso que comete quien aprende Spring Boot. Funciona perfecto en desarrollo — llegas al lunes y la base está limpia. Pero en producción cada reinicio del servidor destruye todos los datos. Cuando pases a una base de datos real usa validate (comprueba que las tablas existen pero no las toca) o none (no hace nada, tú gestionas el esquema).
Variables de entorno: cómo proteger las credenciales
H2 no tiene contraseña real, así que ahora mismo no hay problema. Pero cuando pases a MySQL o PostgreSQL tendrás usuario y contraseña de base de datos, y posiblemente claves de API. Si los escribes directamente en application.properties y subes el proyecto a GitHub, esas credenciales son públicas para siempre — incluso si luego borras el archivo, siguen en el historial de Git.
Spring Boot lee variables de entorno del sistema operativo directamente. La sintaxis ${NOMBRE_VARIABLE:valor_por_defecto} dice: "lee esta variable de entorno, y si no existe usa este valor por defecto". Así el proyecto funciona en local sin configurar nada y en producción coge las credenciales reales del entorno del servidor:
# ❌ NUNCA hagas esto en un proyecto real
spring.datasource.username=root
spring.datasource.password=miPasswordReal123
# ✅ Así se hace: lee la variable de entorno, con valor por defecto para desarrollo
spring.datasource.url=${DB_URL:jdbc:h2:mem:taskdb}
spring.datasource.username=${DB_USERNAME:sa}
spring.datasource.password=${DB_PASSWORD:}
Para desarrollo local crea un archivo .env en la raíz del proyecto con los valores reales. Este archivo nunca se sube a Git:
DB_URL=jdbc:postgresql://localhost:5432/taskdb
DB_USERNAME=postgres
DB_PASSWORD=tu_password_aqui
DB_URL=jdbc:postgresql://localhost:5432/taskdb
DB_USERNAME=
DB_PASSWORD=
# Variables de entorno — NUNCA subir
.env
.env.local
.env.production
# Maven
target/
# IntelliJ
.idea/
*.iml
Spring Initializr ya genera un .gitignore con target/ incluido, pero no incluye .env por defecto — añádelo tú a mano antes de hacer el primer commit.
Paso 8: Probar la API
Reinicia la aplicación y comprueba que los endpoints responden correctamente. Para esto necesitas Postman o Thunder Client (extensión de VS Code) — el navegador solo sirve para peticiones GET, no puedes enviar JSON con él.
Los endpoints disponibles son:
# 1. Crear una tarea nueva (POST)
curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Aprender Spring Boot","description":"Completar tutorial completo"}'
# 2. Obtener todas las tareas (GET)
curl http://localhost:8080/api/tasks
# 3. Actualizar tarea (PUT)
curl -X PUT http://localhost:8080/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"title":"Aprender Spring Boot","description":"¡Tutorial completado!","completed":true}'
# 4. Eliminar tarea (DELETE)
curl -X DELETE http://localhost:8080/api/tasks/1
🔍 Explorar la base de datos:
Ve a http://localhost:8080/h2-console y conéctate con:
- JDBC URL: jdbc:h2:mem:taskdb
- User: sa
- Password: (vacío)
Paso 9: Manejo centralizado de errores
Ahora mismo si envías un título vacío en el POST, la validación falla pero la respuesta que devuelve Spring es un JSON de error genérico con un stack trace enorme — no muy útil para quien consume la API.
La solución es un @RestControllerAdvice: una clase especial que intercepta las excepciones antes de que lleguen al cliente y las convierte en respuestas JSON limpias con el mensaje correcto. Solo la defines una vez y funciona para todos los controllers del proyecto.
Crea la carpeta exception y añade:
package com.studycodepro.taskmanager.exception;
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.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationExceptions(
MethodArgumentNotValidException ex) {
Map<String, Object> errorResponse = new HashMap<>();
Map<String, String> errors = new HashMap<>();
// Recopilar todos los errores de validación
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.BAD_REQUEST.value());
errorResponse.put("error", "Validation Failed");
errorResponse.put("message", "Los datos enviados no son válidos");
errorResponse.put("errors", errors);
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}
// RuntimeException — errores de lógica de tu aplicación
// No usar Exception.class: capturaría errores internos de Spring que él ya sabe manejar
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Map<String, Object>> handleGlobalException(RuntimeException ex) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("timestamp", LocalDateTime.now());
errorResponse.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
errorResponse.put("error", "Internal Server Error");
errorResponse.put("message", "Ocurrió un error interno en el servidor");
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
Paso 10: Compilar y ejecutar el JAR
Hasta ahora has ejecutado la aplicación con ./mvnw spring-boot:run, que es útil durante el desarrollo porque recompila al vuelo. Pero para desplegarla en un servidor real necesitas generar un JAR — un archivo ejecutable que incluye tu código, todas las dependencias y el servidor Tomcat integrado.
La ventaja del JAR es que cualquier máquina con Java instalado puede ejecutarlo directamente sin instalar Maven, Tomcat ni nada más:
# Compilar la aplicación
./mvnw clean package
# Ejecutar el JAR generado
java -jar target/taskmanager-0.0.1-SNAPSHOT.jar
# Ejecutar con perfil de producción
java -jar -Dspring.profiles.active=prod target/taskmanager-0.0.1-SNAPSHOT.jar
Paso 11: Tests — cómo verificar que funciona sin probarlo a mano
Probar la API con Postman cada vez que cambias algo es tedioso y fácil de olvidar. Los tests automatizados ejecutan esas comprobaciones por ti en segundos, y si algo se rompe te avisan antes de que llegue a producción.
Spring Initializr ya añade JUnit 5 y Mockito al proyecto, no necesitas instalar nada. Los tests van en src/test/java/, en el mismo package que la clase que pruebas.
Hay dos tipos habituales. Los tests unitarios prueban una sola clase en aislamiento, sin arrancar Spring — van en milisegundos. Los tests de integración con @SpringBootTest arrancan el contexto completo con base de datos real — más lentos pero comprueban que todo encaja.
Empieza por tests unitarios del Service, que es donde vive la lógica. Para no tocar la base de datos usamos Mockito, que crea una versión falsa del Repository y nos permite controlar qué devuelve:
package com.studycodepro.taskmanager.service;
import com.studycodepro.taskmanager.model.Task;
import com.studycodepro.taskmanager.repository.TaskRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TaskServiceTest {
@Mock
private TaskRepository taskRepository;
@InjectMocks
private TaskService taskService;
@Test
void getAllTasks_devuelveLista() {
Task task = Task.builder().title("Aprender Spring Boot").build();
when(taskRepository.findAll()).thenReturn(List.of(task));
List<Task> resultado = taskService.getAllTasks();
assertThat(resultado).hasSize(1);
assertThat(resultado.get(0).getTitle()).isEqualTo("Aprender Spring Boot");
}
@Test
void createTask_guardaYDevuelve() {
Task task = Task.builder().title("Nueva tarea").build();
when(taskRepository.save(task)).thenReturn(task);
Task resultado = taskService.createTask(task);
assertThat(resultado.getTitle()).isEqualTo("Nueva tarea");
verify(taskRepository).save(task);
}
@Test
void deleteTask_existente_devuelveTrue() {
when(taskRepository.existsById(1L)).thenReturn(true);
boolean resultado = taskService.deleteTask(1L);
assertThat(resultado).isTrue();
verify(taskRepository).deleteById(1L);
}
@Test
void deleteTask_noExiste_devuelveFalse() {
when(taskRepository.existsById(99L)).thenReturn(false);
boolean resultado = taskService.deleteTask(99L);
assertThat(resultado).isFalse();
}
}
Qué hace cada pieza:
- @ExtendWith(MockitoExtension.class) — activa Mockito sin arrancar Spring. El test va en milisegundos.
- @Mock — crea una versión falsa del repositorio que no toca ninguna base de datos real.
- @InjectMocks — crea el
TaskServiceinyectando los mocks como si fuera Spring. - when(...).thenReturn(...) — define qué devuelve el mock cuando se llama a ese método.
- verify(...) — comprueba que el método se llamó realmente.
Para ejecutar los tests:
# Todos los tests
./mvnw test
# Solo una clase
./mvnw test -Dtest=TaskServiceTest
Un error que aparece seguido al empezar con tests
Si el test falla con NullPointerException en el service, casi siempre es porque Mockito no inyectó el mock. Comprueba que tienes @ExtendWith(MockitoExtension.class) en la clase y que el campo del service usa @InjectMocks, no new TaskService().
Lo que tienes ahora
Si has seguido el tutorial hasta aquí tienes una API REST funcional con arquitectura en capas (Controller → Service → Repository), base de datos H2 integrada, validaciones automáticas y manejo de errores centralizado. Eso es lo que usa cualquier proyecto Spring Boot real — en pequeño, pero con la misma estructura.
Siguiente paso concreto
La base de datos H2 se borra cada vez que reinicias el servidor. Para que los datos persistan cambia la URL a jdbc:h2:file:./taskdb en lugar de jdbc:h2:mem:taskdb. Ese es el primer problema real que vas a encontrar cuando lo uses de verdad.
Si quieres seguir con Java
Antes de pasar a Spring Security o PostgreSQL, conviene tener sólidas las bases de POO en Java. Aquí es donde la mayoría tiene huecos sin saberlo:
👉 Java POO explicado con ejemplos del mundo realRefuerza tus bases de Java orientado a objetos con ejemplos prácticos