Transactions, Caching & Performance

Transactions in JPA & Spring

18 min Lesson 1 of 13

Transactions in JPA & Spring

A transaction is a unit of work that either completes in full or leaves the database completely unchanged. Without transactions, a crash halfway through a multi-step operation — say, debiting one bank account before crediting another — produces corrupt, inconsistent data. Transactions are the safety net that prevents this.

Spring and JPA together make transaction management almost invisible, hiding the ugly EntityTransaction.begin()/commit()/rollback() boilerplate behind a single annotation: @Transactional. This lesson explains what that annotation actually does, what guarantees it provides, and the patterns every production developer must follow.

The ACID Guarantees

Every relational database transaction is expected to satisfy four properties, collectively called ACID:

  • Atomicity — all operations in the transaction succeed together, or none of them do. If any step throws an exception, the entire transaction is rolled back.
  • Consistency — a transaction takes the database from one valid state to another. Constraints, foreign keys, and application-level invariants must hold both before and after.
  • Isolation — concurrent transactions do not see each other's intermediate, uncommitted changes. The degree of isolation is configurable (covered in Lesson 3).
  • Durability — once a transaction is committed, its changes survive crashes, power failures, and restarts. The database writes to durable storage (WAL logs, etc.) before acknowledging the commit.
Key insight: Spring's @Transactional provides Atomicity and (via the database) Consistency and Durability automatically. Isolation is a database-level knob you can tune — more on that in Lesson 3.

How Spring Implements @Transactional

Spring wraps your bean in a JDK dynamic proxy (or a CGLIB subclass proxy for classes). When a caller invokes a @Transactional method, the proxy intercepts the call, opens a transaction on the EntityManager/DataSource, delegates to your method, and then either commits or rolls back. Your business code never touches the transaction object directly.

// Spring Boot 3 — jakarta.persistence.* not javax.persistence.* import jakarta.persistence.*; 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 // ← the whole method is one unit of work public Order placeOrder(Long productId, int quantity) { Inventory item = inventoryRepo.findByProductIdForUpdate(productId); if (item.getStock() < quantity) { throw new InsufficientStockException("Not enough stock"); } item.deduct(quantity); // 1. modify inventory inventoryRepo.save(item); Order order = new Order(productId, quantity); orderRepo.save(order); // 2. create order record return order; // Spring proxy commits here — both writes land atomically // If InsufficientStockException is thrown above, both are rolled back } }

Notice: if the InsufficientStockException is thrown, neither the inventory update nor the order insert reaches the database. That is atomicity in action.

Default Rollback Rules

Spring rolls back a transaction when an unchecked exception (subclass of RuntimeException or Error) is thrown. It commits when a checked exception propagates — a legacy design decision that surprises many developers.

Checked exceptions do NOT trigger rollback by default. If your service method declares throws IOException and an IOException escapes the method, Spring will commit the partial work. Use @Transactional(rollbackFor = IOException.class) to opt in to rollback for checked exceptions — or, better, wrap checked exceptions in RuntimeException subtypes at the domain boundary.

You can fine-tune rollback behaviour with the annotation attributes:

// Roll back for ALL exceptions — checked and unchecked @Transactional(rollbackFor = Exception.class) public void riskyOperation() throws Exception { ... } // Roll back for a specific checked exception @Transactional(rollbackFor = PaymentGatewayException.class) public void charge(Order order) throws PaymentGatewayException { ... } // Commit even if IllegalArgumentException is thrown @Transactional(noRollbackFor = IllegalArgumentException.class) public void auditLog(String msg) { ... }

The EntityManager and the Persistence Context

When you annotate a Spring Data repository method (or your own @Repository) with @Transactional, Spring binds an EntityManager to the current transaction. The EntityManager maintains a persistence context — an identity map of every entity it has loaded. Within one transaction, loading the same row twice returns the same Java object reference; Hibernate will not issue a second SELECT.

At commit time, Hibernate's dirty-checking mechanism compares every managed entity's current state to its snapshot taken at load time. Any field that changed triggers an automatic UPDATE — you do not need to call save() explicitly for entities you retrieved within the same transaction.

@Transactional public void applyDiscount(Long orderId, int discountPercent) { Order order = orderRepo.findById(orderId).orElseThrow(); // order is now "managed" inside the persistence context order.applyDiscount(discountPercent); // No explicit save() needed — dirty-checking fires an UPDATE on commit }
Best practice: Keep transactions short. The longer a transaction stays open, the longer it holds database locks, blocking other writers and readers (depending on isolation level). Load data, make changes, commit — avoid blocking I/O like HTTP calls or file reads inside a transaction boundary.

Self-Invocation — The Most Common Pitfall

Because @Transactional is implemented via a proxy, calling a @Transactional method from within the same class bypasses the proxy entirely and therefore has no transactional effect. This is the single most common Spring transaction bug.

@Service public class ReportService { @Transactional public void generateReport() { buildSummary(); // ← calls this.buildSummary(), NOT the proxy // if buildSummary() is @Transactional, that annotation is IGNORED } @Transactional(readOnly = true) public void buildSummary() { ... } }

The fix: extract buildSummary() into a separate Spring-managed bean, and inject that bean. Then calls go through the proxy and the transaction semantics apply correctly.

Placing @Transactional — Service vs Repository

Annotate at the service layer, not (only) the repository layer. Repository methods like save() and findById() already carry their own @Transactional from Spring Data, but a service method that calls two repositories needs an outer transaction so both operations participate in the same unit of work. If you omit the service-level annotation, each repository call commits independently — and you lose atomicity across them.

Summary

ACID guarantees are the contract your database upholds for every transaction. Spring's @Transactional proxy provides atomicity transparently: commit on clean return, roll back on unchecked exceptions (and on checked ones only when you explicitly opt in). The persistence context's dirty-checking saves you explicit save() calls, but self-invocation silently breaks the proxy — always inject a separate bean when you need transactional composition. Keep transactions as short as possible to minimise lock contention. The next lesson goes deeper into propagation: what happens when a transactional method calls another transactional method.