Curso Spring Data JPA. 4: transacciones con @Transactional

logo spring

A priori, la transaccionalidad parece un concepto demasiado avanzado como para introducirlo al principio del curso, incluso algo tangencial al tema principal. No obstante, resulta necesario conocerlo para trabajar de forma adecuada con JPA y, por extensión, con Spring Data JPA.

Al hablar de transaccionalidad, me refiero al hecho de ejecutar métodos dentro de una transacción. Y por transacción, a un grupo de operaciones que deben realizarse de manera conjunta. Es decir, o se ejecutan todas o ninguna.

En este capítulo trataremos sin profundizar demasiado el uso práctico de las transacciones en Spring Framework en lo que compete a JPA. Su empleo básico es sencillo y, en general, suficiente para los escenarios más habituales que encontraremos en nuestro día a día.

Índice

  1. La anotación @Transactional
  2. Excepciones
  3. Procesos transaccionales
  4. Un error muy común
  5. Operaciones de escritura y escritura
  6. Transacciones y JPA
    1. Transacción = contexto de persistencia
    2. Sincronización automática de entidades
  7. Dónde declarar las transacciones
  8. Resumen final
  9. Código de ejemplo


La anotación @Transactional

En Spring, al principio de los tiempos los programadores «ancestrales» recurrían a tediosas configuraciones en XML para configurar la transaccionalidad de los métodos. Esto implicaba trabajar con conceptos de programación orientadas a aspectos tales como advisors y pointcuts. Otra opción, peor todavía, era lidiar con las transacciones a mano en el código.

Por suerte, esta etapa duró poco. Si bien los mecanismos para dotar de transaccionalidad a Spring no cambiaron, la llegada de Java 5 y sus anotaciones simplificó sobremanera su empleo. Con la anotación @Transactional (*) definimos el comportamiento de un método público de un bean de Spring en lo que respecta a las transacciones. Y dado que Spring Boot configura un gestor de transacciones (transaction manager) no tenemos que hacer nada para disfrutar de esta cómoda y poderosa anotación. 👏

(*) No confundas @org.springframework.transaction.annotation.Transactional con @jakarta.transaction.Transactional

Lo más importante de @Transactional es la configuración del llamado «modo de propagación» a establecer en la propiedad propagation con un valor del enumerado Propagation. Su función es decidir qué transacción se usa o crea, según el caso, cuando se invoca un método marcado con @Transactional.

La siguiente tabla resumen los modos disponibles. La referencia que se toma es la presencia de una transacción en curso en el momento de invocarse un método @Transactional.

ModoExiste transacción en cursoNo existe transacción en curso
REQUIREDUsarCrear nueva
SUPPORTSUsarNada. El método no será transaccional.
MANDATORYUsarLanzar excepción
REQUIRES_NEWSuspender y crear una nuevaCrear nueva
NOT_SUPPORTEDSuspenderNada. El método no será transaccional.
NEVERLanzar excepciónNada. El método no será transaccional.
NESTEDEjecutar el método en una transacción anidadaCrear una nueva

Para el propósito del curso —y en la mayor parte de escenarios— nos sirve REQUIRED, la opción predeterminada (no hace falta indicarla). Según la tabla, se «requiere» que el método se ejecute siempre en una transacción. Si el método invocante ya es transaccional, se usa esa misma transacción en curso; si no, se crea una nueva.

Por cierto, con @Transactional, además de métodos, se anotan clases e interfaces de tal modo que sus métodos heredan la configuración. Más adelante comprobaremos qué sucede cuando tanto un método como su clase están anotados.

Excepciones

Cuando una transacción finaliza, se procede con la confirmación de las operaciones (commit) solicitadas a la base de datos durante la transacción. El lanzamiento de alguna excepción de tipo RuntimeException provoca la acción de vuelta atrás (rollback) de la transacción. Son las que no estamos obligados a capturar (NullPointerException, IllegalArgumentException, etcétera). En el caso de las demás (las conocidas como checked), no hay rollback que valga.

El tratamiento de las excepciones se personaliza con las siguientes propiedades de @Transactional:

rollbackForUna o varias excepciones, en formato Class, para las que se debe hacer el rollback.
rollbackForClassNameUna o varias excepciones, en formato String, para las que se debe hacer el rollback.
noRollbackForUna o varias excepciones, en formato Class, para las que no se debe hacer el rollback.
noRollbackForClassNameUna o varias excepciones, en formato String, para las que no se debe hacer el rollback.
Lo común es cortar por lo sano y escribir algo así:
@Transactional(rollbackFor = Exception.class)

Con esto, aseguras que se deshaga la transacción para cualquier Exception. Por supuesto, hazlo solo si es lo que necesitas.

Procesos transaccionales

Para crear un proceso transaccional no es imprescindible ir anotando con @Transactional todos los métodos que se vayan invocando como parte de ese proceso, tan solo aquel que representa el inicio. En la siguiente imagen, Service1#foo y callToPrivate comparten la transacción de doSomething, aunque no estén marcados con @Transactional o incluso se ubiquen en clases distintas.

En apariencia, el comportamiento de un método sin @Transactional coincide con el que conseguimos con @Transactional(propagation = Propagation.SUPPORTS) porque se usa la transacción en curso, y no hay transaccionalidad cuando no haya una transacción en curso. Pero hay una diferencia: con @Transactional, si se «escapa» una excepción elegible para rollback, se realizará el rollback de la transacción en curso.

Sin la anotación esto no sucede así. El rollback solo se efectúa si la excepción va ascendiendo y en algún momento escapa de un método @Transactional.

Un error muy común

Existe un detalle fundamental que debes conocer por constituir una fuente potencial de errores. @Transactional solo funciona cuando el método anotado con ella (o que la reciba de su clase o interfaz) se invoque desde otro bean distinto.

En la próxima imagen, callToPublic no es transaccional si lo llama doSomething, salvo que doSomething forme parte de una transacción iniciada en un método previo de la pila de llamadas.

Esta limitación —anotaciones que solo funcionan sobre métodos invocados entre beans distintos— también la encontramos en otras anotaciones de Spring como @Async y @Cacheable. ¿Por qué ocurre esto? 🤔

Sabemos que los beans de Spring son objetos proxy que «enriquecen» a los objetos reales que contienen con funcionalidades tales como las proporcionadas por las anotaciones anteriores. Pues bien, para que esas funcionalidades se apliquen, las llamadas a los métodos del objeto real deben pasar por el proxy (icono verde de la siguiente ilustración). Esto no ocurre cuando se hacen directamente entre métodos del objeto real envuelto por el proxy (icono rojo) o, lo que es lo mismo, en nuestro código invocamos un método de la propia clase.

Dos alternativas solventan esta situación:

  • Refactorizar el código para separar en clases distintas los métodos implicados. Es lo que yo suelo hacer.
  • Inyectar la clase en sí misma:
@Service
public class MyUserCaseImpl {

    @Autowired
    @Lazy
    private MyUserCaseImpl _self;

La clase MyUserCaseImpl se contiene a sí misma, pero las llamadas a los métodos de _self pasan por el proxy y este puede hacer su trabajo.

La técnica anterior funciona aun siendo MyUserCaseImpl un bean de tipo singleton (solo existe uno y es siempre el mismo). Esto se debe a que esta peculiar «autoinyección» (self injection) es un caso admitido por Spring. Eso sí, se precisa que la inyección sea de tipo @Lazy (perezosa); de lo contrario veremos este error:

 Requested bean is currently in creation: Is there an unresolvable circular reference?

Informa que el bean a inyectar está creándose y que es probable que tengamos una referencia circular. El mensaje tiene razón: para crear un objeto de MyUserCaseImpl se requiere de un objeto MyUserCaseImpl. Muy loco, ¿verdad? 🤪

@Lazy soluciona el problema inyectando en _self en el momento de la creación del objeto de MyUserCaseImpl un proxy especial el cual será reemplazado más tarde por la dependencia cuando sea imprescindible.

Operaciones de escritura y escritura

Los métodos que solicitan operaciones de escritura en la base de datos deben formar parte de un bloque transaccional sí o sí. En ausencia de ella, se lanza esta excepción:

Caused by: jakarta.persistence.TransactionRequiredException: Executing an update/delete query

Como diría un matemático, la existencia de la transacción es condición necesaria pero insuficiente: necesita permiso para escribir en la base de datos. Si no cuenta con él, veremos este otro error:

 java.sql.SQLException: Connection is read-only. Queries leading to data modification are not allowed

La autorización se configura con la propiedad readOnly («solo lectura») de @Transactional. Su valor predeterminado es false, lo que significa que no está en modo de solo lectura, ergo se permite la escritura.

Ahora estamos en condiciones de comprender por qué la prueba testUpdatePopulation escrita en el capítulo anterior funcionó: save, implementado en la clase SimpleJpaRepository, está anotado con @Transactional. Compruébalo por ti mismo:

@Transactional
public <S extends T> S save(S entity) {

Se aplica la configuración predeterminada; esto es, propagación REQUIRED (reusar o crear) y escritura permitida.

Fíjate en la declaración de la clase:

@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {

@Transactional a nivel de clase se aplica a todos los métodos públicos. En este ejemplo en particular serán transacciones de solo lectura que no podrán hacer cambios en la base de datos. Por fortuna, la configuración de @Transactional para la clase puede sobrescribirse en los métodos donde sea preciso. Es el caso de save, que sí necesita escribir en la base de datos. Su @Transactional prevalece sobre el de la clase.

Pese a que las operaciones de solo lectura también deben realizarse dentro de una transacción, no es obligatorio declararla con @Transactional. Aun así, recomiendo hacerlo para activar el flag readOnly porque permite tanto al controlador JDBC de la base de datos como al proveedor de JPA realizar ciertas optimizaciones. En el caso concreto de Hibernate, si las entidades recuperadas no se van a modificar, no se chequearán los posibles cambios en las mismas para enviarlos de forma automática la base de datos (esta característica de JPA la veremos en la próxima sección del capítulo). Esto mejora un poco el rendimiento y asegura que los cambios en las entidades no se persisten, pues nada nos impide llamar a sus setters.

Transacciones y JPA

Transacción = contexto de persistencia

Lo más relevante acerca de la transaccionalidad en cuanto a JPA es que el concepto de transacción está íntimamente unido al de contexto de persistencia: una transacción tiene asociado un contexto único. Este contexto es un contenedor de vida efímera que almacena las entidades, siguiendo un ciclo de vida o flujo de trabajo bien definido. Se trata de un concepto capital de JPA que explico en un artículo específico:

Esta prueba, situada en la clase TransactionalOperationsTest, verifica la relación transacción-contexto de persistencia:

@Test
@Transactional
void testCountryIsAlwaysTheSameInTransaction() {
    Country spain1 = countryRepository.findById(DatasetConstants.SPAIN_ID).get();
    Country spain2 = countryRepository.findById(DatasetConstants.SPAIN_ID).get();

    assertThat(spain1).isEqualTo(spain2);
}

Nota. Aunque @Transactional funciona en las clases con pruebas, ten en cuenta que cuando la transacción finalice no se realiza commit sino rollback con el fin de preservar los datos de prueba. Este comportamiento no debería ser un problema; no obstante, se puede evitar con las anotaciones @Rollback(false) o @Commit.

Todo el código de testCountryIsAlwaysTheSameInTransaction comparte el mismo contexto por estar bajo la misma transacción. En la línea 4, findById recupera un país desde la tabla con una sentencia SELECT. La consecuencia es que el objeto referenciado por la variable spain1 queda incluido en el contexto de persistencia asociado a la transacción como el representante de la entidad Country de identificador SPAIN_ID.

La segunda llamada a findById solicita esa misma entidad, así que se devuelve el objeto que la representa, ya almacenado en el contexto compartido por todo el método-transacción. Por lo tanto, spain2 y spain1 son la referencia a un mismo objeto registrado en el contexto -la entidad para España-, hecho que verifica la aserción de la última línea.

Este test, similar al anterior, comprueba qué sucede si no tenemos una transacción:

@Test
void testCountryIsDifferentOutsideTransaction() {
    Country spain1 = countryRepository.findById(DatasetConstants.SPAIN_ID).get();
    Country spain2 = countryRepository.findById(DatasetConstants.SPAIN_ID).get();

    assertThat(spain1).isNotEqualTo(spain2);
}

Fíjate que ahora el segundo findById debe traer de la base de datos el país en un nuevo objeto (las líneas 3 y 4 no comparten el mismo contexto). En consecuencia, spain1 y spain2 son objetos distintos, aunque representan a la entidad España.

¿Quieres más pruebas? Activando las trazas de la bitácora verás la ejecución de dos SELECT. En el caso de la prueba testCountryIsAlwaysTheSameInTransaction solo verás una.

Sincronización automática de entidades

Veamos uno de los aspectos más potentes y —cuasimágico— del contexto de persistencia. Resulta que los cambios en las entidades realizados dentro su contexto se sincronizan automáticamente con la base de datos cuando Hibernate lo considere imprescindible, a más tardar al finalizar la transacción. En consecuencia, si obtenemos y editamos una entidad en una misma transacción (mismo contexto), es redundante invocar a save (*); Hibernate ya sabe que hay cambios en la entidad que debe llevar a la base de datos.

(*) En realidad, si recuerdas el capítulo previo, save invoca al método persist o merge del gestor de entidades. Introducen una entidad en el contexto de persistencia, lo que no siempre implica su escritura en la base de datos en ese mismo instante.

Comprobemos lo anterior en el siguiente servicio para Country -clase que contiene la definición de los procesos de negocio relativos a los países-. Tiene un método que cambia el nombre de un país:

package com.danielme.springdatajpa.service;

import com.danielme.springdatajpa.repository.CountryCrudRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@AllArgsConstructor
public class CountryService {

    private final CountryCrudRepository countryRepository;

    @Transactional
    public void updateCountryNameTransactional(Long id, String newName) {
        countryRepository.findById(id)
                .ifPresent(spain -> spain.setName(newName));
    }

}

Resulta tentador, y a todas luces razonable, llamar a save tras haberse establecido el nuevo nombre. No lo hagas. Cuando la transacción termine se ejecutará la sentencia UPDATE en la base de datos porque Hibernate detecta que el objeto spain cambió. Por consiguiente, esta prueba será exitosa.

@Test
void testUpdateInTransaction(@Autowired CountryService service) {
    String newName = "test";

    service.updateCountryNameTransactional(DatasetConstants.SPAIN_ID, newName);

    Country spain = countryRepository.findById(DatasetConstants.SPAIN_ID).get();
    assertThat(spain.getName()).isEqualTo(newName);
}

Sin transaccionalidad en el servicio tendríamos que solicitar al repositorio el guardado del país:

public void updateCountryNameNoTransaction(Long id, String newName) {
    Optional<Country> countryOpt = countryRepository.findById(id);
    if (countryOpt.isPresent()) {
        countryOpt.get().setName(newName);
        countryRepository.save(countryOpt.get());
    }
}

La clave es que el código de este último método no comparte una misma transacción \ contexto. Hibernate no es consciente de lo que hagamos con countryOpt, así que no trasladará automáticamente los posibles cambios a la base de datos. Seremos nosotros quienes tengamos que solicitar el guardado de los cambios, esto es, volver a introducir la entidad en un contexto de persistencia con merge (lo que hace save).

¿Y si convertimos updateCountryNameNoTransaction en transaccional conservando la llamada a save? Sigue funcionando, pero hay dos consecuencias sutiles:

  • Denota un pobre conocimiento sobre JPA por parte del programador. Tú no eres ese programador porque lees mi blog 😉
  • Merma de rendimiento. Estamos ejecutando la lógica de merge y todas las operaciones que ello implica. Puede incluir la ejecución de una SELECT.

Dónde declarar las transacciones

Comenté que las transacciones deben declararse de manera explícita para las operaciones de escritura, siendo recomendable hacerlo también en las de solo lectura para que se apliquen las optimizaciones del modo read only. Ya que el uso de @Transactional es válido en interfaces, el siguiente dilema es razonable: ¿debemos configurar la transaccionalidad en los repositorios?

Podemos sin problema alguno. Marcar con @Transactional los repositorios nos dará la seguridad de que sus métodos tienen la transaccionalidad bien configurada, con independencia de que sean llamados o no por métodos transaccionales. Asimismo, debido a que la inmensa mayoría de sus métodos serán de solo lectura, podemos anotar la interfaz con @Transactional(readOnly=true).

Sin embargo, la cuestión que planteé decía «debemos» y no «podemos». El verbo «deber» es demasiado categórico en este caso.

Lo cierto es que estamos ante una decisión conceptual. En mi opinión, resulta más natural declarar las transacciones solo en las clases que contienen los distintos procesos de negocio o casos de uso. Abarcarán toda la secuencia de métodos invocados, entre los que se encontrarán los pertenecientes a los repositorios.

Puesto que en el curso usaremos directamente los repositorios en las pruebas, configuraré la transaccionalidad en ellos; pero quiero dejar claro que es una decisión consciente fruto de una situación concreta, no una norma o convención a seguir.

Resumen final

Recapitulemos:

  • @Transactional establece el comportamiento de un método en lo tocante a las transacciones. El comportamiento predeterminado, y el más usado con diferencia, consiste en ejecutar el método dentro de una transacción. Si cuando el método se invoca existe una transacción en curso, formará parte de ella; si no, se creará una nueva.
  • @Transactional cancela una transacción con rollback si se lanza una excepción de tipo unchecked. Esto es configurable en la anotación.
  • ¡Cuidado con los proxy! @Transactional solo se aplica si el método es invocado desde otro situado en un objeto distinto.
  • Solo es obligatorio declarar transacciones si tu código escribe en la base de datos. Ahora bien, si solo lees datos es aconsejable hacerlo para configurar la transacción en modo de solo lectura. Ganaremos seguridad y rendimiento.
  • En JPA una transacción está vinculada a un contexto de persistencia. Debes comprender este concepto y el flujo de trabajo disponible para las entidadades.

Código de ejemplo

El proyecto, explicado en el capítulo dos, se encuentra en GitHub. Para más información sobre cómo utilizar GitHub consulta este artículo.



Otros tutoriales relacionados

Introducción a Spring Boot: Aplicación Web con servicios REST y Spring Data JPA

Spring Boot: Gestión de errores en aplicaciones web y REST

Testing en Spring Boot con JUnit 45. Mockito, MockMvc, REST Assured, bases de datos embebidas

Spring JDBC Template: simplificando el uso de SQL

Deja una respuesta

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.