Spring Boot Tutorial: Crea tu primera aplicación web profesional paso a paso

Aprende Spring Boot desde cero y crea tu primera aplicación completa con API REST y base de datos. Sin configuración interminable, directo al código que funciona.

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:

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:

Pulsa Generate, descarga el ZIP, descomprímelo y ábrelo en tu IDE.

Al abrirlo verás esta estructura de carpetas:

Estructura del proyecto generado 📋
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:

src/main/java/.../controller/HelloController.java 📋
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:

Para arrancar la aplicación, ejecuta en la terminal desde la raíz del proyecto:

Terminal 📋
# 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, hashCode y toString
  • @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:

src/main/java/.../model/Task.java 📋
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:

src/main/java/.../repository/TaskRepository.java 📋
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:

src/main/java/.../service/TaskService.java 📋
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:

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:

src/main/java/.../controller/TaskController.java 📋
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:

src/main/resources/application.properties 📋
# 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:

⚠️ 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:

application.properties con variables de entorno 📋
# ❌ 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:

.env (nunca subir a Git) 📋
DB_URL=jdbc:postgresql://localhost:5432/taskdb
DB_USERNAME=postgres
DB_PASSWORD=tu_password_aqui
.env.example (este sí se sube — es la plantilla) 📋
DB_URL=jdbc:postgresql://localhost:5432/taskdb
DB_USERNAME=
DB_PASSWORD=
.gitignore — asegúrate de que incluye esto 📋
# 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:

GET /api/tasks - Listar todas
POST /api/tasks - Crear nueva
PUT /api/tasks/{id} - Actualizar
DELETE /api/tasks/{id} - Eliminar
Ejemplos con curl/Postman 📋
# 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:

src/main/java/.../exception/GlobalExceptionHandler.java 📋
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:

Comandos de build y deploy 📋
# 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:

src/test/java/.../service/TaskServiceTest.java 📋
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 TaskService inyectando 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:

Ejecutar 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 real

Refuerza tus bases de Java orientado a objetos con ejemplos prácticos