La lección anterior cerró el módulo 3 con una constatación incómoda: la API de CicloUrbana está completa, documentada y publicable, pero cuando el proceso se reinicia todo desaparece. Las cuatro estaciones de Ribalta vuelven a nacer del CargadorEstacionesDemo, las bicicletas dadas de alta se esfuman y los alquileres del día se pierden. Todo vive en el ConcurrentHashMap de EstacionRepositorioEnMemoria, que existe mientras existe la JVM. Este módulo sustituye esa provisionalidad por persistencia real sobre una base de datos relacional, y lo hace sin tocar ni un controlador, gracias a que desde 02-01 escondimos el almacenamiento tras la interfaz EstacionRepositorio.

Antes de escribir una sola anotación conviene entender el terreno. Esta lección es conceptual y responde a las preguntas que casi nadie se hace antes de empezar y todo el mundo se hace cuando algo falla: por qué existe un ORM, qué significa exactamente cada una de las cuatro siglas que se mezclan a diario —JDBC, JPA, Hibernate, Spring Data JPA—, qué es el contexto de persistencia y por qué una entidad puede estar «gestionada» o «separada», y qué hace Spring Boot por su cuenta en cuanto detecta el starter. Sin esta base, JPA parece magia; con ella, es una máquina previsible.

Contenido

  1. El desfase objeto-relacional
  2. Qué es un ORM y qué problema resuelve realmente
  3. Quién es quién: JDBC, JPA, Hibernate y Spring Data JPA
  4. Qué aporta spring-boot-starter-data-jpa
  5. EntityManager, unidad de persistencia y contexto de persistencia
  6. Los cuatro estados de una entidad
  7. Qué genera Spring Boot al detectar el starter
  8. Alternativas a JPA en el ecosistema Spring
  9. El modelo de datos objetivo de CicloUrbana
  10. Errores Comunes y Consejos
  11. Ejercicios

  1. El desfase objeto-relacional

En Java, Estacion es un objeto: tiene identidad propia (dos objetos distintos aunque sean iguales campo a campo), referencias directas a otros objetos, herencia, y vive en un grafo que el recolector de basura gestiona. En una base de datos relacional, una estación es una fila en una tabla: tiene identidad por clave primaria, se relaciona con otras filas mediante claves ajenas, no hereda de nada y solo existe dentro de un conjunto de tuplas.

Los dos modelos son buenos, pero no encajan. A esa colección de desencuentros se le llama desfase objeto-relacional (object-relational impedance mismatch), y tiene cinco caras concretas:

Desencuentro En Java En SQL Consecuencia práctica
Granularidad Estacion contiene un objeto Ubicacion con latitud y longitud La tabla estaciones tiene dos columnas sueltas Hay que aplanar o expandir objetos
Identidad == (referencia) y equals() (valor) son cosas distintas Solo existe la clave primaria Dos objetos pueden representar la misma fila
Herencia Incidencia → IncidenciaBateria, IncidenciaVandalismo No hay herencia entre tablas Hay que elegir una estrategia de mapeo
Asociación Referencia dirigida: Bicicleta.estacion Clave ajena simétrica, navegable en ambos sentidos La bidireccionalidad hay que construirla a mano
Navegación bici.getEstacion().getNombre(), salto a salto JOIN, todo de una vez Navegar objeto a objeto genera muchas consultas

Un ejemplo de CicloUrbana lo hace tangible. Para obtener el nombre de la estación de una bicicleta, en Java lo natural es:

String nombre = bicicleta.getEstacion().getNombre();

En SQL lo natural es lo contrario: pedirlo todo de golpe.

SELECT b.matricula, e.nombre
FROM bicicletas b
JOIN estaciones e ON e.id = b.estacion_id
WHERE b.id = 42;

La primera forma, ejecutada dentro de un bucle sobre cien bicicletas, produce cien consultas. Ese es el germen del problema N+1 que veremos en 04-04. No es un fallo de JPA: es el desfase asomando.

  1. Qué es un ORM y qué problema resuelve realmente

Un ORM (Object-Relational Mapping) es una capa que traduce automáticamente entre objetos y filas. Escribes estacionRepositorio.guardar(estacion) y alguien genera el INSERT; lees estacion.getBicicletas() y alguien genera el SELECT.

Para valorar lo que aporta, mira lo que costaría hacerlo a mano con JDBC puro. Este sería el guardar de una estación sin ORM:

public Estacion guardar(Estacion estacion) {
    String sql = """
            INSERT INTO estaciones (nombre, direccion, capacidad, latitud, longitud)
            VALUES (?, ?, ?, ?, ?)
            """;
    try (Connection conexion = dataSource.getConnection();
         PreparedStatement sentencia =
                 conexion.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {

        sentencia.setString(1, estacion.getNombre());
        sentencia.setString(2, estacion.getDireccion());
        sentencia.setInt(3, estacion.getCapacidad());
        sentencia.setDouble(4, estacion.getLatitud());
        sentencia.setDouble(5, estacion.getLongitud());
        sentencia.executeUpdate();

        try (ResultSet claves = sentencia.getGeneratedKeys()) {
            if (claves.next()) {
                estacion.setId(claves.getLong(1));
            }
        }
        return estacion;
    } catch (SQLException e) {
        throw new AccesoDatosException("No se pudo guardar la estación", e);
    }
}

Veinte líneas para una tabla de cinco columnas. Multiplícalo por buscarPorId, buscarTodas, actualizar, eliminar, y luego por las seis tablas del modelo. Y todavía falta el ResultSet → objeto, el manejo de nulos, y que cada ALTER TABLE obliga a revisar todas esas posiciones de parámetro numeradas a mano.

Lo que un ORM aporta, ordenado por importancia real:

  • Elimina el código repetitivo de mapeo. El 80 % de ese fragmento desaparece.
  • Gestiona la identidad y el estado. Sabe qué objetos ha cargado y cuáles han cambiado, y genera el UPDATE solo de lo modificado.
  • Traduce SQL específico de cada motor. El mismo código funciona sobre H2 en desarrollo y PostgreSQL en producción.
  • Ofrece un lenguaje de consulta orientado a objetos (JPQL) que se valida contra el modelo, no contra cadenas.
  • Automatiza la carga de asociaciones y ofrece control sobre cuándo cargarlas.

Y lo que un ORM no aporta, porque conviene decirlo desde el principio:

  • No te exime de conocer SQL. Cuando algo va lento, el diagnóstico se hace leyendo el SQL generado.
  • No convierte un modelo relacional malo en uno bueno.
  • No es gratis: añade una capa con reglas propias que hay que entender (justo lo que hace este módulo).

  1. Quién es quién: JDBC, JPA, Hibernate y Spring Data JPA

Estas cuatro piezas se nombran a diario como si fueran intercambiables y no lo son. Cada una vive en un nivel de abstracción distinto y depende de la anterior.

Pieza Qué es Quién la mantiene Tipo de artefacto Ejemplo de uso
JDBC API estándar de Java para hablar con una base de datos vía SQL Especificación de Java SE API + driver por motor connection.prepareStatement(sql)
JPA (Jakarta Persistence) Especificación: define anotaciones, EntityManager, JPQL y el ciclo de vida. No ejecuta nada Jakarta EE (antes JSR-338) Interfaces y anotaciones @Entity, EntityManager.persist()
Hibernate Implementación de JPA: el motor que genera y ejecuta el SQL de verdad Red Hat Librería (hibernate-core) Es quien escribe el INSERT
Spring Data JPA Abstracción sobre JPA: genera implementaciones de repositorios a partir de interfaces Spring (VMware/Broadcom) Librería (spring-data-jpa) interface EstacionRepositorio extends JpaRepository<...>

La distinción crítica es JPA es una especificación, Hibernate es una implementación. JPA define que existe @Entity y qué debe significar; Hibernate escribe el código que hace que signifique eso. Otras implementaciones son EclipseLink y OpenJPA, pero Spring Boot trae Hibernate por defecto y es la elección estándar de facto.

graph TD
    A["EstacionService (tu código de negocio)"] --> B["EstacionRepositorio<br/>interfaz Spring Data JPA"]
    B --> C["SimpleJpaRepository<br/>implementación generada"]
    C --> D["EntityManager<br/>API de JPA (especificación)"]
    D --> E["Hibernate<br/>implementación de JPA"]
    E --> F["JDBC + driver PostgreSQL"]
    F --> G[("PostgreSQL 16")]

Cada flecha hacia abajo es una traducción. estacionRepositorio.findById(1L) se convierte en una llamada a EntityManager.find(Estacion.class, 1L), que Hibernate traduce a un SELECT ... WHERE id = ?, que el driver JDBC envía por el socket a PostgreSQL. Cuando algo va mal, el diagnóstico consiste en bajar por estas capas hasta encontrar en cuál se rompió la traducción.

Un matiz que ahorra confusión: Spring Data JPA no sustituye a Hibernate, se apoya en él. Y Spring Data JPA no es Spring Data JDBC; comparten familia y el estilo de repositorios, pero por debajo son proyectos distintos (lo veremos en el apartado 8).

  1. Qué aporta spring-boot-starter-data-jpa

Como todo starter (02-06), es un POM sin código que arrastra un conjunto coherente de dependencias con versiones ya compatibles entre sí.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

Esa única línea añade al classpath:

Dependencia Para qué sirve
spring-boot-starter-jdbc DataSource, JdbcTemplate y el pool HikariCP
hibernate-core El motor ORM: implementación de JPA
jakarta.persistence-api Las anotaciones y las interfaces de la especificación
spring-data-jpa Los repositorios y su generación automática
spring-orm La integración de Spring con JPA y su gestor de transacciones
spring-aspects Soporte AOP, base de @Transactional (04-07)
jakarta.transaction-api La API de transacciones

Lo que no incluye —deliberadamente— es el driver de la base de datos, porque el starter no puede saber a qué motor te vas a conectar. Añadir H2 o PostgreSQL es lo primero que haremos en 04-02.

Puedes comprobar el árbol real en tu proyecto:

./mvnw dependency:tree -Dincludes=org.hibernate.orm:*,org.springframework.data:*

  1. EntityManager, unidad de persistencia y contexto de persistencia

Estos tres términos son el vocabulario básico de JPA. Confundirlos es la causa de la mitad de las preguntas de foro sobre «por qué no se guardan mis cambios».

Unidad de persistencia (persistence unit): la configuración completa de un almacén de datos —qué DataSource usar, qué clases son entidades, qué dialecto SQL, qué propiedades de Hibernate—. En una aplicación JPA clásica se declaraba en persistence.xml. En Spring Boot ese fichero no existe: la unidad de persistencia se construye a partir de application.yml y del escaneo automático de entidades. Es una de las cosas que el starter hace por ti.

EntityManager: la interfaz central de JPA. Es tu puerta de entrada a la persistencia. Sus operaciones esenciales:

Método Qué hace
persist(entidad) Marca una entidad nueva para ser insertada
find(Clase, id) Recupera por clave primaria (mira antes en el contexto)
getReference(Clase, id) Devuelve un proxy perezoso sin ir a la base de datos
merge(entidad) Reincorpora una entidad separada al contexto
remove(entidad) Marca una entidad gestionada para borrado
flush() Fuerza el envío del SQL pendiente a la base de datos
detach(entidad) / clear() Saca una entidad, o todas, del contexto
createQuery(jpql) Crea una consulta JPQL

Con Spring Data JPA casi nunca lo usarás directamente, porque los repositorios lo encapsulan. Pero está ahí debajo, y entender su comportamiento explica el de los repositorios.

Contexto de persistencia (persistence context): es el concepto que de verdad hay que interiorizar. Es una caché de primer nivel que el EntityManager mantiene con todas las entidades que ha cargado o guardado durante una unidad de trabajo. Tiene tres propiedades que lo cambian todo:

  1. Garantiza identidad única: dentro del mismo contexto, dos búsquedas de la estación 1 devuelven exactamente el mismo objeto Java. estacionA == estacionB es true.
  2. Detecta cambios automáticamente (dirty checking): guarda una copia del estado original; al hacer flush compara y genera un UPDATE solo si algo cambió. Por eso, sobre una entidad gestionada, no hace falta llamar a save() (lo desarrollaremos en 04-07).
  3. Retrasa la escritura (write-behind): acumula el SQL y lo envía en el último momento, lo que permite agruparlo y ordenarlo.

En una aplicación Spring típica, el contexto de persistencia vive lo que dura la transacción, normalmente un método @Transactional del servicio. Fuera de él, las entidades quedan separadas. Ese vínculo entre contexto y transacción es el eje del módulo y el motivo de la lección 04-07.

  1. Los cuatro estados de una entidad

Toda instancia de una clase @Entity está siempre en uno de estos cuatro estados. Saber en cuál está explica por qué un cambio se guarda o se pierde.

Estado ¿Tiene id? ¿Está en el contexto? ¿Se sincroniza con la BD?
Transitorio (transient/new) Normalmente no No No
Gestionado (managed/persistent) Sí Sí Sí, automáticamente
Separado (detached) Sí No No
Eliminado (removed) Sí Sí, marcada para borrado Se borrará al hacer flush
stateDiagram-v2
    [*] --> Transitorio: new Estacion()
    Transitorio --> Gestionado: persist() / save()
    Gestionado --> Separado: fin de transacción / detach() / clear()
    Separado --> Gestionado: merge()
    Gestionado --> Eliminado: remove() / delete()
    Eliminado --> Gestionado: persist()
    Eliminado --> [*]: commit (DELETE)
    Separado --> [*]: recolector de basura

Recorramos los cuatro con una estación de Ribalta:

// 1. TRANSITORIO: un objeto Java normal y corriente.
Estacion estacion = new Estacion("Plaza Mayor", "Calle Mayor 1", 24);
// Hibernate no sabe que existe. Si la JVM termina, se pierde.

// 2. GESTIONADO: a partir de save(), el contexto lo vigila.
Estacion gestionada = estacionRepositorio.save(estacion);
gestionada.setCapacidad(26);
// NO hace falta volver a llamar a save(): el dirty checking
// detectará el cambio y emitirá el UPDATE al hacer commit.

// 3. SEPARADO: al terminar la transacción, el contexto se cierra.
// A partir de aquí, los cambios ya no se propagan solos:
gestionada.setCapacidad(30);   // este cambio NO llega a la base de datos

// 4. Para reincorporarla hay que fusionarla:
Estacion reGestionada = estacionRepositorio.save(gestionada); // hace merge

Dos consecuencias que sorprenden a todo el mundo la primera vez:

  • El paso 2 no necesita save() explícito. Muchísimo código Spring lo llama «por si acaso»; es inofensivo pero revela que no se ha entendido el contexto de persistencia.
  • El paso 3 sí lo necesita, y ahí save() no inserta: hace merge. La diferencia se detalla en 04-05.

El estado separado es el que produce la temida LazyInitializationException: intentar navegar una asociación perezosa de una entidad cuyo contexto ya se cerró. Lo veremos en 04-04.

  1. Qué genera Spring Boot al detectar el starter

En cuanto spring-boot-starter-data-jpa está en el classpath y hay un DataSource configurado, la autoconfiguración de 02-06 se dispara. Las clases implicadas son DataSourceAutoConfiguration, HibernateJpaAutoConfiguration y JpaRepositoriesAutoConfiguration, y entre todas registran estos beans sin que escribas una línea:

Bean creado Qué hace Condición
DataSource (HikariCP) Pool de conexiones Hay spring.datasource.url o una BD embebida
EntityManagerFactory Fábrica del EntityManager; construye la unidad de persistencia Hay DataSource + Hibernate
EntityManager (proxy por transacción) La puerta de entrada a JPA Hay EntityManagerFactory
JpaTransactionManager Gestor de transacciones para @Transactional Hay EntityManagerFactory
JpaVendorAdapter Adapta Hibernate a la abstracción de Spring Hibernate en el classpath
Repositorios Un proxy por cada interfaz que extienda Repository @EnableJpaRepositories implícito
PlatformTransactionManager Base de la gestión transaccional (04-07) —

Además, escanea automáticamente las clases @Entity desde el paquete de la clase anotada con @SpringBootApplication hacia abajo. Como CicloUrbanaApplication vive en com.ciclourbana, todas las entidades de com.ciclourbana.estaciones, .bicicletas y .alquileres se detectan solas. Es la misma regla del escaneo de componentes de 01-04, aplicada a entidades.

Puedes verificarlo con el informe de autoconfiguración de 02-06:

./mvnw spring-boot:run -Dspring-boot.run.arguments=--debug

En la sección Positive matches aparecerán entradas como:

HibernateJpaAutoConfiguration matched:
  - @ConditionalOnClass found required classes 'jakarta.persistence.EntityManager',
    'org.hibernate.SessionFactory' (OnClassCondition)
  - @ConditionalOnBean found bean 'dataSource' (OnBeanCondition)

JpaRepositoriesAutoConfiguration#jpaRepositoriesFactoryBean matched:
  - @ConditionalOnBean found bean 'entityManagerFactory'

Y si no configuras un DataSource teniendo el starter, el arranque falla con un mensaje muy característico:

Failed to configure a DataSource: 'url' attribute is not specified and no embedded
datasource could be configured.
Reason: Failed to determine a suitable driver class

Es exactamente el problema que resuelve la lección siguiente.

  1. Alternativas a JPA en el ecosistema Spring

JPA es la opción por defecto, no la única ni siempre la mejor. Conocer el mapa evita el error de forzarla donde no encaja.

Tecnología Nivel de abstracción Puntos fuertes Puntos débiles Cuándo elegirla
JdbcTemplate Bajo: tú escribes el SQL Control total, sin sorpresas, sin estado Mapeo manual, código repetitivo Informes, consultas complejas, cargas masivas
Spring Data JDBC Medio Modelo simple, sin caché ni carga perezosa, muy predecible Sin relaciones perezosas ni herencia Modelos DDD con agregados bien delimitados
Spring Data JPA Alto Productividad, relaciones, caché, portabilidad Curva de aprendizaje, comportamiento implícito CRUD de dominio rico, el caso general
jOOQ Bajo, con SQL tipado SQL comprobado en compilación, perfecto para consultas complejas Requiere generación de código; licencia comercial en BD propietarias Aplicaciones donde el SQL es el centro
MyBatis Bajo-medio SQL en XML o anotaciones, mapeo flexible Más configuración, menos automatismo Migración de aplicaciones con SQL heredado
R2DBC Medio Acceso reactivo no bloqueante Ecosistema menor, sin JPA posible Aplicaciones WebFlux con mucha concurrencia

Un criterio práctico y honesto: JPA brilla cuando escribes y lees agregados de dominio; se estorba cuando haces informes. En CicloUrbana usaremos Spring Data JPA para todo el CRUD de estaciones, bicicletas y alquileres, y en 04-06 veremos consultas nativas para casos como «estaciones más cercanas», donde el SQL específico de PostgreSQL gana por goleada. No son excluyentes: conviven en el mismo proyecto sobre el mismo DataSource.

  1. El modelo de datos objetivo de CicloUrbana

Este es el destino del módulo. Seis tablas que reflejan el negocio de la red de Ribalta:

erDiagram
    ESTACIONES ||--o{ BICICLETAS : "alberga"
    ESTACIONES ||--o{ ALQUILERES : "origen"
    ESTACIONES ||--o{ ALQUILERES : "destino"
    BICICLETAS ||--o{ ALQUILERES : "es alquilada en"
    BICICLETAS ||--o{ INCIDENCIAS : "acumula"
    USUARIOS ||--o{ ALQUILERES : "realiza"
    TARIFAS ||--o{ ALQUILERES : "se aplica en"

    ESTACIONES {
        bigint id PK
        varchar nombre UK
        varchar direccion
        int capacidad
        numeric latitud
        numeric longitud
        boolean activa
        bigint version
    }
    BICICLETAS {
        bigint id PK
        varchar matricula UK
        varchar estado
        int nivel_bateria
        bigint estacion_id FK
        bigint version
    }
    USUARIOS {
        bigint id PK
        varchar correo UK
        varchar nombre
        varchar tipo_tarifa
        date fecha_alta
    }
    ALQUILERES {
        bigint id PK
        bigint usuario_id FK
        bigint bicicleta_id FK
        bigint estacion_origen_id FK
        bigint estacion_destino_id FK
        timestamp inicio
        timestamp fin
        numeric importe
    }
    TARIFAS {
        bigint id PK
        varchar codigo UK
        numeric precio_minuto
        numeric importe_minimo
    }
    INCIDENCIAS {
        bigint id PK
        bigint bicicleta_id FK
        varchar tipo
        varchar descripcion
        timestamp reportada_en
    }

Fíjate en tres detalles que anticipan lecciones concretas:

  • La columna version en estaciones y bicicletas es el bloqueo optimista de 04-03, el que sustituirá al ShallowEtagHeaderFilter de 03-03.
  • alquileres tiene dos claves ajenas a estaciones (origen y destino): un caso donde mappedBy no basta y hay que nombrar cada @JoinColumn explícitamente (04-04).
  • importe y precio_minuto son numeric, nunca double. El porqué está en 04-03, y es una de las reglas menos negociables de todo el módulo.

El recorrido del módulo, apoyado en este modelo:

Lección Qué añade a CicloUrbana
04-02 H2 en desarrollo, PostgreSQL 16 en Docker, HikariCP afinado
04-03 Estacion, Bicicleta y Alquiler como entidades con @Version y auditoría
04-04 Las relaciones del diagrama, LAZY y el problema N+1
04-05 EstacionRepositorio extends JpaRepository, paginación en la API
04-06 Consultas derivadas, JPQL, nativas y proyecciones
04-07 @Transactional en los servicios, propagación y bloqueos
04-08 Flyway: el esquema como código versionado

Errores Comunes y Consejos

Creer que JPA es Hibernate. Son cosas distintas: JPA es la especificación, Hibernate la implementación. Importa cuando aparece una anotación como @BatchSize, que es de Hibernate, no de JPA: funciona, pero te ata al proveedor. Comprueba siempre el paquete del import: jakarta.persistence.* es estándar; org.hibernate.annotations.* es específico.

Pensar que un ORM te libra de saber SQL. Es exactamente al revés: cuanto más automática es la generación de SQL, más importante es saber leerlo. Activa el log de sentencias desde el primer día (04-02) y acostúmbrate a mirarlo.

Llamar a save() sobre una entidad ya gestionada. No hace daño, pero indica que no se ha entendido el dirty checking. Dentro de un método @Transactional, modificar una entidad cargada basta.

Olvidar que el contexto de persistencia muere con la transacción. Es el origen de la LazyInitializationException y de los «cambios que no se guardan». Cuando algo raro pase, la primera pregunta debe ser: ¿en qué estado está esta entidad?

Elegir JPA para todo, incluidos los informes. Una consulta de agregación sobre un millón de alquileres no debería pasar por el mapeo de entidades. JdbcTemplate o una consulta nativa con proyección es la respuesta correcta (04-06).

Consejo: dibuja el modelo relacional antes de anotar clases. El diagrama ER de arriba se hizo antes que las entidades, no después. Anotar sin un modelo pensado produce esquemas que hay que rehacer, y en 04-08 verás que rehacer un esquema en producción es caro.

Consejo: conserva la interfaz EstacionRepositorio. El módulo entero es la demostración de que aislar el almacenamiento tras una interfaz permite cambiar de tecnología sin tocar servicios ni controladores.

Ejercicios

Ejercicio 1: clasificar el estado de las entidades

Dado el siguiente método de un servicio de CicloUrbana, indica en qué estado está la variable estacion en cada punto marcado y si el cambio de la línea final llega a la base de datos. Justifícalo.

@Transactional
public void renombrarEstacion(Long id, String nuevoNombre) {
    Estacion estacion = new Estacion();          // (A)
    estacion = estacionRepositorio.findById(id)  // (B)
            .orElseThrow(() -> new RecursoNoEncontradoException("Estación", id));
    estacion.setNombre(nuevoNombre);             // (C)
}                                                 // (D) fin del método

Ejercicio 2: elegir la tecnología de acceso a datos

Para cada necesidad del ayuntamiento de Ribalta, elige entre JdbcTemplate, Spring Data JPA, jOOQ o R2DBC, y razona la elección en una frase.

  1. CRUD completo de estaciones con validación y relaciones con bicicletas.
  2. Un informe mensual que cruza alquileres, usuarios y tarifas con seis JOIN, tres subconsultas y funciones de ventana.
  3. Cargar de golpe 500 000 lecturas de sensores de las bicicletas cada noche.
  4. Un panel en tiempo real que mantiene 20 000 conexiones abiertas mostrando la ocupación de las estaciones.

Ejercicio 3: recorrer las capas

Explica, capa por capa, qué ocurre cuando EstacionService ejecuta estacionRepositorio.findById(3L) y la estación «Parque del Río» no está aún en el contexto de persistencia. Nombra las cinco capas implicadas y qué hace cada una.

Soluciones

Solución 1.

  • (A) Transitorio. new Estacion() crea un objeto Java normal; Hibernate no lo conoce, no tiene id y nada lo vigila. Además, esta línea es inútil: la referencia se sobrescribe inmediatamente. Es un olor a código, no un error.
  • (B) Gestionado. findById carga la fila y la incorpora al contexto de persistencia de la transacción abierta por @Transactional. Hibernate guarda una copia de su estado original para el dirty checking.
  • (C) Gestionado y «sucio». El objeto sigue gestionado; su nombre difiere ahora de la copia original. Todavía no se ha ejecutado ningún UPDATE.
  • (D) El cambio SÍ llega a la base de datos. Al terminar el método, el proxy transaccional hace commit; antes del commit se ejecuta un flush que compara estado actual y copia original, detecta el nombre modificado y emite UPDATE estaciones SET nombre = ? WHERE id = ?. Tras el commit el contexto se cierra y la entidad pasa a separada.

No hace falta llamar a save(). Es el ejemplo canónico de dirty checking. Si el método no llevara @Transactional, el contexto viviría solo lo que dura la llamada al repositorio, la entidad volvería separada y el cambio se perdería en silencio: el peor tipo de fallo, porque no lanza ninguna excepción.

Solución 2.

  1. Spring Data JPA. Es su caso ideal: agregado de dominio, relaciones, validación, escritura y lectura por identificador. La productividad compensa de sobra la abstracción.
  2. jOOQ (o JdbcTemplate con SQL nativo). Seis JOIN, subconsultas y funciones de ventana no son expresables cómodamente en JPQL, y forzarlo produce consultas ilegibles. jOOQ da SQL tipado y comprobado en compilación; JdbcTemplate es la alternativa sin dependencias añadidas.
  3. JdbcTemplate con batchUpdate. Una carga masiva no debe pasar por el contexto de persistencia: 500 000 entidades gestionadas agotarían la memoria y el dirty checking haría un trabajo inútil. Inserción por lotes en JDBC directo.
  4. R2DBC con WebFlux. Veinte mil conexiones concurrentes con el modelo bloqueante de JDBC exigirían veinte mil hilos. El acceso reactivo no bloqueante está pensado exactamente para esto. (Con Java 21 los hilos virtuales cambian parte de este cálculo, pero R2DBC sigue siendo la opción de manual.)

Solución 3.

  1. EstacionService llama a estacionRepositorio.findById(3L) sobre la interfaz. No sabe nada de JPA.
  2. El proxy de Spring Data JPA —creado en el arranque, como veremos en 04-05— recibe la llamada y la delega en SimpleJpaRepository, la implementación estándar.
  3. SimpleJpaRepository traduce a entityManager.find(Estacion.class, 3L) y envuelve el resultado en Optional.
  4. El EntityManager (Hibernate) busca primero en el contexto de persistencia. Como no está, genera el SQL adaptado al dialecto: select e1_0.id, e1_0.capacidad, ... from estaciones e1_0 where e1_0.id=?.
  5. JDBC y el driver de PostgreSQL piden una conexión al pool HikariCP, envían la sentencia preparada y devuelven un ResultSet.

De vuelta, Hibernate construye la instancia de Estacion, la registra en el contexto de persistencia con una copia de su estado, y la devuelve envuelta en Optional. Si el servicio volviera a pedir la estación 3 en la misma transacción, el paso 5 no se repetiría: la caché de primer nivel devolvería el mismo objeto.

Conclusión

Ya tienes el mapa completo del terreno. Sabes que el desfase objeto-relacional no es un capricho sino cinco desencuentros concretos entre dos modelos igual de válidos, y que un ORM existe para absorberlos a cambio de introducir reglas propias. Distingues con precisión las cuatro piezas que se confunden a diario: JDBC como API de bajo nivel, JPA como especificación, Hibernate como implementación que genera el SQL real, y Spring Data JPA como capa que fabrica repositorios a partir de interfaces. Conoces las siete dependencias que arrastra spring-boot-starter-data-jpa y, sobre todo, la que no arrastra: el driver. Entiendes qué es el contexto de persistencia y sus tres propiedades —identidad única, dirty checking y escritura diferida—, que explican por qué modificar una entidad gestionada basta para guardarla. Puedes situar cualquier instancia en uno de los cuatro estados y predecir si sus cambios sobrevivirán. Sabes qué beans crea la autoconfiguración en cuanto detecta el starter y cómo verificarlo con --debug. Y tienes criterio para saber cuándo JPA no es la respuesta y conviene JdbcTemplate, jOOQ o R2DBC.

También tienes el destino a la vista: las seis tablas del modelo de CicloUrbana, con su columna version para el bloqueo optimista, sus dos claves ajenas de alquileres a estaciones y sus importes en numeric.

Falta lo primero de todo: no hay ninguna base de datos. Si añades el starter ahora mismo y arrancas, la aplicación fallará con «Failed to configure a DataSource». La lección 04-02, Configuración de Fuentes de Datos, resuelve eso: montaremos H2 en memoria para desarrollo con su consola web, levantaremos PostgreSQL 16 con un docker-compose.yml propio de CicloUrbana, afinaremos el pool HikariCP parámetro a parámetro con criterios de dimensionado reales, decidiremos qué valor de ddl-auto usar en cada entorno y por qué open-in-view debe desactivarse desde el primer día, y dejaremos el log de SQL configurado para poder ver exactamente qué envía Hibernate a Ribalta.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados