← Persistencia con JPA y PostgreSQL

Sesión 20 · Semana 10

Primera entidad persistente

Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado preparar la transición a persistencia. En Servidor continúas la implementación del mismo producto.

Se explica

25 minutos · explicación y demostración

La aplicación ya se conecta a PostgreSQL. Hoy mapearás una clase a una tabla y sustituirás el repositorio en memoria. En JPA, una entidad es una clase persistente con identidad; JpaRepository proporciona operaciones comunes para guardarla y consultarla.

Cobrando la promesa de la UD4

Al final de la UD4 dejamos escrita una promesa formal:

«En la UD5, TareaRepositorioEnMemoria se borra y en su lugar aparece una interfaz que extiende JpaRepository. Spring la implementa automáticamente. El service, que ya depende de una interfaz con esos mismos nombres de método, no se entera. Cambias dónde se guardan los datos sin abrir la capa que decide las reglas.»

Hoy es el día de cobrar esa promesa.

En la UD4 resististe la tentación de meter el código de acceso a datos en el controlador o en el servicio. Aceptaste escribir una interfaz TareaRepository e inyectarla por constructor. Parecía ceremonia innecesaria para una simple lista en memoria.

Ahora vas a ver la recompensa: vamos a cambiar por completo el motor de persistencia de la aplicación —sustituyendo la memoria volátil por PostgreSQL— y nuestro servicio no va a cambiar ni una sola línea de lógica de negocio.

La sustitución limpia de la capa de acceso a datos
  1. TareaController (intacto)
  2. TareaService (intacto)
  3. TareaRepository (ahora con Spring Data)
  4. PostgreSQL

Identidad persistente y contrato estable

Una entidad JPA representa datos con identidad en la base de datos. Su identificador distingue una fila de otra; no es la posición que ocupa en una lista de Java. Las anotaciones de mapeo declaran qué campos se guardan y cómo se genera esa identidad. Un repositorio JPA ejecuta las operaciones de acceso a través del contexto de persistencia.

El cambio se realiza detrás del servicio. El controlador y sus DTO deben seguir publicando las mismas rutas, entradas y respuestas. Si el cliente necesita cambiar porque has sustituido una colección por PostgreSQL, revisa si habías expuesto detalles internos en el contrato.

La comprobación decisiva no es ver una fila mientras la aplicación está arrancada: se crea un dato mediante HTTP, se consulta en PostgreSQL, se detiene Java, se vuelve a arrancar y se lee el mismo identificador. Así se demuestra que el estado está fuera del proceso. Conserva también la consulta con la que localizas la fila; te servirá para diagnosticar altas y modificaciones en la siguiente sesión.

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Paso 1 · Retomar el proyecto y preparar la comprobación

  1. Arranca PostgreSQL y el backend con la configuración de la sesión 19. Abre el modelo y el repositorio de la entidad principal.
  2. Localiza el servicio que utiliza la interfaz del repositorio. Mantén sus DTO y rutas: el cambio debe afectar al almacenamiento.
  3. Prepara una creación con datos reconocibles y anota cómo consultarás después su id desde HTTP y SQL.

Paso 2 · Mapear la primera entidad: Tarea

Actualiza la entidad que ya existe; el bloque enseña los campos básicos de JPA, pero conserva también los campos del dominio y sus accesos. Antes de arrancar, cambia el tipo de los ids a Long en los DTO, argumentos del servicio y controlador, mappers y tests. Los ejemplos numéricos de Java pasan a 1L; en JSON siguen siendo números. La referencia numérica proyectoId se conserva hasta transformarla en relación en la sesión 23. Comprueba las referencias del IDE para no dejar un constructor o una comparación con tipos anteriores.

Abre src/main/java/com/ejemplo/gestor/model/Tarea.java y anótala:

package com.ejemplo.gestor.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;

@Entity
@Table(name = "tareas")
public class Tarea {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "titulo", nullable = false, length = 120)
    private String titulo;

    @Column(name = "prioridad", nullable = false, length = 20)
    private String prioridad;

    @Column(name = "completada", nullable = false)
    private boolean completada;

    @Column(name = "proyecto_id", nullable = false)
    private Long proyectoId;

    public Long getProyectoId() { return proyectoId; }
    public void setProyectoId(Long proyectoId) { this.proyectoId = proyectoId; }

    // Constructor sin argumentos obligatorio para JPA
    public Tarea() {
    }

    // Constructor de conveniencia para crear tareas nuevas
    public Tarea(String titulo, String prioridad) {
        this.titulo = titulo;
        this.prioridad = prioridad;
        this.completada = false;
    }

    // Constructor completo
    public Tarea(Long id, String titulo, String prioridad, boolean completada) {
        this.id = id;
        this.titulo = titulo;
        this.prioridad = prioridad;
        this.completada = completada;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitulo() {
        return titulo;
    }

    public void setTitulo(String titulo) {
        this.titulo = titulo;
    }

    public String getPrioridad() {
        return prioridad;
    }

    public void setPrioridad(String prioridad) {
        this.prioridad = prioridad;
    }

    public boolean isCompletada() {
        return completada;
    }

    public void setCompletada(boolean completada) {
        this.completada = completada;
    }
}

Detengámonos en cada decisión técnica:

@Entity
Registra la clase en el metamodelo de JPA. Le dice a Hibernate: «esta clase representa un registro persistente y tú eres responsable de su ciclo de vida».
@Table(name = "tareas")
Especifica el nombre explícito de la tabla física en plural y minúsculas. Si lo omites, Hibernate usará el nombre de la clase (tarea), lo que puede colisionar con palabras reservadas de SQL (como User u Order).
@Id y @GeneratedValue(strategy = GenerationType.IDENTITY)
Marca la clave primaria. La estrategia IDENTITY le indica a Hibernate que confíe en la columna autonumérica de PostgreSQL (GENERATED BY DEFAULT AS IDENTITY o SERIAL), delegando la generación del valor al motor de la base de datos al ejecutar el INSERT.
El constructor vacío public Tarea() {}
Es estrictamente obligatorio por la especificación JPA. Cuando Hibernate recupera filas de la base de datos mediante JDBC, no conoce tus constructores de negocio: necesita instanciar el objeto vacío por reflexión (Class.getDeclaredConstructor().newInstance()) y luego rellenar los atributos campo a campo.
Por qué Long y no int ni long
Un tipo primitivo no admite null: un long por defecto vale 0. Si el id valiera 0 al nacer, Hibernate dudaría de si estás intentando actualizar un registro existente con id 0 o si es un registro nuevo. Al usar el objeto Long, una tarea nueva tiene id = null, lo que señala de forma inequívoca que aún no existe en PostgreSQL.

Paso 3 · Crear la interfaz JpaRepository y utilizar sus operaciones

Ahora sustituimos nuestra interfaz manual de la UD4 por la interfaz estándar de Spring Data.

Abre src/main/java/com/ejemplo/gestor/repository/TareaRepository.java y déjala exactamente así:

package com.ejemplo.gestor.repository;

import com.ejemplo.gestor.model.Tarea;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface TareaRepository extends JpaRepository<Tarea, Long> {
}

Mira con atención ese archivo: tiene cero líneas de implementación. No hay métodos escritos, no hay sentencias SQL, no hay bucles.

¿Cómo es posible que esto funcione?

El patrón Dynamic Proxy de Spring Data

Cuando Spring Boot arranca y encuentra una interfaz que extiende JpaRepository, crea dinámicamente en tiempo de ejecución una clase oculta que la implementa (un proxy dinámico de Java).

Esa clase generada internamente por Spring inyecta el EntityManager de JPA y traduce cada llamada a operaciones de base de datos dentro de una transacción.

Conviene observar qué métodos hereda la interfaz sin declararlos:

Método heredado de JpaRepository Lo que hace en PostgreSQL Lo que teníamos en la UD4
List<Tarea> findAll() SELECT ... FROM tareas Devolvía una copia del ArrayList
Optional<Tarea> findById(Long id) SELECT ... FROM tareas WHERE id = ? Recorría la lista con un bucle for
Tarea save(Tarea tarea) INSERT o UPDATE en la tabla Asignaba id manual y hacía .add()
void deleteById(Long id) DELETE FROM tareas WHERE id = ? tareas.removeIf(...)
boolean existsById(Long id) SELECT count(*) ... WHERE id = ? findById(id).isPresent()
long count() SELECT count(*) FROM tareas tareas.size()

Los nombres de métodos que diseñamos en la sesión 16 no fueron casualidad: eran exactamente los métodos que JpaRepository ya tiene estandarizados.

Paso 4 · Borrar la memoria y conectar el servicio

Migrar también un test de la interfaz antigua. Mockito ya viene con spring-boot-starter-test: mock crea un colaborador de prueba y when define su respuesta. En el test del servicio sustituye new TareaRepositorioFalso() por lo siguiente, importando mock, when y verify de org.mockito.Mockito y java.util.Optional:

TareaRepository repositorio = mock(TareaRepository.class);
Tarea existente = new Tarea(7L, "Revisar entrega", "alta", false);
when(repositorio.findById(7L)).thenReturn(Optional.of(existente));

Pasa repositorio al constructor del servicio junto a los demás colaboradores que ya utilizaba tu test. Ejecuta obtener(7L), conserva la aserción sobre su resultado y añade verify(repositorio).findById(7L). En el caso ausente configura Optional.empty() y conserva la aserción de excepción. Migra de esta forma los dobles antiguos antes de retirarlos; no implementes manualmente los métodos de JpaRepository.

  1. Conserva en Git la versión de memoria y retira sus implementaciones del código compilado cuando la interfaz pase a extender JpaRepository. Quitar solo @Repository no resuelve los métodos de interfaz que ya no coincidan.

  2. Mantén las dependencias del constructor del servicio y adapta sus llamadas: deleteById ahora devuelve void; usa existsById u obtener para decidir la ausencia. Después de modificar una entidad fuera de una transacción, devuelve repositorio.save(entidad) para persistirla.

  3. Revisa los dobles de prueba que implementaban la interfaz pequeña: ahora necesitarían los métodos heredados de JPA. Sustitúyelos por mocks de Mockito, como se explica a continuación, conservando las reglas y aserciones de los tests. Ejecuta test antes de comprobar las peticiones.

  4. Borra el archivo TareaRepositorioEnMemoria.java (su versión anterior seguirá disponible en Git). Ya no lo necesitamos.

  5. Abre TareaService.java. Tu servicio ya declaraba:

@Service
public class TareaService {

    private final TareaRepository repositorio;

    public TareaService(TareaRepository repositorio) {
        this.repositorio = repositorio;
    }
    // Conserva aquí los demás colaboradores y métodos de tu servicio.
}

Como repositorio es de tipo TareaRepository, Spring inyectará automáticamente el bean generado por Spring Data JPA en lugar del antiguo repositorio en memoria.

Si tu servicio o tus controladores usaban int para los identificadores, actualízalos a Long para que coincidan con el tipo de la clave primaria. Revisa los métodos listar(), obtener(id), crear(tarea) y eliminar(id): deben conservar sus reglas, pero adaptar los tipos, el retorno de deleteById y la persistencia de los cambios. La regla de negocio de que una tarea nace sin completar sigue en su sitio, protegida y aislada.

Paso 5 · La gran comprobación: el dato sobrevive al reinicio

Crea primero el proyecto padre mediante su endpoint y guarda su id. En la tarea utiliza ese id como proyectoId y una prioridad aceptada por tu validador (alta, media o baja); no retires la validación de la UD3 para hacer funcionar un ejemplo. Si estás migrando las dos entidades, completa también el paso 6 antes de comprobar una regla que consulte proyectos. Anota el id real de la tarea, detén únicamente Java y vuelve a consultar ese id después del arranque. No presupongas que vale 1 ni elimines el volumen de PostgreSQL durante esta prueba.

Ejecuta tu aplicación Spring Boot. Como en la sesión 19 configuramos ddl-auto=update y show-sql=true, mira la consola en los primeros segundos de arranque. Verás a Hibernate ejecutar:

create table if not exists tareas (
    id bigint generated by default as identity,
    completada boolean not null,
    prioridad varchar(20) not null,
    titulo varchar(120) not null,
    primary key (id)
)

Hibernate ha leído las anotaciones @Entity, @Id y @Column de tu clase Tarea y ha creado la tabla correspondiente en PostgreSQL con todas sus restricciones.

Abre Postman, Thunder Client o la terminal con curl y envía una petición POST para crear una tarea:

POST http://localhost:8080/tareas
Content-Type: application/json

{
  "titulo": "Aprender persistencia con JPA y PostgreSQL",
  "prioridad": "alta"
}

Observa la consola de Spring Boot. En el instante exacto en que llega la petición, verás aparecer:

Hibernate:
    insert
    into
        tareas
        (completada, prioridad, titulo)
    values
        (?, ?, ?)

La respuesta HTTP devolverá el JSON con id: 1 asignado por PostgreSQL:

{
  "id": 1,
  "titulo": "Aprender persistencia con JPA y PostgreSQL",
  "prioridad": "alta",
  "completada": false
}

Ve a la terminal o al IDE y detén por completo el proceso de Spring Boot (Ctrl + C o botón rojo de stop).

En la UD4, este paso borraba todo lo que hubieras creado.

Vuelve a arrancar la aplicación (./mvnw spring-boot:run).

Envía ahora una petición GET:

GET http://localhost:8080/tareas

Mira la respuesta:

[
  {
    "id": 1,
    "titulo": "Aprender persistencia con JPA y PostgreSQL",
    "prioridad": "alta",
    "completada": false
  }
]

El dato sigue ahí. Ha sobrevivido al apagado de la máquina virtual Java porque no estaba en la memoria volátil de Tomcat: estaba guardado en los ficheros de datos de PostgreSQL.

Abre tu consola SQL de DBeaver o psql y consulta la tabla sin pasar por Spring Boot:

SELECT * FROM tareas;

Verás la fila real:

 id | completada | prioridad |                      titulo
----+------------+-----------+---------------------------------------------------
  1 | f          | ALTA      | Aprender persistencia con JPA y PostgreSQL

La lista en memoria es oficialmente parte del pasado.

Paso 6 · Persistir la entidad Proyecto

Aplica de forma autónoma el mismo procedimiento para migrar la entidad Proyecto:

  1. Abre com.ejemplo.gestor.model.Proyecto.
  2. Añade las anotaciones @Entity y @Table(name = "proyectos").
  3. Anota su clave primaria con @Id y @GeneratedValue(strategy = GenerationType.IDENTITY). Asegúrate de que su tipo sea Long.
  4. Mapea nombre (VARCHAR(100), obligatorio), descripcion (VARCHAR(255)), activo (BOOLEAN) y fechaCreacion (DATE).
  5. Añade el constructor vacío obligatorio sin argumentos.
  6. Crea la interfaz ProyectoRepository extends JpaRepository<Proyecto, Long>.
  7. Borra ProyectoRepositorioEnMemoria.
  8. Arranca la aplicación, inserta dos proyectos mediante POST /proyectos, reinicia el servidor y comprueba con GET /proyectos que ambos persisten en PostgreSQL.

Paso 7 · Comprobar y registrar el resultado del proyecto

  1. Crea un registro, detén solo el backend, vuelve a arrancarlo y consulta el mismo id: debe conservarse.
  2. Consulta la tabla desde el cliente SQL y relaciona fila, objeto y DTO. Comprueba que el servicio ya utiliza el repositorio persistente y no una lista paralela.

Ampliación si has completado el trabajo

Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.

Reto · ¿Cómo sabe save() si debe hacer INSERT o UPDATE?

El método save() de Spring Data parece mágico: le pasas un objeto y él solo decide si ejecuta una sentencia INSERT o un UPDATE.

Investiga cómo toma esa decisión analizando el ciclo de vida de las entidades en JPA:

Spring Data comprueba el valor del atributo @Id:

  • Si id == null, Hibernate considera que la entidad es transitoria (transient, nueva en memoria). Invoca internamente EntityManager.persist() y genera un INSERT.
  • Si id != null, Hibernate considera que la entidad es separada (detached, existente). Invoca EntityManager.merge() y asume que debe actualizar.

¿Qué ocurre si creas un objeto Tarea manualmente, le asignas un id = 9999L (que no existe en la base de datos) y llamas a repositorio.save(tarea)?

  • Pruébalo en un test o en un endpoint de prueba y observa con atención las sentencias SQL que Hibernate imprime en la consola.
  • ¿Qué consulta ejecuta Hibernate antes de decidir qué hacer?
  • Explica por qué intentar actualizar un registro con un id inexistente provoca un SELECT previo inútil y qué consecuencias tiene eso sobre el rendimiento de un sistema con alta concurrencia.
Objetivo mínimoTarea anotada como entidad, TareaRepository creado con Spring Data y persistencia comprobada tras reiniciar.
Si lo tienesProyecto migrado a JPA, repositorio en memoria borrado y ambas tablas verificadas en el cliente SQL.
RetoEl mecanismo interno de save() documentado, explicando la diferencia entre persist() y merge() y el coste del SELECT previo ante IDs asignados a mano.
Ver respuestas

1 · Porque Hibernate utiliza reflexión de Java para instanciar la clase vacía al recuperar registros de la base de datos antes de poblar sus campos con los valores de las columnas.

2 · Spring Data genera dinámicamente en tiempo de arranque una clase intermediaria (proxy dinámico) que implementa la interfaz e invoca al EntityManager de JPA dentro de una transacción.

3 · Porque en la UD4 aplicamos inversión de dependencias: el servicio dependía de una abstracción (la interfaz) y no de una implementación concreta, con exactamente las mismas signaturas que ofrece Spring Data.

4 · Comprobando el campo @Id: si es null asume que es nueva y ejecuta un INSERT; si tiene un valor asignado asume que ya existe, ejecuta un SELECT para verificar su estado y emite un UPDATE.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

Los datos sobreviven al reinicio y el cliente recibe el mismo contrato.

Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.