Spring Transaction Management: Propagation and Isolation Explained
Learn Spring transaction management with propagation and isolation levels.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- ✓Basic understanding of Spring Boot and dependency injection
- ✓Familiarity with JPA or JDBC for database operations
- ✓Java 17+ and Maven/Gradle installed
• Spring transaction management uses @Transactional to control database transactions declaratively • Propagation defines how transactions behave across method calls (e.g., REQUIRED, REQUIRES_NEW) • Isolation levels control data visibility between concurrent transactions (e.g., READ_COMMITTED, SERIALIZABLE) • Default propagation is REQUIRED, default isolation is READ_COMMITTED in most databases • Rollback rules are critical: unchecked exceptions trigger rollback by default, checked exceptions do not
Think of a transaction like a bank transfer. Propagation is like deciding whether to start a new transfer or join an existing one. Isolation is like choosing how much privacy you need: do you let others see your pending balance or wait until it’s finalized? Spring manages this automatically with @Transactional annotations.
Transaction management is the backbone of any reliable enterprise application. Without proper transaction control, you risk data corruption, inconsistent states, and hard-to-debug production issues. In Spring, the @Transactional annotation provides a declarative way to manage transactions, but it’s not magic. You need to understand propagation and isolation to avoid subtle bugs that can bring down a payment system or a SaaS billing pipeline.
Spring’s transaction abstraction works with both local (JDBC, JPA) and global (JTA) transactions. The key is that it uses AOP proxies to wrap your method calls. This means internal method calls within the same class bypass the proxy and the transaction settings are ignored. This is a common pitfall for beginners.
In this article, we’ll cover propagation behaviors, isolation levels, rollback rules, and real-world scenarios. You’ll learn how to configure transactions in Spring Boot 3.x with practical code examples. By the end, you’ll be able to design transactional boundaries that prevent phantom reads, dirty reads, and non-repeatable reads in your applications.
We’ll also share a production incident that cost a fintech company $50k because of misconfigured propagation. Let’s dive in.
What is Spring Transaction Management?
Spring transaction management provides a consistent programming model for handling database transactions across different APIs like JDBC, JPA, Hibernate, and JTA. The core is the @Transactional annotation, which you place on service methods or classes. When Spring encounters this annotation, it creates a transaction proxy around your bean. The proxy starts a transaction before the method executes and commits or rolls back based on the outcome.
Spring supports two modes: declarative (using annotations) and programmatic (using TransactionTemplate). Declarative is preferred because it keeps transaction logic out of your business code. Under the hood, Spring uses AOP (Aspect-Oriented Programming) to intercept method calls. This is why self-invocation (calling a @Transactional method from within the same class) doesn’t work — the proxy is bypassed.
Here’s a simple example of a service that manages user registration with a transaction:
What the Official Docs Won’t Tell You
The official Spring documentation covers propagation and isolation levels, but it glosses over the real-world pain points. Here’s what they don’t emphasize enough:
First, the default rollback policy only works for unchecked exceptions (RuntimeException and Error). Checked exceptions (like Exception) do NOT trigger a rollback by default. This is a massive footgun. If your service method throws a checked exception, the transaction will commit even if the operation failed. You must explicitly configure rollbackFor for checked exceptions.
Second, isolation levels are database-dependent. MySQL’s REPEATABLE_READ is different from PostgreSQL’s. Spring’s default isolation level is DEFAULT, which uses the database’s default. For MySQL, that’s REPEATABLE_READ; for PostgreSQL, it’s READ_COMMITTED. Never assume consistency across databases.
Third, propagation REQUIRES_NEW is a double-edged sword. It suspends the current transaction and starts a new one. If the new transaction commits but the outer one rolls back, you have partial commits. This is useful for logging or audit trails, but dangerous for business-critical operations.
Here’s an example of configuring rollback for checked exceptions:
Understanding Transaction Propagation
Propagation defines how transactions behave when a transactional method calls another transactional method. Spring supports seven propagation levels, but the most commonly used are REQUIRED, REQUIRES_NEW, NESTED, MANDATORY, NEVER, SUPPORTS, and NOT_SUPPORTED.
REQUIRED (default): If a transaction exists, join it; otherwise, create a new one. This is the most common choice for service methods that should participate in the caller’s transaction.
REQUIRES_NEW: Always create a new transaction, suspending the current one if it exists. Use this for operations that must commit independently, like audit logging.
NESTED: Uses a savepoint within the existing transaction. If the nested transaction rolls back, the outer transaction can continue or roll back to the savepoint. This is database-dependent (works with JDBC savepoints, but not always with JPA).
Here’s an example showing REQUIRED vs REQUIRES_NEW:
Isolation Levels Explained
Isolation levels control how transactions interact with each other concurrently. They prevent three phenomena: dirty reads (reading uncommitted data), non-repeatable reads (reading different values in the same transaction), and phantom reads (seeing new rows inserted by another transaction).
Spring supports five isolation levels: DEFAULT (database default), READ_UNCOMMITTED (lowest, allows dirty reads), READ_COMMITTED (prevents dirty reads), REPEATABLE_READ (prevents dirty and non-repeatable reads), and SERIALIZABLE (highest, prevents all phenomena but hurts performance).
READ_COMMITTED is the most common choice for production systems because it balances consistency and performance. SERIALIZABLE is rarely used except for critical financial operations.
Here’s an example of setting isolation level:
Configuring Rollback Behavior
Rollback behavior determines which exceptions cause the transaction to roll back. By default, Spring rolls back on unchecked exceptions (RuntimeException and Error) and commits on checked exceptions. You can customize this using rollbackFor, noRollbackFor, rollbackForClassName, and noRollbackForClassName.
For example, you might want to roll back on a specific checked exception like InsufficientFundsException, but not on a validation exception like ValidationException that should be handled gracefully.
You can also configure rollback rules at the class level using @Transactional on the class, which applies to all public methods. Method-level annotations override class-level ones.
Here’s an example of fine-grained rollback control:
Transaction Propagation in Practice
Let’s look at a real-world scenario: a SaaS billing system that handles subscription upgrades. When a user upgrades, you need to create a new invoice, update the subscription, and charge the user. If any step fails, everything should roll back.
But you also want to log the upgrade attempt in an audit table, even if the upgrade fails. This is a perfect use case for REQUIRES_NEW on the audit logging method.
Here’s the implementation:
Testing Transactional Behavior
Testing transactions is tricky because you need a real database to verify rollback and isolation behavior. In-memory databases like H2 are useful for unit tests, but they don’t always behave like production databases (e.g., MySQL’s REPEATABLE_READ vs H2’s implementation).
For integration tests, use Testcontainers to spin up a real database container. This ensures your transaction configuration works as expected in production.
Spring provides @Transactional on test methods to roll back after each test. This is great for isolation but can mask issues where transactions are not properly configured.
Here’s an integration test example:
Debugging Transaction Issues in Production
Transaction issues often manifest as data inconsistencies, deadlocks, or performance degradation. Common symptoms include:
- Partial commits: Some data saved, some not (caused by wrong propagation)
- Deadlocks: Two transactions waiting for each other (caused by high isolation or long transactions)
- Stale data: Reading old data (caused by wrong isolation level)
To debug, enable Spring’s transaction logging by adding logging.level.org.springframework.transaction=DEBUG in application.properties. This shows when transactions start, commit, or roll back.
Use database monitoring tools like MySQL’s SHOW ENGINE INNODB STATUS or PostgreSQL’s pg_stat_activity to see active transactions and locks.
Here’s a debugging snippet:
The $50k Transaction: How Misconfigured Propagation Caused a Payment Nightmare
- Always verify propagation behavior for nested transactional methods
- Use integration tests with real databases to catch transaction boundary issues
- REQUIRES_NEW should be used sparingly and only when you truly need independent commits
logging.level.org.springframework.transaction=DEBUGCheck @Transactional(rollbackFor = ...)| File | Command / Code | Purpose |
|---|---|---|
| UserService.java | @Service | What is Spring Transaction Management? |
| PaymentService.java | @Service | What the Official Docs Won’t Tell You |
| OrderService.java | @Service | Understanding Transaction Propagation |
| InventoryService.java | @Service | Isolation Levels Explained |
| AccountService.java | @Service | Configuring Rollback Behavior |
| SubscriptionService.java | @Service | Transaction Propagation in Practice |
| SubscriptionServiceTest.java | @SpringBootTest | Testing Transactional Behavior |
| application.properties | logging.level.org.springframework.transaction=DEBUG | Debugging Transaction Issues in Production |
Key takeaways
Common mistakes to avoid
3 patternsForgetting to configure rollbackFor for checked exceptions
Using REQUIRES_NEW without understanding its independent commit behavior
Calling @Transactional methods from within the same class
Interview Questions on This Topic
Explain the difference between REQUIRED and REQUIRES_NEW propagation. When would you use each?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Spring Boot. Mark it forged?
4 min read · try the examples if you haven't