Spring Boot y Spring Cloud
Spring Data JPA con Spring Boot: repositorios, consultas, transacciones y migraciones
Intermedio8 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
Sabrás persistir y consultar datos con Spring Data JPA en un servicio Spring Boot 3: definir entidades y repositorios, escribir consultas derivadas, JPQL y nativas, paginar y proyectar resultados, colocar bien las transacciones, evitar las trampas del N+1 y del open-in-view, y gestionar el esquema con Flyway en lugar de ddl-auto.
Lo que aprenderás
- Configurar un DataSource, JPA e Hibernate mediante propiedades de Spring Boot y saber qué valores por defecto cambiar
- Escribir interfaces de repositorio con consultas derivadas y métodos @Query y @Modifying
- Paginar, ordenar y proyectar resultados sin cargar grafos de entidades completos
- Colocar @Transactional en la capa adecuada y explicar qué cambia una transacción de solo lectura
- Detectar y corregir N+1 selects con fetch joins y @EntityGraph
- Gestionar los cambios de esquema con migraciones de Flyway en lugar de hibernate.ddl-auto
Spring Data JPA elimina la mayor parte del código que antes exigía el acceso a datos: una declaración de interfaz sustituye a una clase DAO, los nombres de los métodos sustituyen a muchas consultas y Spring Boot conecta el DataSource, Hibernate y el gestor de transacciones a partir de un puñado de propiedades. Esa misma comodidad facilita publicar un servicio que lanza cientos de consultas por petición sin que nadie lo note hasta que llega el tráfico. Esta página cubre la mecánica y, igual de importante, los puntos en los que hay que cambiar los valores por defecto. Está pensada para Spring Boot 3 con Spring Data JPA 3 e Hibernate 6, sobre Jakarta Persistence 3.1.
Lo que Boot configura por ti
Añadir spring-boot-starter-data-jpa y un driver JDBC trae un pool de conexiones (HikariCP), un EntityManagerFactory respaldado por Hibernate, un JpaTransactionManager y el escaneo de repositorios desde el paquete de la aplicación. La configuración mínima es una URL y unas credenciales:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/shop
username: shop
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 10 # ajústalo al número de conexiones que admite la base de datos
jpa:
open-in-view: false # ver más abajo
properties:
hibernate:
default_batch_fetch_size: 50
flyway:
enabled: true
Hay dos valores por defecto sobre los que conviene decidir desde el primer día. spring.jpa.open-in-view vale true por defecto y mantiene abierto el EntityManager durante toda la petición web para que las vistas y los serializadores JSON puedan disparar cargas lazy. Es cómodo, y precisamente por eso Boot registra un aviso al arrancar: oculta las explosiones de consultas y retiene una conexión a la base de datos mientras se escribe la respuesta. Desactívalo y carga explícitamente lo que necesites. spring.jpa.hibernate.ddl-auto vale por defecto create-drop para bases de datos embebidas y none en el resto de casos; déjalo en none y deja que las migraciones gestionen el esquema. spring.jpa.show-sql escribe el SQL en la salida estándar sin pasar por el framework de logging; mejor usa logging.level.org.hibernate.SQL=DEBUG (y org.hibernate.orm.jdbc.bind=TRACE en Hibernate 6 para ver los parámetros enlazados).
Entidades e identificadores
Una entidad es una clase mapeada a una tabla. El ejemplo siguiente muestra las decisiones que importan más que las propias anotaciones:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "orders_seq")
@SequenceGenerator(name = "orders_seq", sequenceName = "orders_seq", allocationSize = 50)
private Long id;
@Version
private long version; // bloqueo optimista
@Enumerated(EnumType.STRING) // nunca ORDINAL: reordenar el enum corrompe los datos
private OrderStatus status;
@ManyToOne(fetch = FetchType.LAZY) // las to-one son EAGER por defecto; sobrescríbelo
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
private Instant createdAt;
protected Order() {} // JPA necesita un constructor sin argumentos; que no sea público
// métodos de negocio, getters; sin setter para id ni version
}
SEQUENCE con un tamaño de asignación permite a Hibernate asignar ids por lotes y habilita el batching JDBC de los inserts; IDENTITY obliga a un viaje de ida y vuelta por fila y desactiva el batching de inserts. @Version convierte las actualizaciones concurrentes en una OptimisticLockException (que llega como ObjectOptimisticLockingFailureException de Spring) en lugar de actualizaciones perdidas en silencio. Todo @ManyToOne y @OneToOne debería ser LAZY: el EAGER que la especificación asigna por defecto a las asociaciones to-one es, con diferencia, la mayor fuente de joins accidentales.
No implementes equals/hashCode sobre ids de base de datos que son null hasta que la entidad se guarda; usa una clave de negocio o la identidad basada en la clase que describe la documentación de Hibernate. Los records no pueden ser entidades (son final e inmutables), pero son ideales para los DTO y las proyecciones que salen de la capa de persistencia.
Repositorios
Un repositorio es una interfaz; Spring Data genera la implementación al arrancar:
public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {
// consulta derivada: se analiza a partir del nombre del método al arrancar
List<Order> findByCustomerIdAndStatusOrderByCreatedAtDesc(Long customerId, OrderStatus status);
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
boolean existsByCustomerIdAndStatus(Long customerId, OrderStatus status);
// JPQL con un fetch join para evitar el N+1 sobre las líneas
@Query("select o from Order o join fetch o.lines where o.id = :id")
Optional<Order> findWithLines(@Param("id") Long id);
// actualización masiva: se salta el contexto de persistencia, de ahí clearAutomatically
@Modifying(clearAutomatically = true)
@Query("update Order o set o.status = :status where o.createdAt < :before and o.status = 'NEW'")
int expireStaleOrders(@Param("status") OrderStatus status, @Param("before") Instant before);
// SQL nativo cuando la base de datos lo hace mejor
@Query(value = """
select customer_id, count(*) as orders, sum(total_cents) as revenue_cents
from orders where created_at >= :since
group by customer_id order by revenue_cents desc limit :limit
""", nativeQuery = true)
List<CustomerRevenue> topCustomers(@Param("since") Instant since, @Param("limit") int limit);
}
JpaRepository extiende ListCrudRepository y ListPagingAndSortingRepository (las variantes que devuelven List, introducidas en Spring Data 3) y añade elementos específicos de JPA como flush() y saveAllAndFlush(). Si un repositorio no debe exponer deleteAll a todo el código, extiende Repository<T, ID> y declara solo los métodos que quieras.
Los nombres de las consultas derivadas se validan al arrancar, así que una errata falla enseguida, pero los nombres largos se vuelven ilegibles: pasadas tres condiciones, cambia a @Query. Las consultas JPQL también se validan al arrancar contra el modelo de entidades. Las nativas no, y esa es una razón más para que sean pocas y estén cubiertas por tests contra la base de datos real.
Specification (de JpaSpecificationExecutor) construye cláusulas where dinámicas con la API Criteria para pantallas de búsqueda con muchos filtros opcionales, y el Query by Example de Spring Data cubre las coincidencias por igualdad sencillas. Para cualquier cosa más elaborada, Querydsl o JPQL construido en un fragmento de repositorio personalizado resulta más claro que anidar specifications.
Paginación, ordenación y proyecciones
Pageable y Sort pasan sin cambios del controlador al repositorio; Page<T> incluye el total de elementos (a costa de una consulta count adicional), mientras que Slice<T> solo sabe si existe una página siguiente, lo que sale más barato para el scroll infinito.
Cargar entidades completas para devolver tres campos es un desperdicio. Las proyecciones basadas en interfaces permiten que la consulta seleccione solo lo declarado:
public interface OrderSummary {
Long getId();
OrderStatus getStatus();
Instant getCreatedAt();
CustomerName getCustomer(); // proyección anidada
interface CustomerName { String getName(); }
}
Page<OrderSummary> findByStatus(OrderStatus status, Pageable pageable);
Las proyecciones basadas en clases (DTO) usan un record y una expresión de constructor, y son las más eficientes porque Hibernate selecciona exactamente los argumentos del constructor:
public record CustomerRevenue(Long customerId, long orders, long revenueCents) {}
@Query("select new com.example.shop.report.CustomerRevenue(o.customer.id, count(o), sum(o.totalCents)) "
+ "from Order o where o.createdAt >= :since group by o.customer.id")
List<CustomerRevenue> revenueSince(@Param("since") Instant since);
Transacciones y contexto de persistencia
El SimpleJpaRepository de Spring Data está anotado con @Transactional(readOnly = true) a nivel de clase, con sobrescrituras de lectura-escritura en los métodos que modifican datos, de modo que cada llamada al repositorio es transaccional por sí misma. Las operaciones de negocio que abarcan varias llamadas necesitan su propio límite transaccional, y su sitio es la capa de servicio:
@Service
public class OrderService {
private final OrderRepository orders;
private final InventoryClient inventory;
@Transactional
public Order ship(long orderId) {
Order order = orders.findWithLines(orderId).orElseThrow(() -> new OrderNotFoundException(orderId));
order.markShipped(); // el dirty checking lo vuelca en el commit; no hace falta llamar a save()
inventory.release(order); // llamada remota dentro de una transacción: que sea breve, o muévela después del commit
return order;
}
@Transactional(readOnly = true)
public Page<OrderSummary> search(OrderStatus status, Pageable pageable) {
return orders.findByStatus(status, pageable);
}
}
Dentro de una transacción, el contexto de persistencia controla cada entidad cargada; los cambios se detectan en el flush y se escriben como sentencias UPDATE, así que llamar a save() sobre una entidad ya gestionada es redundante. readOnly = true pone el flush mode de Hibernate en MANUAL, se ahorra los snapshots del dirty checking y permite que algunos drivers y routers envíen la consulta a una réplica; úsalo en todas las rutas de lectura.
Reglas que ahorran tiempo de depuración: @Transactional solo funciona en métodos públicos invocados a través del proxy de Spring, así que una autoinvocación dentro de la misma clase no es transaccional; las excepciones runtime provocan rollback por defecto y las checked no (rollbackFor lo cambia); y un método @Transactional que captura una excepción de una llamada anidada al repositorio sigue haciendo rollback, porque el fallo interno marcó la transacción como rollback-only. La guía de Spring Framework sobre JDBC y transacciones trata a fondo la propagación y el aislamiento.
El problema N+1
Carga veinte pedidos y luego llama a order.getLines() en cada uno: una consulta para los pedidos más veinte para las líneas. Spring Data e Hibernate no pueden saber de antemano qué asociaciones va a tocar quien llama, así que la solución es decírselo:
- Fetch join en JPQL:
select o from Order o join fetch o.lines where .... Cargar dos colecciones en una misma consulta multiplica las filas (un producto cartesiano); Hibernate 6 elimina los duplicados, pero el número de filas transferidas sigue creciendo, así que carga una sola colección por consulta. @EntityGraphen un método del repositorio:@EntityGraph(attributePaths = {"lines", "customer"})se aplica a las consultas derivadas y afindById.- Batch fetching con
hibernate.default_batch_fetch_size: las colecciones lazy de las entidades ya cargadas se inicializan por lotes con una consultain (...)en lugar de una consulta por cada una. Es la mejora global más barata y funciona en todas partes. - Proyecciones que nunca llegan a cargar la colección.
Detecta el problema antes que tus usuarios: registra el SQL en los tests @DataJpaTest y cuenta las sentencias, o usa las estadísticas de Hibernate (spring.jpa.properties.hibernate.generate_statistics=true) en un entorno de staging y vigila el número de consultas por petición.
Migraciones de esquema con Flyway
Boot autoconfigura Flyway cuando flyway-core está en el classpath junto con el módulo específico de la base de datos (flyway-database-postgresql para PostgreSQL desde Flyway 10). Las migraciones son ficheros SQL en src/main/resources/db/migration nombrados por versión:
-- V3__add_order_version_and_status_index.sql
alter table orders add column version bigint not null default 0;
create index orders_status_created_at_idx on orders (status, created_at desc);
Flyway ejecuta las migraciones pendientes al arrancar, registra cada una en flyway_schema_history y se niega a arrancar si se ha editado un fichero ya aplicado (su checksum ha cambiado). Ese comportamiento es deliberado: las migraciones son historia inmutable, y una migración errónea se corrige con otra nueva. Mantén el ddl-auto de Hibernate en none en todos los entornos que usen migraciones; los dos mecanismos nunca deben gestionar el esquema a la vez. Poner spring.jpa.hibernate.ddl-auto=validate en los tests es una red de seguridad útil: Hibernate comprueba que el esquema migrado coincide con los mapeos de las entidades y hace fallar el build si se desalinean.
Liquibase es la alternativa equivalente, con changelogs en XML, YAML o SQL y un modelo de rollback más completo; Boot lo autoconfigura de la misma manera.
Errores comunes
Fiarse del EAGER por defecto en las asociaciones to-one. Cada findById arrastra al cliente, la dirección del cliente y todo lo que cuelgue de ella. Pon fetch = FetchType.LAZY explícitamente en cada @ManyToOne y @OneToOne.
Usar generación IDENTITY y preguntarse por qué los inserts por lotes son lentos. Cambia a SEQUENCE con allocationSize y configura spring.jpa.properties.hibernate.jdbc.batch_size=50 y hibernate.order_inserts=true.
Llamar a save() en un bucle para una actualización masiva. Usa un update JPQL con @Modifying o una sentencia nativa; mil entidades gestionadas y mil UPDATE no son un proceso batch.
Capturar una excepción dentro de una transacción y seguir adelante. La transacción ya está marcada como rollback-only; el commit fallará con UnexpectedRollbackException. Gestiona los fallos fuera del método transaccional o usa REQUIRES_NEW a conciencia.
Guardar los enums como ordinales. Insertar una constante nueva en medio del enum reasigna en silencio todas las filas almacenadas. Siempre EnumType.STRING.
Probar contra H2 en modo PostgreSQL y desplegar en PostgreSQL. El tratamiento de tipos, las secuencias, on conflict y JSON son distintos. Usa Testcontainers con el motor real para los tests de repositorio, como se muestra en la guía de testing.
Buenas prácticas
- Mantén las entidades en la capa de persistencia y mapéalas a records en el límite del servicio.
- Un límite
@Transactionalpor caso de uso, en el servicio;readOnly = trueen todas las rutas de consulta. - Haz lazy todas las asociaciones y luego carga de forma deliberada con fetch joins, entity graphs o batch fetching.
- Prefiere proyecciones para listados e informes; carga agregados completos solo para modificarlos.
- Gestiona el esquema con Flyway o Liquibase y valida los mapeos contra él en CI.
- Dimensiona el pool de HikariCP según la capacidad de la base de datos, no según el número de threads de la aplicación, y vigila los tiempos de espera del pool con las métricas de Actuator.
Puntos clave
- Boot configura HikariCP, Hibernate y las transacciones a partir de unas pocas propiedades; desactiva open-in-view y deja
ddl-autoennonesalvo en bases de datos desechables. - Los repositorios son interfaces: consultas derivadas para los casos simples, JPQL con
@Querypara el resto y SQL nativo solo cuando la base de datos ofrece algo que JPQL no puede. - Las transacciones van en los métodos de servicio; los métodos del repositorio ya son transaccionales por separado, y el dirty checking escribe los cambios sin llamadas explícitas a
save(). - Los N+1 selects vienen de asociaciones lazy que se tocan fuera de un plan de carga; corrígelos con fetch joins,
@EntityGraph, batch fetching o proyecciones. - Usa por defecto ids
SEQUENCE,@Versionpara el bloqueo optimista,EnumType.STRINGy asociaciones to-one lazy. - Las migraciones versionadas de Flyway, validadas contra el modelo de entidades en los tests, mantienen el esquema y el código sincronizados.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Necesito @Transactional en las llamadas al repositorio?
- No para llamadas sueltas: la implementación de repositorios de Spring Data ya envuelve cada método en una transacción (de solo lectura en las consultas). Sí necesitas @Transactional en un método de servicio que encadena varias llamadas al repositorio y necesita que todas tengan éxito o fallen juntas, o que modifica una entidad y confía en el dirty checking para volcar el cambio en el commit.
- ¿Por qué mi asociación lazy lanza LazyInitializationException?
- La entidad se cargó dentro de una transacción, la transacción terminó y cerró el contexto de persistencia, y después algún código tocó una colección o un proxy sin inicializar. Puedes cargar la asociación en la consulta (join fetch o @EntityGraph), mapear a un DTO dentro de la transacción o ampliar la transacción para que cubra el acceso. Activar open-in-view oculta el síntoma, pero no las consultas adicionales.
- ¿Es seguro spring.jpa.hibernate.ddl-auto=update en producción?
- No. Solo sabe añadir, nunca renombrar ni eliminar de forma segura; adivina los tipos de columna; ejecuta al arrancar DDL que nadie ha revisado con los privilegios de la aplicación, y no deja ningún historial para dar marcha atrás. Úsalo como mucho en bases de datos locales desechables y gestiona los esquemas reales con migraciones versionadas de Flyway o Liquibase.
- ¿Cuándo debo usar una consulta nativa en lugar de JPQL?
- Cuando necesitas algo que JPQL no puede expresar o que la base de datos hace mucho mejor: funciones de ventana, CTE recursivas, operadores JSON propios de un fabricante o un join ajustado a mano. Las consultas nativas pierden la portabilidad y la validación al arrancar contra el modelo de entidades, así que procura tener pocas, pruébalas contra el motor de base de datos real y mapea los resultados a una proyección o un DTO.