Transacciones en Spring: @Transactional y los errores comunes
1. Descripción general
@Transactional es la anotación más utilizada en Spring Boot. También es fuente de muchos errores difíciles de encontrar: datos que no se revierten (rollback), transacciones que “desaparecen”, o UnexpectedRollbackException que aparece sin motivo claro.
Este artículo explica cómo funciona @Transactional a través de proxy. Luego, pasaremos por cada error común, cada uno con código de ejemplo y formas de solucionarlo. Cuando termines de leer, sabrás cuándo se abre realmente una transacción y cuándo se revierte.
2. Configuración
Usaremos Spring Data JPA con H2 en memoria:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Activa el log del interceptor de transacciones para ver cuándo se abren y cierran:
logging.level.org.springframework.transaction.interceptor=TRACE
Entity y repository usados en este artículo:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue
private Long id;
private String customerName;
private BigDecimal amount;
protected Order() {
}
public Order(String customerName, BigDecimal amount) {
this.customerName = customerName;
this.amount = amount;
}
public Long getId() { return id; }
public String getCustomerName() { return customerName; }
public BigDecimal getAmount() { return amount; }
}
public interface OrderRepository extends JpaRepository<Order, Long> {
}
La tabla se llama orders porque order es una palabra clave SQL.
3. Cómo funciona @Transactional
3.1. Ejemplo básico
@Service
public class PaymentService {
public void charge(String customer, BigDecimal amount) {
if (amount.compareTo(new BigDecimal("1000")) > 0) {
throw new IllegalStateException("Excedido el límite de pago");
}
}
}
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
this.orderRepository = orderRepository;
this.paymentService = paymentService;
}
@Transactional
public Order placeOrder(String customer, BigDecimal amount) {
Order order = orderRepository.save(new Order(customer, amount));
paymentService.charge(customer, amount);
return order;
}
}
Cuando llamas a placeOrder("An", new BigDecimal("5000")), el pedido se guarda (save) primero. Después, charge lanza IllegalStateException. Como es una RuntimeException, Spring revierte todo, así que la tabla orders permanece vacía.
3.2. El proxy es central
Spring no modifica el bytecode de OrderService. Crea un proxy (por defecto, una subclase CGLIB en Spring Boot) que envuelve el bean. Cuando otro bean inyecta OrderService, en realidad recibe este proxy. Cada llamada pasa a través de TransactionInterceptor:
llamador → proxy → [abre transacción] → OrderService.placeOrder() → [commit/rollback] → llamador
El log TRACE muestra esto claramente:
TRACE o.s.t.i.TransactionInterceptor : Getting transaction for [com.nonla.order.OrderService.placeOrder]
TRACE o.s.t.i.TransactionInterceptor : Completing transaction for [com.nonla.order.OrderService.placeOrder] after exception: java.lang.IllegalStateException: Excedido el límite de pago
La mayoría de los errores que verás a continuación provienen de un principio: si la llamada no pasa a través del proxy, no hay transacción.
4. Error 1: Self-invocation
@Service
public class OrderService {
// ... constructor como arriba
public void placeOrders(List<String> customers) {
for (String customer : customers) {
placeOrder(customer, new BigDecimal("100")); // llamada a través de this
}
}
@Transactional
public Order placeOrder(String customer, BigDecimal amount) {
System.out.println("Transaction active: "
+ TransactionSynchronizationManager.isActualTransactionActive());
Order order = orderRepository.save(new Order(customer, amount));
paymentService.charge(customer, amount);
return order;
}
}
Cuando llamas a orderService.placeOrders(List.of("An", "Bình")) desde el controller, mostrará:
Transaction active: false
Transaction active: false
placeOrders no tiene @Transactional, por lo que el proxy pasa directamente la llamada al objeto real. Dentro, placeOrder(...) es equivalente a this.placeOrder(...), y this es el objeto original, no el proxy. La anotación se ignora por completo. Cada save se ejecuta en la transacción propia de SimpleJpaRepository y se confirma inmediatamente. Si el segundo pedido falla, el primero sigue en la BD.
Hay tres formas de solucionarlo, ordenadas por prioridad:
- Extrae
placeOrderen otro bean, por ejemploOrderPlacementService, e inyéctalo. - Pon
@Transactionalen el propioplaceOrderssi quieres que todo el lote sea una transacción. - Auto-inyéctate a través de
@Lazy. Funciona pero es difícil de leer, úsalo solo cuando no puedas extraer el bean.
@Service
public class OrderBatchService {
private final OrderService orderService; // este es el proxy
public OrderBatchService(OrderService orderService) {
this.orderService = orderService;
}
public void placeOrders(List<String> customers) {
customers.forEach(c -> orderService.placeOrder(c, new BigDecimal("100")));
}
}
Ahora cada llamada pasa a través del proxy, y el log muestra Transaction active: true.
5. Error 2: Método private o final
A partir de Spring Framework 6.0, cuando usas proxy basado en clases (CGLIB), @Transactional funciona en métodos public, protected y package-private. Sin embargo, todavía hay dos casos que no funcionan:
- Método
private: la subclase no puede sobrescribirlo, por lo que el proxy no puede interceptar la llamada. - Método
final: CGLIB tampoco puede sobrescribirlo. La llamada va directamente al código original, sin transacción.
@Transactional
public final Order placeVipOrder(String customer, BigDecimal amount) { // SIN transacción
return orderRepository.save(new Order(customer, amount));
}
Spring no lanza un error en este caso. Solo ves una línea de log en nivel INFO o DEBUG, muy fácil de pasar por alto.
Nota: Kotlin cierra por defecto (
final) todas las clases y métodos. Usa el pluginkotlin-spring(all-open) para que los beans de Spring se abran automáticamente.
6. Error 3: Checked exception no causa rollback
Por defecto, Spring solo revierte cuando encuentra RuntimeException o Error. Las checked exceptions harán que la transacción se confirme (commit).
public class PaymentDeclinedException extends Exception {
public PaymentDeclinedException(String message) {
super(message);
}
}
@Transactional
public Order placeOrderStrict(String customer, BigDecimal amount) throws PaymentDeclinedException {
Order order = orderRepository.save(new Order(customer, amount));
if (amount.signum() <= 0) {
throw new PaymentDeclinedException("Cantidad inválida");
}
return order;
}
Si llamas con amount = -5, la excepción se lanza al llamador, pero el pedido negativo ya se ha guardado. La solución es declararlo explícitamente:
@Transactional(rollbackFor = PaymentDeclinedException.class)
Muchos equipos usan la convención rollbackFor = Exception.class para todo el servicio. Una forma más simple es diseñar las excepciones de negocio que hereden de RuntimeException. También puedes hacer lo contrario: noRollbackFor = SomeRuntimeException.class mantiene el commit incluso si hay error.
7. Error 4: Tragar la excepción y UnexpectedRollbackException
Este es el error más confuso. Considera un servicio que reserva inventario:
@Service
public class InventoryService {
@Transactional // propagation por defecto: REQUIRED
public void reserve(String sku) {
throw new IllegalStateException("Agotado: " + sku);
}
}
Y OrderService lo llama, luego atrapa el error con la intención de “si falla la reserva, aún crea el pedido”:
@Transactional
public Order placeOrderWithInventory(String customer, String sku) {
Order order = orderRepository.save(new Order(customer, new BigDecimal("200")));
try {
inventoryService.reserve(sku);
} catch (IllegalStateException ex) {
System.out.println("Ignorar error de inventario: " + ex.getMessage());
}
return order;
}
Resultado:
Ignorar error de inventario: Agotado: SKU-01
org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
Esto es lo que sucede. reserve participa en la misma transacción física que placeOrderWithInventory. Cuando la excepción pasa a través del proxy de InventoryService, Spring marca la transacción como rollback-only (solo para revertir). Tu catch afuera no puede eliminar esta marca. En el momento del commit, Spring detecta la marca, revierte, y luego lanza UnexpectedRollbackException para informar que el llamador pensó que se confirmó pero en realidad no.
La solución depende de tu intención:
- Si un error de inventario debe cancelar todo el pedido, no hagas
catch. Deja que la excepción suba. - Si
reservees independiente, dale su propia transacción conREQUIRES_NEW(ver sección 8). - Si
reserveno necesita escribir en la BD, elimina@Transactionalde ella.
Nota: Atrapar la excepción dentro del mismo método no marca la transacción. La marca solo se establece cuando la excepción atraviesa un proxy
@Transactional.
8. Propagation REQUIRES_NEW: útil pero caro
Un caso razonable para REQUIRES_NEW es escribir un log de auditoría. El log debe guardarse incluso si el negocio principal se revierte:
@Service
public class AuditService {
private final AuditLogRepository auditLogRepository;
public AuditService(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String action) {
auditLogRepository.save(new AuditLog(action));
}
}
Spring suspende la transacción externa y abre una nueva en una conexión diferente. Cuando record termina, esta transacción se confirma independientemente.
El costo a pagar:
- Cada solicitud mantiene dos conexiones al mismo tiempo. Cuando la carga es alta, el pool (HikariCP tiene 10 conexiones por defecto) puede agotarse y causar un deadlock a nivel de pool: cada hilo mantiene una conexión y espera la segunda.
- La nueva transacción no ve los datos no confirmados de la transacción externa. Si
AuditLogtiene una clave externa haciaOrderrecién creado, obtendrás un error o te quedarás esperando un lock.
Usa REQUIRES_NEW solo cuando realmente necesites commit independiente, no como un “parche de emergencia” para todos los errores de rollback-only.
9. readOnly y transacciones demasiado largas
@Transactional(readOnly = true) le dice a Hibernate que establezca el modo flush en MANUAL e ignore la detección de cambios. Para consultas que devuelven muchas entidades, esto reduce significativamente la memoria y CPU:
@Transactional(readOnly = true)
public List<Order> findOrdersOf(String customer) {
return orderRepository.findAll().stream()
.filter(o -> o.getCustomerName().equals(customer))
.toList();
}
Hay dos cosas que recordar:
readOnlyes una sugerencia de optimización, no un mecanismo de protección. Dependiendo del driver y la base de datos, unUPDATEnativo todavía puede ejecutarse.- No llames APIs HTTP externas, no envíes correos electrónicos ni uses
Thread.sleepdentro de la transacción. La conexión se mantiene todo ese tiempo, y los locks en las filas escritas también. Obtén datos de afuera primero, luego abre una transacción corta para escribir.
10. Pruebas del comportamiento de rollback
Hay otro error en las pruebas. Si pones @Transactional en la clase de prueba, todo se ejecuta dentro de la transacción de prueba y se revierte al final. Eso oculta si el servicio realmente realiza commit o rollback correctamente. Para pruebas sobre transacciones, elimina esta anotación y limpia los datos tú mismo:
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@AfterEach
void cleanUp() {
orderRepository.deleteAll();
}
@Test
void whenPaymentFails_thenOrderIsRolledBack() {
assertThatThrownBy(() -> orderService.placeOrder("An", new BigDecimal("5000")))
.isInstanceOf(IllegalStateException.class);
assertThat(orderRepository.count()).isZero();
}
@Test
void whenInnerServiceFailsAndIsCaught_thenUnexpectedRollback() {
assertThatThrownBy(() -> orderService.placeOrderWithInventory("Bình", "SKU-01"))
.isInstanceOf(UnexpectedRollbackException.class);
assertThat(orderRepository.count()).isZero();
}
@Test
void whenCheckedExceptionWithoutRollbackFor_thenOrderIsCommitted() {
assertThatThrownBy(() -> orderService.placeOrderStrict("Chi", new BigDecimal("-5")))
.isInstanceOf(PaymentDeclinedException.class);
assertThat(orderRepository.count()).isEqualTo(1);
}
}
La tercera prueba “fija” el comportamiento predeterminado con checked exception. Cuando agregues rollbackFor, esta prueba falla, y cambias la expectativa a isZero(). Esta es una buena forma de documentar la decisión de rollback directamente en el código.
11. Conclusión
@Transactional parece simple, pero todo su comportamiento depende del proxy. Resumen de los errores principales:
- Self-invocation: llamar a través de
thisignora el proxy. Extrae el bean. - Método
private/final: no se intercepta, y Spring no informa de un error. - Checked exception: se confirma por defecto. Usa
rollbackForo usaRuntimeException. - Rollback-only: la excepción que pasa a través del proxy dentro marcará toda la transacción,
catchafuera no ayuda. REQUIRES_NEW: consume una conexión adicional, úsalo solo cuando necesites commit independiente.- Prueba con
@Transactional: oculta el comportamiento real de commit/rollback.
En caso de duda, activa el log de TransactionInterceptor en nivel TRACE y verifica TransactionSynchronizationManager.isActualTransactionActive(). Estas dos herramientas te dirán exactamente si la transacción realmente existe o no.