CicloUrbana ya tiene fontanería: un DataSource, un pool HikariCP dimensionado y dos motores listos —H2 en memoria para desarrollar y PostgreSQL 16 en Docker—. Pero la base de datos está completamente vacía, porque Hibernate no tiene nada que mapear. Esta lección llena ese hueco: convierte el modelo de dominio de la red de Ribalta en entidades JPA, las clases que Hibernate sabe traducir a tablas y filas.
Es una lección de decisiones. Cada anotación que pongas aquí condiciona el esquema físico, el rendimiento de las consultas y la facilidad con la que el modelo podrá evolucionar dentro de dos años. Elegir mal la estrategia de identificadores penaliza cada inserción; guardar importes en double produce facturas incorrectas que nadie detecta hasta que un ciudadano reclama; mapear un enumerado por su posición convierte un simple reordenado del código en una corrupción silenciosa de datos. Vamos a fijar cada una de esas decisiones con su razón.
Contenido
- Por qué un
recordno puede ser una entidad @Entityy@Table@Idy las cuatro estrategias de@GeneratedValue@Columny el control de la columna- Tipos de datos y su mapeo
- Enumerados:
STRINGfrente aORDINAL - Campos derivados y
@Transient - Objetos embebidos:
@Embeddabley@Embedded - Auditoría automática
- Bloqueo optimista con
@Version equalsyhashCodeen entidades JPA- Entidades y DTOs: la separación se mantiene
- Errores Comunes y Consejos
- Ejercicios
- Por qué un
record no puede ser una entidad
record no puede ser una entidadDesde 01-03 el dominio de CicloUrbana usa record:
public record Estacion(Long id, String nombre, String direccion,
int capacidad, double latitud, double longitud) { }Ha sido una decisión excelente para el módulo 3: inmutabilidad, equals/hashCode gratis y cero código repetitivo. Pero un record no puede ser una entidad JPA, y no por un capricho de la especificación:
| Requisito de JPA | Un record lo cumple |
Por qué JPA lo necesita |
|---|---|---|
| Constructor sin argumentos | No | Hibernate instancia la entidad vacía y luego rellena campos |
| Campos mutables | No (son final) |
El dirty checking y la carga perezosa reescriben campos |
La clase no puede ser final |
No (los record lo son) |
Hibernate genera subclases proxy para el LAZY |
| Identidad por clave primaria | Discrepa | El equals de un record compara todos los campos |
Los tres primeros son técnicos y bastan para cerrar la discusión. El cuarto es conceptual y más profundo: un record es un objeto de valor, definido por el conjunto de sus campos; una entidad es un objeto con identidad, y la estación 1 sigue siendo la estación 1 aunque cambie de nombre, dirección y capacidad.
La regla que adopta el curso: record para DTOs y objetos de valor; clases mutables para entidades. Los record de com.ciclourbana.comun.dto se quedan exactamente donde están (03-05); lo que cambia es el dominio.
La entidad resultante:
package com.ciclourbana.estaciones;
import jakarta.persistence.*;
@Entity
@Table(name = "estaciones")
public class Estacion {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "estaciones_seq")
@SequenceGenerator(name = "estaciones_seq", sequenceName = "estaciones_id_seq",
allocationSize = 50)
private Long id;
private String nombre;
private String direccion;
private int capacidad;
protected Estacion() { } // requerido por JPA; no usar desde el dominio
public Estacion(String nombre, String direccion, int capacidad) {
this.nombre = nombre;
this.direccion = direccion;
this.capacidad = capacidad;
}
public Long getId() { return id; }
public String getNombre() { return nombre; }
public void setNombre(String nombre) { this.nombre = nombre; }
}Dos detalles con intención: el constructor sin argumentos es protected, no public —JPA solo exige que sea visible para la subclase proxy, y así el código de negocio no puede crear estaciones a medio construir—; y no hay setId, porque el identificador lo asigna la base de datos y permitir cambiarlo desde fuera abre la puerta a corromper la identidad de una fila.
@Entity y @Table
@Entity y @Table@Entity marca la clase como gestionable por JPA. Con eso basta: si no dices nada, la tabla se llamará como la clase.
@Table da control sobre el mapeo físico:
@Entity
@Table(name = "estaciones", schema = "public",
uniqueConstraints = @UniqueConstraint(name = "uk_estaciones_nombre",
columnNames = "nombre"),
indexes = {
@Index(name = "idx_estaciones_activa", columnList = "activa"),
@Index(name = "idx_estaciones_ubicacion", columnList = "latitud, longitud")
})
public class Estacion { /* ... */ }| Atributo | Para qué sirve |
|---|---|
name |
Nombre de la tabla. Decláralo siempre |
schema |
Esquema de la base de datos |
uniqueConstraints |
Restricciones de unicidad de una o varias columnas |
indexes |
Índices que se crearán con el esquema |
Tres advertencias:
- Declara siempre
nameexplícitamente. Por defecto Spring Boot aplicaCamelCaseToUnderscoresNamingStrategy(FichaTecnica→ficha_tecnica): funciona, pero deja el nombre físico a merced de una estrategia configurable. indexesyuniqueConstraintssolo actúan cuando Hibernate genera el esquema. Conddl-auto: validateson pura documentación —los índices reales los creará Flyway en 04-08—, pero conviene declararlos para tener entidad y esquema descritos en el mismo sitio.- La unicidad de
nombreno sustituye a la validación de negocio:EstacionServiceseguirá comprobandoexistePorNombrepara devolver un409conProblemDetaillegible (03-06) en lugar de dejar escapar unaDataIntegrityViolationException. La restricción es la última línea de defensa, la que cubre las condiciones de carrera.
@Id y las cuatro estrategias de @GeneratedValue
@Id y las cuatro estrategias de @GeneratedValueToda entidad necesita una clave primaria marcada con @Id. @GeneratedValue indica quién produce el valor.
| Estrategia | Cómo funciona | Ventaja | Inconveniente | Motor |
|---|---|---|---|---|
AUTO |
Hibernate elige por ti | No hay que decidir | Impredecible entre versiones y motores | Todos |
IDENTITY |
Columna autoincremental de la BD | Simple, sin objetos extra | Desactiva la inserción por lotes | MySQL, PostgreSQL (serial) |
SEQUENCE |
Objeto secuencia de la BD | Permite lotes y reserva de bloques | Requiere soporte de secuencias | PostgreSQL, Oracle, H2 |
TABLE |
Una tabla que guarda contadores | Portable a cualquier motor | Lento, contención y bloqueos | Todos |
Con PostgreSQL, la respuesta es SEQUENCE con allocationSize, y el motivo es concreto y medible. Con IDENTITY, Hibernate no conoce el id hasta después del INSERT, y como lo necesita para registrar la entidad en el contexto de persistencia, está obligado a ejecutar el INSERT inmediatamente, saltándose la escritura diferida: eso deshabilita por completo la inserción por lotes, y insertar 1 000 bicicletas son 1 000 viajes de ida y vuelta. Con SEQUENCE y allocationSize = 50, en cambio, Hibernate pide un valor a la secuencia y reserva en memoria los 50 siguientes, así que asigna ids sin consultar nada y agrupa los INSERT en lotes:
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "bicicletas_seq")
@SequenceGenerator(name = "bicicletas_seq", sequenceName = "bicicletas_id_seq",
allocationSize = 50)
private Long id;Con spring.jpa.properties.hibernate.jdbc.batch_size: 20 (que ya fijamos en 04-02), insertar 1 000 bicicletas pasa de ~1 000 viajes a ~50. En el catálogo nocturno de bicicletas de Ribalta la diferencia es de minutos a segundos.
Regla crítica: el allocationSize de la anotación debe coincidir con el INCREMENT BY de la secuencia real. Si la secuencia se creó con INCREMENT BY 1 y declaras allocationSize = 50, Hibernate asignará ids que ya existen o que colisionarán. En 04-08 crearemos las secuencias en Flyway con el incremento correcto (CREATE SEQUENCE estaciones_id_seq START WITH 1 INCREMENT BY 50;).
Un último aviso sobre AUTO: con Hibernate 6 sobre PostgreSQL suele acabar en SEQUENCE, pero delega en el proveedor una decisión que afecta al rendimiento y que puede cambiar al actualizar. Decláralo tú.
@Column y el control de la columna
@Column y el control de la columna@Column describe la columna física. Sin ella, el nombre se deriva del campo y el resto son valores por defecto.
@Column(name = "nombre", nullable = false, length = 80, unique = true)
private String nombre;
@Column(name = "capacidad", nullable = false)
private int capacidad;
@Column(name = "matricula", nullable = false, length = 10, updatable = false)
private String matricula;
@Column(name = "importe", precision = 8, scale = 2)
private BigDecimal importe;| Atributo | Qué controla | DDL generado |
|---|---|---|
name |
Nombre físico | nombre |
nullable |
Admite nulos | not null |
length |
Longitud de texto | varchar(80) |
precision / scale |
Dígitos totales y decimales | numeric(8,2) |
unique |
Unicidad de una sola columna | unique |
updatable |
Se incluye en los UPDATE |
Solo afecta al SQL, no al DDL |
insertable |
Se incluye en los INSERT |
Solo afecta al SQL |
Cuatro observaciones prácticas:
lengthpor defecto es 255. Dejarlo así convierte cada texto en unvarchar(255)sin criterio. Lamatriculade CicloUrbana esRB-0142: siete caracteres, ylength = 10es generoso y descriptivo.nullable = falsees documentación ejecutable, y va de la mano de@NotNull(03-04): la validación produce un400legible, la restricción de columna es la garantía última de integridad.updatable = falseencaja con la matrícula: se asigna al dar de alta la bicicleta y no cambia jamás, así que Hibernate la excluye de losUPDATE.unique = trueen@Columnfrente auniqueConstraintsen@Table: la primera genera un nombre de restricción aleatorio (UK_a7f3b2...) que acabará apareciendo en los mensajes de error; con@Tablela nombras y además puedes cubrir varias columnas.
- Tipos de datos y su mapeo
| Tipo Java | Tipo SQL (PostgreSQL) | Uso en CicloUrbana | Notas |
|---|---|---|---|
String |
varchar(n) |
nombre, direccion, matricula |
Fija length |
int / Integer |
integer |
capacidad, nivelBateria |
El primitivo no admite nulos |
long / Long |
bigint |
Identificadores | Usa Long para el @Id |
BigDecimal |
numeric(p,s) |
importe, precioMinuto |
Obligatorio para dinero |
double |
double precision |
Nunca para importes | Solo magnitudes físicas |
boolean |
boolean |
activa |
Con nullable = false |
LocalDate |
date |
fechaAlta de un usuario |
Sin hora ni zona |
LocalDateTime |
timestamp |
Uso local sin zona | Ambiguo entre zonas |
Instant |
timestamp with time zone |
inicio, fin de un alquiler |
Recomendado |
Duration |
bigint (nanosegundos) |
Duración de alquiler | Mapeo automático en Hibernate 6 |
enum |
varchar con STRING |
EstadoBicicleta |
Nunca ORDINAL |
byte[] + @Lob |
bytea |
Foto de una incidencia | Cárgalo perezoso |
UUID |
uuid |
Claves públicas | Nativo en PostgreSQL |
Por qué BigDecimal y nunca double para dinero. No es purismo: double es coma flotante binaria y no puede representar exactamente valores decimales como 0,1. El resultado:
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2"))); // 0.3Un alquiler de CicloUrbana a 0,15 €/minuto durante 47 minutos debe dar exactamente 7,05 €. Con double, tras acumular miles de alquileres los céntimos se desvían y el descuadre contable aparece a fin de mes sin culpable identificable. Con BigDecimal y numeric(8,2), el valor es exacto de extremo a extremo.
Dos precauciones al usarlo: construye siempre desde String, porque new BigDecimal(0.1) arrastra el error del double; y compara con compareTo, no con equals, ya que equals considera distintos 2.50 y 2.5 por diferir en escala.
Por qué Instant para marcas de tiempo. Instant es un punto absoluto en la línea del tiempo, independiente de zona horaria, y Hibernate 6 lo mapea a timestamp with time zone. Un alquiler iniciado a las 02:30 del último domingo de octubre —cuando en Ribalta el reloj retrocede una hora— ocurre en un instante único e inequívoco. Con LocalDateTime esa hora existe dos veces y la duración del alquiler puede salir negativa.
Para byte[], declara siempre @Lob @Basic(fetch = FetchType.LAZY): sin LAZY traerías varios megabytes cada vez que se lee una incidencia. Aun así, la recomendación general es guardar los binarios en un almacén de objetos y en la base de datos solo su URL.
- Enumerados:
STRING frente a ORDINAL
STRING frente a ORDINALEstadoBicicleta viene del módulo 3:
Y en la entidad:
@Enumerated(EnumType.STRING)
@Column(name = "estado", nullable = false, length = 20)
private EstadoBicicleta estado = EstadoBicicleta.DISPONIBLE;| Modo | Qué guarda | Consulta directa en SQL | Ante un reordenado |
|---|---|---|---|
ORDINAL (por defecto) |
La posición: 0, 1, 2 |
Ilegible | Corrupción silenciosa |
STRING |
El nombre: DISPONIBLE |
Legible | Seguro |
El peligro de ORDINAL es que el valor por defecto de JPA es justamente el peligroso. Imagina que dentro de un año alguien añade un estado nuevo respetando el orden alfabético:
Todas las filas con estado = 0, que significaban DISPONIBLE, pasan a significar AVERIADA. Sin error, sin excepción y sin aviso: cientos de bicicletas de Ribalta aparecen averiadas de un día para otro, y no hay forma de recuperar el dato original porque el 0 no dice qué significaba.
@Enumerated(EnumType.STRING) en todos los enumerados, sin excepción. El coste —unos bytes por fila— es irrelevante frente a datos legibles, consultables (WHERE estado = 'DISPONIBLE') e inmunes al reordenado. Añade además una restricción CHECK en el esquema (04-08) para que la base de datos rechace valores desconocidos.
- Campos derivados y
@Transient
@Transient@Transient marca un campo que no se persiste. Es la contrapartida a que, por defecto, JPA mapea todos los campos.
@Transient
private int bicicletasDisponibles;
@Transient
public boolean estaCasiLlena() {
return bicicletasDisponibles >= capacidad * 0.9;
}Casos legítimos: valores calculados en tiempo de ejecución, cachés locales o datos que llegan de otro servicio. Cuidado con el import: jakarta.persistence.Transient, no java.beans.Transient ni la palabra clave transient de Java.
Existe también @Formula, específica de Hibernate, que calcula un campo con una subconsulta SQL incrustada en la entidad —por ejemplo, contar las bicicletas disponibles de la estación—. Es tentador, pero tiene tres inconvenientes serios: mete SQL de un motor concreto dentro del dominio, se ejecuta en cada carga aunque nadie use el campo, y no es utilizable desde JPQL. En CicloUrbana preferimos calcular esos agregados en una consulta explícita con proyección (04-06), donde el coste es visible y se paga solo cuando se necesita.
- Objetos embebidos:
@Embeddable y @Embedded
@Embeddable y @EmbeddedEstacion tiene latitud y longitud, dos campos que siempre viajan juntos y que ya en 03-05 dieron lugar a UbicacionResponse. Un embebido permite agruparlos en un objeto sin crear una tabla:
package com.ciclourbana.comun;
@Embeddable
public class Ubicacion {
@Column(name = "latitud", nullable = false, precision = 9, scale = 6)
private BigDecimal latitud;
@Column(name = "longitud", nullable = false, precision = 9, scale = 6)
private BigDecimal longitud;
protected Ubicacion() { }
public Ubicacion(BigDecimal latitud, BigDecimal longitud) {
this.latitud = latitud;
this.longitud = longitud;
}
public BigDecimal getLatitud() { return latitud; }
public BigDecimal getLongitud() { return longitud; }
// equals/hashCode comparando ambos campos con compareTo: es un objeto de VALOR.
}Las columnas siguen viviendo en la tabla estaciones: no hay JOIN ni tabla nueva, solo una agrupación en el modelo Java. Qué gana CicloUrbana con ello: el comportamiento va con los datos (distanciaA(Ubicacion otra) es un método de Ubicacion, no una utilidad estática suelta); la restricción @CoordenadasValidas de 03-04 se aplica a un objeto en lugar de a dos campos sueltos; la clase es reutilizable, porque Bicicleta también tendrá una Ubicacion con su última posición GPS; y es un objeto de valor de manual, sin identidad propia, por lo que aquí equals sí compara campos, al contrario que en una entidad.
Cuando una entidad necesita dos embebidos del mismo tipo, las columnas chocarían. @AttributeOverride las renombra:
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "latitud",
column = @Column(name = "latitud_recogida", precision = 9, scale = 6)),
@AttributeOverride(name = "longitud",
column = @Column(name = "longitud_recogida", precision = 9, scale = 6))
})
private Ubicacion ubicacionRecogida;
// El segundo embebido es idéntico, renombrando a latitud_entrega y longitud_entrega:
@Embedded
@AttributeOverrides({ /* ... */ })
private Ubicacion ubicacionEntrega;Un Alquiler puede así registrar dónde se recogió y dónde se dejó la bicicleta, reutilizando la misma clase.
- Auditoría automática
Saber cuándo se creó y cuándo se modificó por última vez cada fila es una necesidad universal. Spring Data lo automatiza.
Primero, activarlo con una clase @Configuration anotada con @EnableJpaAuditing —en CicloUrbana, com.ciclourbana.comun.config.ConfiguracionAuditoria—. Después, una clase base con los campos comunes:
package com.ciclourbana.comun;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class EntidadAuditable {
@CreatedDate
@Column(name = "creado_en", nullable = false, updatable = false)
private Instant creadoEn;
@LastModifiedDate
@Column(name = "modificado_en", nullable = false)
private Instant modificadoEn;
public Instant getCreadoEn() { return creadoEn; }
public Instant getModificadoEn() { return modificadoEn; }
}Y las entidades la extienden:
Las piezas y su papel:
| Anotación | Qué hace |
|---|---|
@EnableJpaAuditing |
Activa el mecanismo en el contexto |
@MappedSuperclass |
Clase base cuyos campos se heredan sin tabla propia |
@EntityListeners(AuditingEntityListener.class) |
Engancha el interceptor que rellena los campos |
@CreatedDate |
Se rellena solo en el INSERT |
@LastModifiedDate |
Se actualiza en cada UPDATE |
@MappedSuperclass es clave: no crea una tabla entidad_auditable. Sus columnas se copian en la tabla de cada entidad hija. Es distinto de la herencia de entidades, que veremos en 04-04.
Para saber quién hizo el cambio existen además @CreatedBy y @LastModifiedBy, que requieren un AuditorAware<String> con el usuario actual. Como no habrá usuario autenticado hasta el módulo 5, queda anotado como trabajo futuro.
- Bloqueo optimista con
@Version
@VersionEn 03-03 resolvimos las actualizaciones perdidas con ETag e If-Match, apoyados en ShallowEtagHeaderFilter. Aquella solución funcionaba en la capa HTTP, pero calculaba el ETag a partir del cuerpo de la respuesta, es decir, después de haber hecho todo el trabajo. JPA ofrece algo mucho mejor: bloqueo optimista en la capa de datos.
Cómo funciona: al cargar la estación 1, Hibernate lee también version = 7; al actualizarla, genera un UPDATE que lleva la versión en la cláusula WHERE y la incrementa.
Si otro usuario ya la modificó, su versión es 8 y el UPDATE afecta a 0 filas. Hibernate lo detecta y lanza OptimisticLockException, que Spring traduce a ObjectOptimisticLockingFailureException.
sequenceDiagram
participant A as Operador A
participant B as Operador B
participant BD as PostgreSQL
A->>BD: leer estación 1 (version=7)
B->>BD: leer estación 1 (version=7)
A->>BD: UPDATE ... WHERE id=1 AND version=7
BD-->>A: 1 fila -> ahora version=8
B->>BD: UPDATE ... WHERE id=1 AND version=7
BD-->>B: 0 filas
Note over B: OptimisticLockException -> HTTP 409
Se llama «optimista» porque no bloquea nada: apuesta a que los conflictos son raros y solo los detecta cuando ocurren. Frente al ETag:
| Aspecto | ETag/If-Match (03-03) |
@Version (JPA) |
|---|---|---|
| Dónde vive | Capa HTTP | Capa de datos |
| Qué protege | Una petición HTTP concreta | Toda escritura, venga de donde venga |
| Coste | Serializar y calcular un hash | Una columna bigint |
| Detecta conflictos entre hilos internos | No | Sí |
La conclusión práctica: @Version sustituye al ShallowEtagHeaderFilter. Puede seguir generándose una cabecera ETag a partir del número de versión —es limpio y barato—, pero la protección real ya no depende de HTTP.
Solo falta cerrar el círculo en el manejador global de 03-06:
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ProblemDetail manejarConflictoConcurrencia(
ObjectOptimisticLockingFailureException ex) {
ProblemDetail problema = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"El recurso fue modificado por otro usuario. Recarga y vuelve a intentarlo.");
problema.setTitle("Conflicto de concurrencia");
problema.setType(URI.create("https://api.ciclourbana.es/errores/conflicto-concurrencia"));
problema.setProperty("codigo", "CONFLICTO_CONCURRENCIA");
return problema;
}El cliente recibe un 409 con el mismo formato RFC 7807 que el resto de errores de CicloUrbana. En 04-07 compararemos este bloqueo con el pesimista, que sí bloquea filas y es necesario en la carrera por la última bicicleta de una estación.
equals y hashCode en entidades JPA
equals y hashCode en entidades JPAEste apartado corrige uno de los errores más extendidos. La tentación es dejar que el IDE genere equals/hashCode con todos los campos, o solo con el id. Ambas opciones fallan.
El problema del id generado. Una entidad nueva tiene id = null. Si la metes en un HashSet y luego la guardas, el id pasa a valer 42, su hashCode cambia y el objeto queda perdido dentro del conjunto: contains devuelve false aunque esté ahí, porque se busca en un cubo distinto.
Set<Estacion> conjunto = new HashSet<>();
Estacion estacion = new Estacion("Universidad", "Av. Universidad 1", 36);
conjunto.add(estacion); // hashCode calculado con id = null
estacionRepositorio.save(estacion); // ahora id = 4: el hashCode ha cambiado
conjunto.contains(estacion); // false. La entidad está y no se encuentraUsar todos los campos tampoco vale, porque el equals cambiaría cada vez que se modifica un atributo, con el mismo efecto sobre las colecciones, y porque conceptualmente es falso: la estación 1 sigue siendo la misma aunque le cambien el nombre.
La solución recomendada, y la que adopta CicloUrbana:
@Override
public boolean equals(Object o) {
if (this == o) return true;
// Hibernate.getClass desenvuelve el proxy de la carga perezosa
if (o == null || !Hibernate.getClass(this).equals(Hibernate.getClass(o))) return false;
return id != null && id.equals(((Estacion) o).getId());
}
@Override
public int hashCode() { // constante: estable durante toda la vida del objeto
return getClass().hashCode();
}Las tres decisiones y su porqué:
hashCodeconstante. Parece una herejía —todas las entidades caen en el mismo cubo—, pero el contrato solo exige que objetos iguales tengan el mismo hash, y una colección de entidades en memoria rara vez pasa de unas decenas de elementos: el coste es despreciable frente a la corrección.id != null && ...: dos entidades nuevas (ambas con idnull) nunca son iguales entre sí, solo consigo mismas porthis == o. Son dos estaciones distintas aún sin guardar, y esa es la semántica correcta.Hibernate.getClass()en lugar degetClass(): con carga perezosa el objeto puede ser un proxy de una subclase generada, y comparargetClass()haría que una entidad y su propio proxy resultaran distintos.
Si prefieres no depender de Hibernate en el dominio, la alternativa profesional es una clave de negocio inmutable: matricula en Bicicleta, correo en Usuario. Es estable desde la creación y no depende del id generado. Solo sirve si existe un atributo realmente único e inmutable.
- Entidades y DTOs: la separación se mantiene
En 03-05 separamos dominio y contrato con DTOs. Ahora que el dominio son entidades JPA, esa separación pasa de recomendable a imprescindible por tres motivos nuevos: las referencias circulares (en 04-04, Estacion tendrá una lista de Bicicleta y cada Bicicleta una referencia a su Estacion, y serializar eso es un bucle infinito); la carga perezosa, que con open-in-view: false lanza LazyInitializationException al serializar fuera de la transacción; y las consultas ocultas que la serialización puede disparar durante la generación del JSON.
Lo bueno es que no hay que cambiar nada: CrearEstacionRequest, EstacionResponse, EstacionDetalleResponse y EstacionMapper siguen siendo válidos. Solo el mapeador se adapta al cambio de record a clase:
package com.ciclourbana.estaciones;
@Mapper(componentModel = "spring")
public interface EstacionMapper {
@Mapping(target = "latitud", source = "ubicacion.latitud")
@Mapping(target = "longitud", source = "ubicacion.longitud")
EstacionResponse aRespuesta(Estacion estacion);
@Mapping(target = "id", ignore = true)
@Mapping(target = "version", ignore = true)
@Mapping(target = "creadoEn", ignore = true)
@Mapping(target = "modificadoEn", ignore = true)
@Mapping(target = "activa", constant = "true")
@Mapping(target = "ubicacion", source = ".", qualifiedByName = "aUbicacion")
Estacion aEntidad(CrearEstacionRequest peticion);
@Named("aUbicacion")
default Ubicacion aUbicacion(CrearEstacionRequest p) {
return new Ubicacion(p.latitud(), p.longitud());
}
}Los ignore = true son deliberados y merecen explicación: el id lo genera la secuencia, la versión la gestiona Hibernate y las fechas de auditoría el AuditingEntityListener. Que MapStruct los tocara sería, en el mejor caso, inútil, y en el peor, una fuente de conflictos de bloqueo optimista fantasma.
Errores Comunes y Consejos
Dejar @Enumerated sin especificar. El valor por defecto es ORDINAL y el fallo es silencioso y catastrófico. Escribe siempre @Enumerated(EnumType.STRING).
Usar double para importes. Los céntimos se pierden y el descuadre aparece semanas después sin origen identificable. BigDecimal con precision/scale, siempre.
Generar equals/hashCode con el IDE. Produce un hashCode que cambia al guardar y entidades que se pierden en los HashSet. Usa el patrón del apartado 11.
Elegir IDENTITY por costumbre. Con PostgreSQL desactiva la inserción por lotes. SEQUENCE con allocationSize es la opción correcta, cuidando que el incremento coincida con el de la secuencia real.
Olvidar el constructor sin argumentos. El error es críptico: No default constructor for entity. Si además añades un constructor con parámetros, Java deja de generarlo automáticamente.
Poner @Transient de java.beans. Compila y no hace nada; el campo se persiste igual. Verifica el import: jakarta.persistence.Transient.
Consejo: @Version en toda entidad que se modifique. Cuesta una columna y previene toda una familia de errores de concurrencia. En CicloUrbana la llevan Estacion, Bicicleta y Alquiler.
Consejo: revisa el DDL que genera Hibernate. Abre la consola de H2 y lanza SHOW COLUMNS FROM estaciones: encontrarás varchar(255) donde esperabas varchar(80) y descubrirás qué anotación falta.
Consejo: no pongas lógica de negocio pesada en la entidad, pero tampoco la dejes anémica. Métodos como estacion.tieneEspacioLibre() o bicicleta.puedeAlquilarse() pertenecen a la entidad; orquestar un alquiler completo pertenece al servicio.
Ejercicios
Ejercicio 1: convertir Bicicleta en entidad
Convierte la clase Bicicleta de CicloUrbana en una entidad JPA completa. Requisitos: tabla bicicletas; matrícula única, obligatoria, de 10 caracteres y no modificable; EstadoBicicleta como texto; nivel de batería entre 0 y 100 y no nulo; identificador por secuencia con reserva de 50; bloqueo optimista; auditoría heredada; e índice por estado. No incluyas todavía la relación con Estacion.
Ejercicio 2: encontrar cinco errores
Esta entidad Alquiler tiene cinco defectos graves. Encuéntralos y corrígelos.
@Entity
public class Alquiler {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated
private EstadoAlquiler estado;
private double importe;
private LocalDateTime inicio;
public Alquiler(Long usuarioId, Long bicicletaId) {
this.usuarioId = usuarioId;
this.bicicletaId = bicicletaId;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Alquiler otro)) return false;
return Objects.equals(id, otro.id)
&& Objects.equals(importe, otro.importe)
&& Objects.equals(inicio, otro.inicio);
}
@Override
public int hashCode() {
return Objects.hash(id, importe, inicio);
}
}Ejercicio 3: bloqueo optimista de extremo a extremo
Describe qué ocurre exactamente, paso a paso y con el SQL implicado, cuando dos operarios del ayuntamiento de Ribalta actualizan la capacidad de la estación «Estación Norte» (version = 3) simultáneamente. Indica qué recibe cada uno y qué debería hacer el cliente.
Soluciones
Solución 1.
package com.ciclourbana.bicicletas;
import com.ciclourbana.comun.EntidadAuditable;
import jakarta.persistence.*;
@Entity
@Table(
name = "bicicletas",
uniqueConstraints = @UniqueConstraint(name = "uk_bicicletas_matricula",
columnNames = "matricula"),
indexes = @Index(name = "idx_bicicletas_estado", columnList = "estado")
)
public class Bicicleta extends EntidadAuditable {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "bicicletas_seq")
@SequenceGenerator(name = "bicicletas_seq", sequenceName = "bicicletas_id_seq",
allocationSize = 50)
private Long id;
@Column(name = "matricula", nullable = false, length = 10, updatable = false)
private String matricula;
@Enumerated(EnumType.STRING)
@Column(name = "estado", nullable = false, length = 20)
private EstadoBicicleta estado = EstadoBicicleta.DISPONIBLE;
@Min(0) @Max(100)
@Column(name = "nivel_bateria", nullable = false)
private Integer nivelBateria;
@Version
@Column(name = "version", nullable = false)
private Long version;
protected Bicicleta() { }
public Bicicleta(String matricula, Integer nivelBateria) {
this.matricula = matricula;
this.nivelBateria = nivelBateria;
}
public boolean puedeAlquilarse(int umbralBateria) {
return estado == EstadoBicicleta.DISPONIBLE && nivelBateria >= umbralBateria;
}
// Accesores de lectura para todo; setter solo en estado y nivelBateria.
// equals/hashCode con el patrón del apartado 11 (id no nulo + hash constante).
}El rango 0-100 se declara con @Min(0) @Max(100) de Bean Validation (03-04), que Hibernate traduce además a una restricción CHECK al generar el esquema; en 04-08 la escribiremos explícitamente en el SQL de Flyway. El método puedeAlquilarse recibe el umbral desde RedProperties (ciclourbana.red.umbral-bateria), configurado en 02-05.
Solución 2. Los cinco defectos:
- Falta
@Table(name = "alquileres"). Sin él, el nombre físico depende de la estrategia de nomenclatura. Añádelo, con sus índices porusuario_idybicicleta_id. @EnumeratedsinEnumType.STRING. Se guarda el ordinal. ReordenarEstadoAlquilercorrompería todo el histórico de alquileres de Ribalta.double importe. Error de redondeo acumulado en las facturas. Debe serBigDecimalcon@Column(precision = 8, scale = 2).- Falta el constructor sin argumentos. Al declarar uno con parámetros, Java ya no genera el implícito, y Hibernate falla al arrancar con
No default constructor for entity. equals/hashCodesobre campos mutables. ElhashCodecambia cuando se calcula el importe al finalizar el alquiler, y la entidad se pierde en cualquierHashSet.
Defectos menores igualmente reprochables: IDENTITY en lugar de SEQUENCE, LocalDateTime en vez de Instant, y ausencia de @Version —especialmente necesaria en un alquiler, que se modifica al menos dos veces—. Versión corregida:
@Entity
@Table(name = "alquileres",
indexes = @Index(name = "idx_alquileres_usuario", columnList = "usuario_id"))
public class Alquiler extends EntidadAuditable {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "alquileres_seq")
@SequenceGenerator(name = "alquileres_seq", sequenceName = "alquileres_id_seq",
allocationSize = 50)
private Long id;
@Enumerated(EnumType.STRING)
@Column(name = "estado", nullable = false, length = 20)
private EstadoAlquiler estado;
@Column(name = "importe", precision = 8, scale = 2) // nunca double
private BigDecimal importe;
@Column(name = "inicio", nullable = false, updatable = false)
private Instant inicio; // nunca LocalDateTime
@Version @Column(name = "version", nullable = false)
private Long version;
protected Alquiler() { } // requerido por JPA
// equals/hashCode con el patrón del apartado 11
}Solución 3. Ambos operarios abren la ficha de «Estación Norte» y reciben version = 3.
Operario A envía PUT /api/v1/estaciones/2 con capacidad 34. Su transacción carga la entidad (version = 3), la modifica y al hacer commit Hibernate ejecuta UPDATE estaciones SET capacidad=34, version=4 WHERE id=2 AND version=3. La base de datos informa de 1 fila afectada, el commit prospera y A recibe 200 OK.
Operario B envía su PUT con capacidad 28 unos segundos después, pero su copia sigue en version = 3, así que Hibernate ejecuta UPDATE estaciones SET capacidad=28, version=4 WHERE id=2 AND version=3. Ahora la fila tiene version = 4, el WHERE no encuentra nada: 0 filas afectadas. Hibernate compara el recuento esperado con el real, lanza StaleObjectStateException y la transacción hace rollback. Spring la traduce a ObjectOptimisticLockingFailureException, y el @RestControllerAdvice de 03-06 la convierte en:
{
"type": "https://api.ciclourbana.es/errores/conflicto-concurrencia",
"title": "Conflicto de concurrencia", "status": 409,
"detail": "El recurso fue modificado por otro usuario. Recarga y vuelve a intentarlo.",
"codigo": "CONFLICTO_CONCURRENCIA", "instancia": "/api/v1/estaciones/2"
}Qué debería hacer el cliente: ante un 409, recargar el recurso, mostrar al operario B la versión actual (capacidad 34, puesta por A) y pedirle que confirme o rehaga su cambio. Lo que nunca debe hacer es reintentar automáticamente con los mismos datos: eso sobrescribiría el trabajo de A, que es exactamente el problema que el mecanismo evita.
Fíjate en que la protección funciona sin cabeceras HTTP. Si el cambio de B llegara por un proceso interno, una tarea programada o una consola de administración, el conflicto se detectaría igual. Ese es el salto respecto al ETag de 03-03.
Conclusión
El dominio de CicloUrbana ya vive en tablas. Sabes por qué un record no puede ser entidad —constructor sin argumentos, campos mutables, clase no final— y, más importante, por qué conceptualmente un record es un objeto de valor mientras una entidad tiene identidad propia; por eso los DTOs de 03-05 siguen siendo record y el dominio pasa a ser clases mutables. Has mapeado tablas con @Entity y @Table, con sus índices y restricciones de unicidad nombradas. Has elegido SEQUENCE con allocationSize = 50 sabiendo exactamente qué se gana: inserción por lotes, imposible con IDENTITY. Controlas la columna con @Column y su length, nullable, precision/scale y updatable. Tienes claro el mapeo de cada tipo, incluidas las dos reglas que más disgustos evitan: BigDecimal para el dinero y Instant para las marcas de tiempo. Y sabes que @Enumerated(EnumType.STRING) no es una preferencia estética, sino la única forma de que reordenar un enumerado no corrompa el histórico de Ribalta.
Has agrupado la latitud y la longitud en un Ubicacion embebido, reutilizable en Bicicleta y duplicable en Alquiler con @AttributeOverride. Tienes auditoría automática mediante @MappedSuperclass, @EntityListeners y @EnableJpaAuditing, sin repetir un solo campo. Has estrenado @Version, entendiendo que el bloqueo optimista protege toda escritura y no solo las que pasan por HTTP, con lo que el ShallowEtagHeaderFilter de 03-03 queda jubilado y el 409 se genera desde el manejador global de 03-06. Y conoces el patrón correcto de equals/hashCode para entidades, con su hashCode constante y su comparación por id no nulo, que evita que las entidades se pierdan dentro de un HashSet al guardarlas.
Pero las entidades están aisladas. Una Bicicleta no sabe en qué estación está; un Alquiler guarda usuarioId y bicicletaId como números sueltos, sin ninguna garantía de que apunten a algo real. El modelo relacional que dibujamos en 04-01 tiene flechas, y todavía no hemos trazado ninguna. La lección 04-04, Relaciones entre Entidades, las traza: las cuatro cardinalidades con su anotación, @ManyToOne y el lado propietario, @OneToMany con mappedBy y los métodos que sincronizan ambos lados, @OneToOne con @MapsId, @ManyToMany y por qué casi siempre conviene sustituirlo por una entidad intermedia. Y con ellas llegan los dos problemas que definen el trabajo diario con un ORM: la LazyInitializationException y el N+1.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
