Java EE / Jakarta EE
Jakarta Persistence (JPA 3.1): entidades, mapeo y contexto de persistencia
Intermedio11 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Jakarta Persistence es el estándar de mapeo objeto-relacional que implementan Hibernate y EclipseLink, y la capa de persistencia sobre la que se apoya Spring Data JPA. Esta guía explica la unidad de persistencia y el EntityManager, los estados por los que pasa una entidad, cómo mapear identificadores, columnas, valores embebidos y relaciones, qué hacen de verdad la carga lazy y las cascadas, y cómo el bloqueo optimista protege las actualizaciones, junto con las trampas que provocan consultas N+1, LazyInitializationException y actualizaciones perdidas.
Lo que aprenderás
- Configurar una unidad de persistencia y obtener un EntityManager gestionado por el contenedor
- Describir el ciclo de vida de una entidad y qué hacen persist, merge, remove, detach y flush
- Mapear identificadores, atributos básicos, embeddables, enums y tipos de java.time
- Mapear asociaciones one-to-many, many-to-one, one-to-one y many-to-many con el lado propietario correcto
- Elegir de forma deliberada estrategias de carga, cascadas, mapeos de herencia y bloqueo optimista
Las bases de datos relacionales almacenan filas; los programas Java trabajan con grafos de objetos. Cualquier aplicación que use ambos necesita un mapeo entre ellos, y escribirlo a mano (SQL para cada operación, código que convierte un result set en objetos, control de la identidad) es repetitivo y propenso a errores. Jakarta Persistence (JPA) estandariza ese mapeo y el runtime que lo aplica, de modo que declaras una sola vez cómo se corresponde Order con la tabla orders y a partir de ahí trabajas con objetos. Pero ese runtime no hace magia: un EntityManager sigue la pista de los objetos que ha cargado, decide cuándo volver a escribirlos y lanza SQL que tú no has escrito. La mayoría de los problemas con JPA vienen de no saber qué está haciendo por ti; esta guía lo hace visible.
Unidades de persistencia y el EntityManager
Una unidad de persistencia es un conjunto con nombre de clases de entidad junto con la configuración necesaria para llegar a una base de datos, y se declara en META-INF/persistence.xml. Dentro de un contenedor, la fuente de datos es una referencia JNDI y las transacciones son de JTA:
<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence
https://jakarta.ee/xml/ns/persistence/persistence_3_0.xsd"
version="3.0">
<persistence-unit name="shop" transaction-type="JTA">
<jta-data-source>java:/jdbc/ShopDS</jta-data-source>
<!-- entity classes are discovered by scanning the archive; list them only outside a container -->
<properties>
<property name="jakarta.persistence.schema-generation.database.action" value="none"/>
<property name="hibernate.show_sql" value="false"/> <!-- provider-specific; harmless if the provider is EclipseLink -->
</properties>
</persistence-unit>
</persistence>
transaction-type="RESOURCE_LOCAL", con las propiedades jakarta.persistence.jdbc.url, user y password, es la variante independiente que se usa en tests y programas Java SE; en ella creas un EntityManagerFactory con Persistence.createEntityManagerFactory("shop") y gestionas tú mismo las EntityTransaction. Dentro de un servidor Jakarta EE inyectas el gestor y el contenedor se encarga del resto:
@PersistenceContext(unitName = "shop")
EntityManager em; // container-managed, transaction-scoped: one persistence context per JTA transaction
El EntityManager es una fachada sobre un contexto de persistencia: un mapa que asocia una identidad (clase de entidad más clave primaria) con la única instancia managed de esa fila. Dentro de un mismo contexto, llamar dos veces a em.find(Order.class, 1L) devuelve el mismo objeto, y ninguna fila está representada dos veces. Un contexto con ámbito de transacción vive exactamente lo mismo que la transacción JTA y se hace flush (los cambios pendientes se convierten en SQL) justo antes del commit. Los contextos gestionados por la aplicación y los extendidos existen para casos especiales (beans stateful, procesos batch), pero el de ámbito de transacción es el predeterminado y el que conviene entender primero.
El ciclo de vida de una entidad
Una instancia de entidad está siempre en uno de cuatro estados, y los métodos del EntityManager la mueven de uno a otro:
| Estado | Significado | Cómo se llega |
|---|---|---|
| New (transient) | Un objeto normal; el contexto no sabe nada de él | new Order() |
| Managed | Lo sigue el contexto; sus cambios se detectan y se escriben en el flush | persist, find, getReference, el resultado de una consulta, el valor que devuelve merge |
| Detached | Estuvo managed en un contexto que ya está cerrado, o se desvinculó explícitamente | Fin de la transacción, detach, clear, serialización |
| Removed | Programado para un DELETE en el flush | remove sobre una instancia managed |
La consecuencia más importante es el dirty checking automático: no llamas a update. Cargas una entidad, cambias un campo dentro de la transacción y el UPDATE se produce en el flush, incluso en entidades que cargaste mediante una consulta y modificaste de paso. merge es el puente para volver de detached a managed, con un detalle que todo el mundo falla alguna vez: devuelve la copia managed y deja el argumento detached.
@Transactional
public Order rename(Long id, String newRef) {
Order order = em.find(Order.class, id); // managed
order.setReference(newRef); // dirty; UPDATE issued at flush, no explicit call
return order; // becomes detached when the transaction commits
}
@Transactional
public Order update(Order detachedFromForm) {
Order managed = em.merge(detachedFromForm); // copies state onto the managed instance
managed.setUpdatedAt(Instant.now()); // affects the row; detachedFromForm is untouched
return managed;
}
getReference devuelve un proxy sin tocar la base de datos, ideal cuando solo necesitas asignar una clave foránea. refresh recarga desde la base de datos y descarta los cambios. flush fuerza la salida del SQL antes de tiempo (una consulta con FlushModeType.AUTO, el valor por defecto, hace flush de todos modos cuando podría leer cambios pendientes), y clear desvincula todo, que es como los procesos batch mantienen la memoria acotada.
Mapear una clase a una tabla
Una entidad es una clase no final con un constructor sin argumentos (como mínimo protected), anotada con @Entity y con un identificador. Los campos se mapean por defecto (todo campo no transient se convierte en una columna con su nombre), así que las anotaciones solo hacen falta donde el valor por defecto no sirve.
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import jakarta.persistence.*;
@Entity
@Table(name = "orders", // "order" is a reserved word in SQL
indexes = @Index(name = "ix_orders_customer", columnList = "customer_id"))
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "orders_seq")
@SequenceGenerator(name = "orders_seq", sequenceName = "orders_seq", allocationSize = 50)
private Long id;
@Column(nullable = false, length = 20, unique = true)
private String reference;
@Enumerated(EnumType.STRING) // never ORDINAL: reordering the enum corrupts data
@Column(nullable = false, length = 16)
private OrderStatus status = OrderStatus.NEW;
@Column(nullable = false)
private Instant placedAt = Instant.now(); // java.time maps natively since JPA 2.2
@Embedded
private Address shippingAddress; // columns inlined into the orders table
@Convert(converter = MoneyConverter.class) // custom type <-> column, via AttributeConverter
private Money total;
@Version
private long version; // optimistic locking, see below
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("lineNo")
private List<OrderLine> lines = new ArrayList<>();
protected Order() { } // required by the specification
public Order(Customer customer) { this.customer = customer; }
public void addLine(Product product, int quantity) {
var line = new OrderLine(this, product, quantity, lines.size() + 1);
lines.add(line); // keep both sides consistent
}
// getters; setters only where the domain allows change
}
Identificadores
Elige la estrategia de generación pensando en la base de datos:
SEQUENCE: la mejor opción por defecto para PostgreSQL, Oracle y cualquier base de datos con secuencias. ConallocationSizeel proveedor obtiene un bloque de ids de una vez, de modo que cada inserción no necesita su propio viaje de ida y vuelta. Mantén el tamaño de asignación igual alINCREMENT BYde la secuencia; si no, tendrás huecos y, con algunos proveedores, colisiones.IDENTITY: columnas autoincrementales (MySQL, SQL Server). Es sencilla, pero el id solo se conoce después delINSERT, lo que obliga al proveedor a insertar inmediatamente en elpersisty desactiva el batching de inserciones.UUID: novedad de Persistence 3.1; el proveedor genera unjava.util.UUID. Los ids son únicos globalmente y pueden asignarse en el cliente, a costa de claves de 16 bytes y de una peor localidad en los índices de algunas bases de datos.TABLE: portable pero lenta; evítala.AUTO: deja elegir al proveedor; es mejor ser explícito, porque la elección afecta al batching y al esquema.
Las claves compuestas se admiten con @EmbeddedId o @IdClass; aparecen en esquemas heredados y conviene evitarlas en los nuevos.
Atributos básicos, embeddables y conversores
@Column controla el nombre, la nulabilidad, la longitud y la precisión; @Basic(fetch = LAZY) sobre un @Lob grande pide al proveedor que retrase su carga, aunque no todos lo respetan sin instrumentación del bytecode. @Transient excluye un campo. Las clases @Embeddable agrupan columnas que van juntas (una dirección, un importe) sin tabla propia, y @AttributeOverride renombra sus columnas cuando el mismo embeddable aparece dos veces. Un AttributeConverter<X, Y> mapea cualquier tipo Java a un tipo de columna soportado por JDBC y es la forma estándar de guardar value objects, cadenas cifradas o blobs JSON. @Temporal solo hace falta para los antiguos campos java.util.Date; usa LocalDate, LocalDateTime, Instant u OffsetDateTime.
Relaciones y el lado propietario
Las asociaciones son donde los mapeos se tuercen. La regla que despeja la mayor parte de la confusión: la base de datos tiene una sola clave foránea, así que un solo lado de la asociación es su propietario, y solo se escriben los cambios hechos en el lado propietario.
@ManyToOnees siempre el lado propietario; la clave foránea vive en su tabla.@JoinColumnle da nombre.@OneToMany(mappedBy = "order")es el lado inverso. Añadir una línea solo aorder.getLines()no escribe nada; lo que asigna la clave foránea esline.setOrder(order). Los métodos auxiliares comoaddLinemantienen ambos lados sincronizados.- Un
@OneToManyunidireccional sinmappedByusa por defecto una tabla de unión, algo que pilla a más de uno por sorpresa; añade@JoinColumnpara usar en su lugar una clave foránea en la tabla hija. @OneToOnecoloca la clave foránea en el lado propietario; haz lazy el lado inverso solo si aceptas que el proveedor quizá tenga que consultar igualmente para saber si el destino existe.@ManyToManyusa una tabla de unión en ambos lados;@JoinTablele da nombre. En cuanto la tabla de unión necesita columnas propias (una fecha, una cantidad), pasa a ser una entidad por derecho propio con dos asociaciones@ManyToOne.
Carga (fetching)
Valores por defecto: @ManyToOne y @OneToOne son EAGER y las colecciones son LAZY. En la mayoría de las aplicaciones estos valores pecan de eager: un many-to-one eager en cada entidad significa que cada consulta arrastra a sus clientes, sus direcciones, y así sucesivamente. Declara fetch = FetchType.LAZY en las asociaciones to-one y carga explícitamente lo que necesita cada caso de uso con JOIN FETCH o con un entity graph, ambos explicados en la guía de consultas. La carga lazy solo funciona mientras el contexto está abierto, y por eso acceder a order.getLines() en una vista después de que termine la transacción lanza LazyInitializationException en Hibernate. El problema N+1 es su reflejo: recorrer cien pedidos tocando getCustomer() en cada uno lanza cien selects adicionales. Ambos se arreglan en la consulta, no pasándolo todo a eager.
Cascadas y eliminación de huérfanos
cascade propaga una operación del EntityManager a lo largo de una asociación: CascadeType.PERSIST en lines significa que al persistir el pedido se persisten sus líneas nuevas. ALL es adecuado para una composición (las líneas no existen sin su pedido) y peligroso para cualquier cosa compartida: cascade = ALL en Order.customer borraría el cliente al eliminar un pedido. orphanRemoval = true además borra un hijo cuando se quita de la colección, lo que convierte order.getLines().remove(line) en un DELETE. Juntos te permiten tratar un agregado como una única unidad.
Herencia y mapeos compartidos
Las entidades pueden heredar de otras entidades, y JPA ofrece tres disposiciones de tablas:
| Estrategia | Tablas | Compromiso |
|---|---|---|
SINGLE_TABLE (por defecto) | Una tabla para toda la jerarquía con una columna discriminadora | Las consultas más rápidas, sin joins; las columnas de las subclases deben admitir NULL |
JOINED | Una tabla por clase, unidas por la clave primaria | Normalizado, sin nulos forzados; cada consulta polimórfica hace joins |
TABLE_PER_CLASS | Una tabla completa por clase concreta | Sin joins en las consultas sobre una clase concreta; las polimórficas necesitan UNION, y la generación de identificadores está restringida |
Declara la estrategia con @Inheritance en la raíz y @DiscriminatorValue en las subclases. Muchas veces lo que quieres no es herencia de entidades sino una @MappedSuperclass: una clase base como AuditedEntity con id, version, createdAt y updatedAt, cuyos campos se mapean en la tabla de cada subclase pero que no se puede consultar por sí misma.
Bloqueo optimista con @Version
Dos usuarios cargan el mismo pedido, ambos lo modifican y ambos guardan: sin protección, la segunda escritura sobrescribe en silencio la primera, la llamada actualización perdida (lost update). Un campo @Version (un número o una marca de tiempo) hace que el proveedor añada WHERE version = ? a cada UPDATE e incremente la versión; si no se ve afectada ninguna fila, es que la fila cambió mientras tanto y se lanza OptimisticLockException en el flush o en el commit. Toda entidad que pueda editarse de forma concurrente debería llevar uno. Haz que la versión viaje hasta el cliente (un campo oculto en un formulario, un campo en un payload JSON) y vuelva al merge; de lo contrario, la comprobación compara la versión recién cargada consigo misma y no protege nada. El bloqueo pesimista (em.find(Order.class, id, LockModeType.PESSIMISTIC_WRITE), es decir, un SELECT … FOR UPDATE) es la alternativa para actualizaciones cortas y muy disputadas, como decrementar un stock.
Proveedores y generación del esquema
Hibernate ORM 6.x y EclipseLink 4.x implementan Persistence 3.1; ambos vienen incluidos en los principales servidores (Hibernate en WildFly, EclipseLink en GlassFish y Payara, cualquiera de los dos en Open Liberty). Si te ciñes a la API de JPA podrás cambiar de proveedor; usa las extensiones a sabiendas: @BatchSize, @Formula, los natural ids y el HQL más rico de Hibernate son útiles, pero cada uno tiene un coste en portabilidad.
La propiedad estándar jakarta.persistence.schema-generation.database.action (none, create, drop, drop-and-create) puede generar las tablas a partir de tus mapeos; la propiedad propia de Hibernate, hibernate.hbm2ddl.auto, añade update y validate. La generación es cómoda para tests y prototipos, y una mala forma de llevar producción: el DDL generado carece de las restricciones, las decisiones de índices y el historial de migraciones que necesita un esquema real. Gestiona los esquemas de producción con scripts de migración versionados (Flyway, Liquibase), pon la propiedad estándar en none y usa el validate de Hibernate si quieres que los mapeos se comprueben contra el esquema real al arrancar.
Errores comunes
- Asociaciones to-one eager por todas partes. Cada consulta carga un árbol entero. Márcalas como
LAZYy carga según el caso de uso. - Tocar colecciones lazy fuera de la transacción.
LazyInitializationExceptionen vistas y serializadores. Cárgalas en la consulta o convierte a DTO dentro del servicio. - Olvidar el lado propietario. Añadir elementos a la colección
mappedByno escribe nada. Asigna el many-to-one o usa métodos auxiliares que hagan ambas cosas. EnumType.ORDINAL. Insertar una constante en mitad del enum reinterpreta todos los valores almacenados. UsaSTRING.cascade = ALLen entidades compartidas. Eliminar un pedido borra a su cliente. Aplica cascadas solo a lo largo de composiciones.- Un
hashCodebasado en un id generado. Cambia después depersist; las entidades desaparecen de los sets. Usa una clave de negocio o un UUID estable asignado en el constructor. allocationSizedistinto del incremento de la secuencia. Huecos en el mejor caso, errores de clave duplicada en el peor. Mantenlos iguales.- Generación del esquema en producción. Faltan restricciones y no hay historial de migraciones. Usa Flyway o Liquibase.
Puntos clave
- Una unidad de persistencia define las entidades y la fuente de datos; un
EntityManagergestionado por el contenedor te da un contexto de persistencia con el ámbito de la transacción JTA. - Las entidades son new, managed, detached o removed; las managed pasan por dirty checking y se escriben en el flush, y
mergedevuelve la copia managed en lugar de vincular tu objeto. - Mapea los identificadores con
SEQUENCEcuando la base de datos lo permita;IDENTITYdesactiva el batching yUUIDes estándar desde Persistence 3.1. - El lado propietario de una asociación contiene la clave foránea; mantén coherentes ambos lados de las asociaciones bidireccionales con métodos auxiliares.
- Haz lazy las asociaciones to-one y carga explícitamente según el caso de uso; aplica cascadas y eliminación de huérfanos solo a lo largo de composiciones.
- Usa
@MappedSuperclasspara los campos de auditoría compartidos, y la herencia de entidades solo cuando el modelo sea realmente polimórfico. - Añade
@Versiona las entidades editables y haz que viaje a través de la interfaz o la API para evitar actualizaciones perdidas.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Qué diferencia hay entre JPA e Hibernate?
- JPA (Jakarta Persistence) es la especificación: las anotaciones, la API de EntityManager, JPQL y la API Criteria. Hibernate ORM es su implementación más utilizada, junto a EclipseLink, que es la implementación compatible con la que se certifica la especificación. El código escrito contra la API de JPA funciona en cualquiera de las dos; las anotaciones propias de Hibernate y los métodos de Session, no. Desde Hibernate 6 ambas han convergido todavía más: el HQL de Hibernate es un superconjunto de JPQL.
- ¿Por qué me salta una LazyInitializationException?
- Accediste a una asociación lazy después de que se cerrara el contexto de persistencia que cargó la entidad, normalmente en una página de Faces o en un serializador REST, cuando la transacción ya había terminado. Arréglalo en la consulta: usa un JOIN FETCH o un entity graph para cargar dentro de la transacción lo que necesita la vista, o convierte el resultado en un DTO antes de devolverlo. Ponerlo todo en EAGER no es una solución: solo traslada el coste a todas las consultas.
- ¿Debo usar merge o persist para guardar una entidad?
- persist para un objeto nuevo que has creado con new: pasa a estar managed y se programa un INSERT. merge para un objeto detached (que viene de una transacción anterior, de un formulario o de una petición deserializada): copia su estado sobre una instancia managed y devuelve esa instancia, mientras que el objeto que pasaste sigue detached. Llamar a persist sobre una entidad detached lanza una excepción, y llamar a merge sobre una entidad nueva funciona, pero ejecuta un SELECT adicional.
- ¿Necesitan mis entidades equals y hashCode?
- Solo si metes entidades en un Set o las usas como claves de un mapa, algo que hacen los mapeos many-to-many y one-to-many con Set. Basa ambos métodos en una clave de negocio o en el identificador una vez asignado, nunca en todos los campos y nunca en un id generado antes de persist: el hash cambiaría al generarse el id y el objeto desaparecería de cualquier HashSet en el que ya estuviera.