Transactions with Spring Data
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.
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.
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.
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 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 = trueit 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.
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:
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:
Putting It Together: A Typical Service Layer
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.