Spring Boot: crea tu primera API REST paso a paso

Aprende Spring Boot 4 desde cero y crea una API REST completa con base de datos, validaciones y tests. 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 en JSON
  • 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: la última 4.1.x estable (evita las que llevan SNAPSHOT o M1 en el nombre)
  • Group: com.studycodepro
  • Artifact: taskmanager
  • Package name: com.studycodepro.taskmanager
  • Packaging: Jar
  • Java: 21

¿Por qué no te digo un número de versión exacto?

Spring Boot publica una versión menor cada seis meses y cada una recibe parches gratuitos durante unos 13 meses. La rama 3.5, que hasta hace poco era la que usaba todo el mundo, dejó de recibirlos en junio de 2026, y la 4.0 los dejará de recibir en diciembre de 2026. Por eso elige siempre la última versión estable que te ofrezca Initializr en el desplegable.

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
Estructura de carpetas del proyecto Spring Boot recién generado en IntelliJ IDEA
Así se ve el proyecto recién generado al abrirlo en IntelliJ IDEA

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. Usa siempre el wrapper 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 un package controller dentro de com/studycodepro/taskmanager/ y dentro crea la clase HelloController y pega dentro el siguiente código:

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 📋
# Windows (PowerShell)
.\mvnw.cmd spring-boot:run

# Mac / Linux
./mvnw spring-boot:run

⚠️ PowerShell no ejecuta comandos de la carpeta actual sin .\

Si usas la terminal integrada de IntelliJ o VS Code en Windows, seguramente es PowerShell por defecto, y ahí mvnw.cmd spring-boot:run a secas da CommandNotFoundException. Es una medida de seguridad de PowerShell, no un fallo del proyecto: escribe .\mvnw.cmd spring-boot:run con el .\ delante y funciona. En Símbolo del sistema (cmd.exe) o en Mac/Linux no hace falta.

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.

⚠️ Si el IDE subraya en rojo los métodos de Lombok

Lombok genera los getters, setters y constructores en tiempo de compilación, así que el IDE necesita saberlo. IntelliJ IDEA ya incluye el plugin de Lombok. Si aun así ves errores en rojo en métodos que sí existen, activa Enable annotation processing en Settings → Build, Execution, Deployment → Compiler → Annotation Processors. En VS Code instala la extensión "Lombok Annotations Support for VS Code".

Estas son las anotaciones Lombok que usaremos:

Las anotaciones Lombok que usaremos

  • @Getter / @Setter: generan los getters y setters de todos los campos.
  • @NoArgsConstructor: constructor vacío sin parámetros. JPA lo exige 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 la inyección de dependencias.

Fíjate en que no usamos @Data en la entidad. @Data genera equals y hashCode con todos los campos, y en entidades JPA suele dar problemas. Con @Getter y @Setter tienes lo que necesitas.

Crea un package model dentro de tu paquete y dentro crea la clase Task:

src/main/java/.../model/Task.java 📋
package com.studycodepro.taskmanager.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.time.LocalDateTime;

@Entity
@Table(name = "tasks")
@Getter
@Setter
@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;

    @Column(nullable = false, length = 100)
    private String title;

    @Column(length = 500)
    private String description;

    @Builder.Default
    @Column(name = "is_completed", nullable = false)
    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: marca el campo que es la clave primaria.
  • @GeneratedValue: la base de datos genera el valor (auto-incremento).
  • @Column: personaliza el nombre de la columna, su longitud o si admite nulos.
  • @CreationTimestamp / @UpdateTimestamp: Hibernate rellena las fechas automáticamente al crear y al actualizar.

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) 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. Si no tienes claro por qué esto es una interfaz y no una clase normal, repasa interfaces en Java antes de seguir.

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 un package repository y dentro crea la interfaz TaskRepository:

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 estas queries a partir del nombre del método
    List<Task> findByCompleted(boolean completed);

    List<Task> findByTitleContainingIgnoreCase(String title);

    // Ya tienes gratis, por heredar de JpaRepository:
    // save(task)      → crear o actualizar
    // findAll()       → obtener todas
    // findById(id)    → buscar por id
    // existsById(id)  → comprobar si existe
    // deleteById(id)  → eliminar por id
}

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 DTO (qué datos aceptas de fuera)

Ahora mismo tu API podría recibir directamente un objeto Task. No lo hagas. Si lo haces, quien llama a la API puede mandar también el id o las fechas createdAt y updatedAt, y tú los estarías aceptando sin querer. Es un fallo de seguridad clásico y muy fácil de evitar.

La solución es un DTO (Data Transfer Object): una clase pequeña que define exactamente qué campos aceptas en una petición. Con un record de Java son tres líneas, y de paso es donde ponemos las validaciones.

Crea un package dto y dentro crea el record TaskRequest:

src/main/java/.../dto/TaskRequest.java 📋
package com.studycodepro.taskmanager.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record TaskRequest(

        @NotBlank(message = "El título es obligatorio")
        @Size(min = 3, max = 100, message = "El título debe tener entre 3 y 100 caracteres")
        String title,

        @Size(max = 500, message = "La descripción no puede superar los 500 caracteres")
        String description,

        Boolean completed
) {}

completed es un Boolean (con mayúscula) a propósito: así puede llegar vacío. Si el cliente no lo envía, la tarea se crea como pendiente.

Paso 6: 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.

El Service es también donde decides qué pasa cuando algo va mal. Piensa en el caso de pedir una tarea con un id que no existe: taskRepository.findById(id) no lanza ningún error, te devuelve un Optional vacío. Si no haces nada con eso, en cuanto intentes usarlo como si hubiera una tarea, Java lanza un NoSuchElementException con un mensaje genérico en inglés que no le sirve a nadie que consuma tu API.

Para controlar eso se crea una excepción personalizada: una clase que extiende de RuntimeException y que lanzas tú mismo, con el mensaje que quieras, en el momento exacto en que detectas el problema. La ventaja es doble: el mensaje es claro desde el origen, y en el Paso 8 vas a poder capturar específicamente esta excepción (y no cualquier otra) para convertirla en una respuesta 404 limpia.

Crea un package exception y dentro la clase TaskNotFoundException:

src/main/java/.../exception/TaskNotFoundException.java 📋
package com.studycodepro.taskmanager.exception;

public class TaskNotFoundException extends RuntimeException {

    public TaskNotFoundException(Long id) {
        super("No existe ninguna tarea con id " + id);
    }
}

extends RuntimeException significa que no estás obligado a declarar ni capturar esta excepción en cada método que la use (a diferencia de una checked exception como IOException). El constructor recibe el id que no se encontró y se lo pasa a super(...), que es el constructor de RuntimeException: así el mensaje queda guardado dentro de la excepción y accesible luego con ex.getMessage(), como verás en el Paso 8.

Ahora el Service:

src/main/java/.../service/TaskService.java 📋
package com.studycodepro.taskmanager.service;

import com.studycodepro.taskmanager.dto.TaskRequest;
import com.studycodepro.taskmanager.exception.TaskNotFoundException;
import com.studycodepro.taskmanager.model.Task;
import com.studycodepro.taskmanager.repository.TaskRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class TaskService {

    private final TaskRepository taskRepository;

    // Todas las tareas, o solo las completadas / pendientes si se indica el filtro
    public List<Task> findAll(Boolean completed) {
        if (completed == null) {
            return taskRepository.findAll();
        }
        return taskRepository.findByCompleted(completed);
    }

    public Task findById(Long id) {
        return taskRepository.findById(id)
                .orElseThrow(() -> new TaskNotFoundException(id));
    }

    public List<Task> searchByTitle(String title) {
        return taskRepository.findByTitleContainingIgnoreCase(title);
    }

    // Las fechas las rellena Hibernate solo (@CreationTimestamp / @UpdateTimestamp)
    public Task create(TaskRequest request) {
        Task task = Task.builder()
                .title(request.title())
                .description(request.description())
                .completed(Boolean.TRUE.equals(request.completed()))
                .build();
        return taskRepository.save(task);
    }

    public Task update(Long id, TaskRequest request) {
        Task task = findById(id);
        task.setTitle(request.title());
        task.setDescription(request.description());
        if (request.completed() != null) {
            task.setCompleted(request.completed());
        }
        return taskRepository.save(task);
    }

    public void delete(Long id) {
        if (!taskRepository.existsById(id)) {
            throw new TaskNotFoundException(id);
        }
        taskRepository.deleteById(id);
    }
}

Constructor injection con Lombok

@RequiredArgsConstructor genera un constructor con todos los campos final. Spring ve ese constructor y sabe que tiene que inyectar ahí el TaskRepository. 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 el campo el error aparece en tiempo de ejecución.

Paso 7: 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:

Y @ResponseStatus fija el código HTTP cuando no es el 200 por defecto: un 201 al crear y un 204 (sin contenido) al borrar.

HelloController ya cumplió su función: confirmar que el servidor arranca y ver cómo funcionan las anotaciones básicas. Puedes borrarlo, no lo vas a necesitar más. Dentro del mismo package controller, crea la clase TaskController:

src/main/java/.../controller/TaskController.java 📋
package com.studycodepro.taskmanager.controller;

import com.studycodepro.taskmanager.dto.TaskRequest;
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.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/tasks")
@RequiredArgsConstructor
public class TaskController {

    private final TaskService taskService;

    // GET /api/tasks                → todas
    // GET /api/tasks?completed=true → solo las completadas
    @GetMapping
    public List<Task> getAll(@RequestParam(required = false) Boolean completed) {
        return taskService.findAll(completed);
    }

    // GET /api/tasks/search?title=spring
    @GetMapping("/search")
    public List<Task> search(@RequestParam String title) {
        return taskService.searchByTitle(title);
    }

    // GET /api/tasks/1
    @GetMapping("/{id}")
    public Task getById(@PathVariable Long id) {
        return taskService.findById(id);
    }

    // POST /api/tasks
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Task create(@Valid @RequestBody TaskRequest request) {
        return taskService.create(request);
    }

    // PUT /api/tasks/1
    @PutMapping("/{id}")
    public Task update(@PathVariable Long id, @Valid @RequestBody TaskRequest request) {
        return taskService.update(id, request);
    }

    // DELETE /api/tasks/1
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        taskService.delete(id);
    }
}

Algunas cosas que conviene entender de este controller:

Paso 8: manejo centralizado de errores

Ahora mismo hay dos casos que no devuelven una respuesta útil: pedir una tarea que no existe (lanza tu TaskNotFoundException) y enviar un título vacío (la validación falla). Queremos un 404 y un 400 con un JSON claro, no un error genérico.

La solución es un @RestControllerAdvice: una clase especial que intercepta las excepciones antes de que lleguen al cliente y las convierte en respuestas limpias. Se define una sola vez y funciona para todos los controllers. Usaremos ProblemDetail, el formato estándar de errores de Spring (basado en el RFC 9457).

Crea un package exception (si no lo has creado ya con TaskNotFoundException) y dentro crea la clase GlobalExceptionHandler:

src/main/java/.../exception/GlobalExceptionHandler.java 📋
package com.studycodepro.taskmanager.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(TaskNotFoundException.class)
    public ProblemDetail handleNotFound(TaskNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, "Los datos enviados no son válidos");

        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
        problem.setProperty("errors", errors);

        return problem;
    }
}

⚠️ Un error típico: capturar RuntimeException o Exception

Es tentador añadir un tercer handler para RuntimeException (o Exception) que devuelva un 500 genérico. No lo hagas. Muchos errores que Spring ya sabe convertir en un 400 (un JSON mal formado, un id que no es un número) heredan de RuntimeException, así que tu handler los interceptaría y los convertiría en un 500 engañoso, y además esconderías el error real. Captura solo las excepciones que tú sabes tratar y deja el resto a Spring.

Paso 9: configurar la base de datos con variables de entorno

Vamos a configurar la base de datos como se haría en un proyecto real: usuario, contraseña y URL fuera del código, en un archivo .env que nunca se sube a Git. Aquí usamos H2 para no instalar nada, pero los archivos son los mismos que con PostgreSQL. Cuando cambies de base de datos solo cambiarán los valores del .env.

1. Protege el .env antes de crearlo

Abre el .gitignore que genera Initializr y añade estas líneas. El orden importa: si creas el .env y haces git add . antes, tus credenciales quedan en el historial para siempre.

.gitignore 📋
# Variables de entorno: NUNCA subir
.env
.env.local
.env.production

# Datos locales de H2 (lo usaremos al final)
data/

2. Crea la plantilla .env.example (este sí se sube)

En la raíz del proyecto, al lado de pom.xml. Sirve para que quien clone el repo sepa qué variables necesita, sin ver tus valores:

.env.example (este sí se sube: es la plantilla) 📋
DB_URL=jdbc:h2:mem:nombre_de_tu_base
DB_USERNAME=tu_usuario
DB_PASSWORD=tu_password

3. Crea el .env con tus valores (este NO se sube)

.env (nunca subir a Git) 📋
DB_URL=jdbc:h2:mem:taskdb
DB_USERNAME=taskuser
DB_PASSWORD=taskpass123

4. Configura application.properties

src/main/resources/application.properties 📋
spring.application.name=taskmanager
server.port=8080

# Carga el archivo .env si existe
spring.config.import=optional:file:.env[.properties]

# Base de datos: solo desde variables de entorno
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}

# JPA / Hibernate
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

5. Arranca y comprueba que lee tu .env

Log de arranque 📋
HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:taskdb user=TASKUSER

Si ves tu usuario (TASKUSER, H2 lo pasa a mayúsculas), Spring está leyendo el .env.

⚠️ Si no existe el .env, o no está en la raíz

Verás Failed to instantiate [com.zaxxer.hikari.HikariDataSource]... 'url' must start with "jdbc". Significa que Spring no encontró DB_URL. Créalo y vuelve a arrancar.

⚠️ create-drop en producción borra todos tus datos

Con una base de datos real usa validate o none.

Paso 10: 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:

Si prefieres la terminal, así se hace en Windows (PowerShell):

PowerShell 📋
# 1. Crear una tarea (POST)
Invoke-RestMethod -Method Post -Uri http://localhost:8080/api/tasks `
  -ContentType "application/json" `
  -Body '{"title":"Aprender Spring Boot","description":"Completar el tutorial"}'

# 2. Listar todas (GET)
Invoke-RestMethod -Uri http://localhost:8080/api/tasks

# 3. Marcarla como completada (PUT)
Invoke-RestMethod -Method Put -Uri http://localhost:8080/api/tasks/1 `
  -ContentType "application/json" `
  -Body '{"title":"Aprender Spring Boot","description":"Hecho","completed":true}'

# 4. Eliminarla (DELETE)
Invoke-RestMethod -Method Delete -Uri http://localhost:8080/api/tasks/1

Y en Mac o Linux, con curl:

Terminal (Mac / Linux) 📋
# 1. Crear una tarea (POST)
curl -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Aprender Spring Boot","description":"Completar el tutorial"}'

# 2. Listar todas (GET)
curl http://localhost:8080/api/tasks

# 3. Marcarla como completada (PUT)
curl -X PUT http://localhost:8080/api/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"title":"Aprender Spring Boot","description":"Hecho","completed":true}'

# 4. Eliminarla (DELETE)
curl -X DELETE http://localhost:8080/api/tasks/1

En PowerShell de Windows 5.1, curl es un alias de otro comando y no funciona igual. Si quieres usar curl de verdad, escribe curl.exe.

⚠️ Si envías tildes, eñes o signos como ¡ y ¿ desde la terminal de Windows

Puedes recibir un 400 con un mensaje como JSON parse error: Invalid UTF-8 start byte 0xa1, aunque tu JSON esté bien escrito. No es un fallo de tu código ni de Spring Boot: la terminal de Windows (tanto PowerShell como Git Bash con su configuración por defecto) suele enviar esos caracteres especiales en Windows-1252 en lugar de UTF-8, y Jackson exige UTF-8 estricto. La forma más simple de evitarlo en pruebas rápidas por terminal es no usar esos caracteres en los valores de ejemplo, como en los bloques de arriba. Si los necesitas de verdad, usa Postman o Thunder Client, que sí envían UTF-8 correctamente sin que tengas que tocar nada.

Prueba también los errores. Estos dos casos deberían darte respuestas claras:

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 y Mockito al proyecto, no necesitas instalar nada. Los tests van en una carpeta aparte, src/test/java/, que es un árbol de packages independiente del de src/main/java/ aunque uses los mismos nombres. Ahora mismo, dentro de src/test/java/com/studycodepro/taskmanager/, solo tienes la clase TaskmanagerApplicationTests que generó Initializr, sin ningún package dentro.

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: son 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.

Cómo crear el test paso a paso en IntelliJ

  • En el árbol de archivos de la izquierda, despliega src/test/java hasta ver com.studycodepro.taskmanager (ahí dentro solo debería estar TaskmanagerApplicationTests por ahora).
  • Haz clic derecho justo sobre esa carpeta com.studycodepro.taskmanager (la de test, no la de main).
  • En el menú, ve a New → Package.
  • Escribe service y pulsa Enter. Te aparecerá una nueva carpeta com.studycodepro.taskmanager.service justo debajo.
  • Haz clic derecho sobre esa carpeta nueva → New → Java Class.
  • Escribe TaskServiceTest y pulsa Enter. Se abrirá el archivo vacío, listo para pegar el código de abajo.

Con eso ya tienes TaskServiceTest en su sitio:

src/test/java/.../service/TaskServiceTest.java 📋
package com.studycodepro.taskmanager.service;

import com.studycodepro.taskmanager.dto.TaskRequest;
import com.studycodepro.taskmanager.exception.TaskNotFoundException;
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.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
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 create_guardaLaTareaComoPendienteSiNoSeIndicaEstado() {
        TaskRequest request = new TaskRequest("Aprender Spring Boot", "Completar el tutorial", null);
        when(taskRepository.save(any(Task.class))).thenAnswer(invocation -> invocation.getArgument(0));

        Task resultado = taskService.create(request);

        assertThat(resultado.getTitle()).isEqualTo("Aprender Spring Boot");
        assertThat(resultado.isCompleted()).isFalse();
        verify(taskRepository).save(any(Task.class));
    }

    @Test
    void findById_existente_devuelveLaTarea() {
        Task task = Task.builder().id(1L).title("Aprender Spring Boot").build();
        when(taskRepository.findById(1L)).thenReturn(Optional.of(task));

        Task resultado = taskService.findById(1L);

        assertThat(resultado.getTitle()).isEqualTo("Aprender Spring Boot");
    }

    @Test
    void findById_noExiste_lanzaTaskNotFoundException() {
        when(taskRepository.findById(99L)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> taskService.findById(99L))
                .isInstanceOf(TaskNotFoundException.class)
                .hasMessageContaining("99");
    }

    @Test
    void delete_noExiste_lanzaExcepcionYNoBorra() {
        when(taskRepository.existsById(99L)).thenReturn(false);

        assertThatThrownBy(() -> taskService.delete(99L))
                .isInstanceOf(TaskNotFoundException.class);

        verify(taskRepository, never()).deleteById(any());
    }
}

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 (o, con never(), que no se llamó).
  • assertThatThrownBy(...): comprueba que el código lanza la excepción que esperas.

Para ejecutar los tests:

Ejecutar tests 📋
# Windows (PowerShell)
.\mvnw.cmd test

# Mac / Linux
./mvnw test

# Solo una clase
./mvnw test -Dtest=TaskServiceTest

TaskServiceTest usa Mockito y no toca la base de datos, así que funciona igual con o sin .env. Pero Spring Initializr también generó otra clase, TaskmanagerApplicationTests, con un único test llamado contextLoads que sí arranca el contexto completo de Spring, incluida la conexión a la base de datos. Como en application.properties ya no hay valores por defecto (Paso 9), ese test depende de que exista un .env con datos válidos para poder arrancar. Eso es un problema: un test no debería depender de que quien lo ejecute tenga configurado su entorno local a mano, y menos aún un test que solo comprueba que el contexto arranca, sin tocar tu lógica.

La solución es darle a ese test su propia configuración, aislada de la del proyecto, con @TestPropertySource. Así el test define sus propios valores de conexión y deja de depender del .env de tu máquina, de un CI ajeno o de nada externo. Abre TaskmanagerApplicationTests (la generó Spring Initializr en src/test/java/.../TaskmanagerApplicationTests.java) y déjala así:

src/test/java/.../TaskmanagerApplicationTests.java 📋
package com.studycodepro.taskmanager;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;

@SpringBootTest
@TestPropertySource(properties = {
        "spring.datasource.url=jdbc:h2:mem:testdb",
        "spring.datasource.username=sa",
        "spring.datasource.password="
})
class TaskmanagerApplicationTests {

    @Test
    void contextLoads() {
    }
}

@TestPropertySource sobrescribe, solo para este test, las propiedades que le indiques, con más prioridad que application.properties y que el propio .env. Como jdbc:h2:mem:testdb es una base de datos en memoria completamente aislada del taskdb que usas en desarrollo, este test ya no necesita ni le afecta el .env de tu máquina: funciona igual lo tengas creado o no, y funcionará igual en cualquier máquina o servidor de CI sin que nadie tenga que configurar nada a mano. Ejecuta ./mvnw test ahora mismo, incluso sin .env, y verás contextLoads pasar en verde.

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().

Paso 12: compilar y ejecutar el JAR

Hasta ahora has ejecutado la aplicación con spring-boot:run, que es útil durante el desarrollo. 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 ejecución 📋
# Compilar la aplicación (Windows: .\mvnw.cmd clean package)
./mvnw clean package

# Ejecutar el JAR generado
java -jar target/taskmanager-0.0.1-SNAPSHOT.jar

Cuando pases a PostgreSQL: mismo mecanismo, otro valor

En el Paso 9 ya dejaste el mecanismo montado: application.properties lee ${DB_URL}, ${DB_USERNAME} y ${DB_PASSWORD} desde tu .env, y ese archivo ya está en .gitignore. No hay que montar nada nuevo, solo cambiar los valores del .env que ya tienes por los de tu base de datos real:

.env (nunca subir a Git) 📋
DB_URL=jdbc:postgresql://localhost:5432/taskdb
DB_USERNAME=postgres
DB_PASSWORD=tu_password_aqui

Tendrás que añadir también el driver de PostgreSQL como dependencia en el pom.xml. Si prefieres no usar .env, otra alternativa es definir esas mismas variables en la configuración de ejecución de IntelliJ (Run → Edit Configurations → Environment variables). En el servidor de producción, defínelas como variables de entorno reales del sistema operativo, nunca dentro de un archivo que viaje con el código.

Cómo hacer que los datos persistan entre reinicios

La base de datos H2 se borra cada vez que reinicias el servidor. Ese es el primer problema real que vas a encontrar cuando la uses de verdad. Para solucionarlo hay que cambiar dos cosas, no una:

application.properties 📋
# 1. Guardar la base de datos en un archivo en lugar de en memoria
spring.datasource.url=jdbc:h2:file:./data/taskdb

# 2. Que Hibernate NO borre las tablas al parar (update las crea si faltan y las conserva)
spring.jpa.hibernate.ddl-auto=update

Si solo cambias la URL y dejas create-drop, Hibernate seguirá eliminando las tablas cada vez que pares la aplicación, y tus datos desaparecerán igual. Por eso hace falta el segundo cambio.

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, errores claros en JSON y tests. Eso es lo que usa cualquier proyecto Spring Boot real, en pequeño pero con la misma estructura.

Siguiente paso concreto

Cambia H2 por PostgreSQL con Docker y usa las variables de entorno que acabas de configurar. Es el paso natural antes de pensar en desplegar nada.

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