Spring Data JPA

Transactions with Spring Data

18 min Lesson 9 of 13

Transactions with Spring Data

Every database write — whether it is a single save() call or a sequence of twenty operations — ultimately has to answer one question: what happens if something goes wrong halfway through? The answer is transactions. In Spring Data JPA, transactions are managed declaratively through the @Transactional annotation, and understanding the rules it enforces — and the performance levers it exposes — is what separates code that merely works from code that is correct under load.

Why Transactions Matter

A transaction is a unit of work that either commits entirely or rolls back entirely. Without one, a failure between two related writes can leave the database in a half-updated state that no business rule ever intended. Spring's transaction support wraps the JDBC connection in an AOP proxy: when the annotated method is entered, a transaction is started (or joined); when the method returns normally, it is committed; when an exception propagates out, it is rolled back.

Spring Data repository methods are transactional by default. The SimpleJpaRepository base class annotates all write methods (save, delete, saveAll, …) with @Transactional and all read methods with @Transactional(readOnly = true). You only need to write your own @Transactional when you are coordinating multiple repository calls inside a service method.

The @Transactional Annotation

Place @Transactional on a Spring-managed bean method (or on the class itself to apply to every public method). Spring replaces the bean with a proxy that intercepts calls and wraps them in transaction logic.

import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class OrderService { private final OrderRepository orderRepo; private final InventoryRepository inventoryRepo; public OrderService(OrderRepository orderRepo, InventoryRepository inventoryRepo) { this.orderRepo = orderRepo; this.inventoryRepo = inventoryRepo; } @Transactional // single transaction wraps both writes public Order placeOrder(Long productId, int qty, Long customerId) { Inventory inv = inventoryRepo.findByProductIdOrThrow(productId); inv.reserve(qty); // throws if insufficient stock inventoryRepo.save(inv); Order order = new Order(customerId, productId, qty); return orderRepo.save(order); // both writes commit or both roll back } }

Because both writes share the same transaction, a InsufficientStockException thrown inside inv.reserve(qty) will roll back the entire unit of work — no partial Inventory update will ever reach the database.

Propagation: What Happens When Transactions Nest

The propagation attribute controls what happens when an annotated method is called from inside another transaction. The default is REQUIRED: join the outer transaction if one exists, otherwise start a new one. The most common values are:

  • REQUIRED (default) — participates in an existing transaction; starts one if there is none. Use for almost everything.
  • REQUIRES_NEW — always starts a fresh transaction, suspending the outer one. Useful for audit-log writes that must commit even if the main transaction rolls back.
  • NOT_SUPPORTED — suspends any active transaction and runs without one. Rare; mainly for read-heavy reporting queries on very large tables where you explicitly do not want a transaction lock.
  • MANDATORY — must be called from inside an existing transaction; throws if there is none. Good for internal helpers that must never be called from outside a service boundary.
@Transactional(propagation = Propagation.REQUIRES_NEW) public void recordAuditEvent(String action, Long entityId) { auditRepo.save(new AuditEvent(action, entityId, Instant.now())); // This commits independently — survives even if the caller rolls back }

Rollback Rules

By default, Spring rolls back the transaction only on unchecked exceptions (RuntimeException and its subclasses, plus Error). Checked exceptions do not trigger a rollback. You can override this:

@Transactional(rollbackFor = PaymentException.class) // checked exception triggers rollback public void processPayment(Long orderId) throws PaymentException { // ... } @Transactional(noRollbackFor = OptimisticLockException.class) // suppress rollback public void retryableSave(Product p) { productRepo.save(p); }
Self-invocation bypasses the proxy. If a @Transactional method calls another @Transactional method on the same bean, the second annotation is ignored — the call goes directly to this, not through the proxy. Extract the second method into a separate Spring bean to get independent transaction behaviour.

Read-Only Transactions

For methods that only read data you should always declare @Transactional(readOnly = true). This single flag carries meaningful consequences:

  • Hibernate dirty-checking is skipped. At flush time Hibernate normally compares every managed entity against its snapshot to detect changes. With readOnly = true it skips that scan entirely, which is measurably faster when you are loading large collections.
  • The database connection hint. Spring passes a read-only hint to the JDBC driver. Some databases (PostgreSQL, MySQL replication setups) can route read-only connections to a replica, reducing load on the primary.
  • Prevents accidental writes. If the method inadvertently modifies an entity, Hibernate will not flush the change — helping you catch logical errors early.
@Transactional(readOnly = true) public List<OrderSummary> findRecentOrders(Long customerId) { // Hibernate loads entities but skips dirty checking at flush return orderRepo.findTop20ByCustomerIdOrderByCreatedAtDesc(customerId); }
Annotate service-layer query methods with readOnly = true as a habit. It costs nothing when you are only reading, but it can save Hibernate from scanning thousands of managed objects on a busy thread.

Transaction Isolation

The isolation attribute maps to SQL isolation levels. The default is DEFAULT, which tells Spring to use whatever the underlying database chooses (typically READ_COMMITTED for PostgreSQL and MySQL). You can tighten or loosen this per method:

@Transactional(isolation = Isolation.SERIALIZABLE) public void transferFunds(Long fromId, Long toId, BigDecimal amount) { // Fully serialised: highest consistency, lowest throughput Account from = accountRepo.findByIdOrThrow(fromId); Account to = accountRepo.findByIdOrThrow(toId); from.debit(amount); to.credit(amount); accountRepo.save(from); accountRepo.save(to); }

In practice, most applications run happily at READ_COMMITTED. Use REPEATABLE_READ or SERIALIZABLE only when you have a specific concurrency anomaly to prevent and have measured the throughput cost.

Timeout

Long-running transactions hold database locks and connection-pool connections. The timeout attribute (in seconds) forces a rollback if the transaction does not finish in time:

@Transactional(timeout = 5) // roll back after 5 seconds public void importBatch(List<Record> records) { records.forEach(r -> recordRepo.save(new DbRecord(r))); }

Putting It Together: A Typical Service Layer

@Service @Transactional(readOnly = true) // default for all methods: read-only public class ProductCatalogService { private final ProductRepository productRepo; private final CategoryRepository categoryRepo; public ProductCatalogService(ProductRepository productRepo, CategoryRepository categoryRepo) { this.productRepo = productRepo; this.categoryRepo = categoryRepo; } public List<Product> findByCategory(String slug) { return productRepo.findByCategorySlug(slug); // inherits readOnly = true } @Transactional // overrides class-level: full read-write public Product createProduct(CreateProductCommand cmd) { Category cat = categoryRepo.findBySlugOrThrow(cmd.categorySlug()); Product p = new Product(cmd.name(), cmd.price(), cat); return productRepo.save(p); } @Transactional // read-write; wraps two repos atomically public void adjustPrice(Long id, BigDecimal newPrice) { Product p = productRepo.findByIdOrThrow(id); p.setPrice(newPrice); // No explicit save() needed — Hibernate dirty-checking commits the change } }

Annotating the class with @Transactional(readOnly = true) and then selectively overriding individual write methods with plain @Transactional is the standard pattern. It is explicit, safe by default, and maximises read performance.

Summary

@Transactional is how Spring Data keeps your writes atomic and your reads efficient. The critical rules: use readOnly = true on every query method; wrap multi-repository operations in a single service-level transaction; remember that self-invocation bypasses the proxy; and adjust rollback rules when you need checked exceptions to trigger a rollback. With these habits in place your data layer will be both correct and fast.