1. Introduction: Why Transactions Matter More Than You Think
If you’ve been working with Spring Boot for a while, chances are you’ve already used @Transactional.
Maybe you added it because “that’s what everyone does”, or because a tutorial told you so.
And most of the time… things seem to work.
Until one day:
- a payment is charged but the order is not created
- a user is created, but their profile is missing
- a retry suddenly creates duplicate data
- or worse: production data ends up in an inconsistent state
That’s usually the moment when you realize that transactions are not just a database feature — they are a core business concept.
In backend development, especially in stateful systems, partial success is often worse than total failure.
A transaction is what allows you to say:
“Either everything succeeds, or nothing does.”
Spring Boot makes transactions easy to use, but also easy to misunderstand.
And misunderstanding them can lead to subtle bugs that are extremely hard to debug.
In this article, we’ll start from the basics:
- what a transaction really is
- why it is fundamental in backend systems
- and how this concept maps to Spring Boot and
@Transactional
Before touching any annotation, we need to get the mental model right.
2. What Is a Transaction? (The Basics, Done Right)
At its core, a transaction is a logical unit of work.
It groups multiple operations into a single, indivisible action.
Think about a very common backend use case:
- create an order
- decrease product stock
- save a payment record
If one of these steps fails, the system should not be left in a half-completed state.
Without transactions, your system might end up like this:
✔ Order created
✔ Payment saved
✘ Stock update failed
That’s not a technical issue.
That’s a business problem.
The ACID Properties
Transactions are usually described using the ACID acronym. You’ve probably heard of it, but let’s translate it into practical backend terms.
Atomicity
“All or nothing.”
Either all operations inside the transaction succeed, or all of them are rolled back.
No partial updates, no “we’ll fix it later”.
Consistency
The system moves from one valid state to another valid state.
Constraints, invariants, and business rules must always hold — before and after the transaction.
Isolation
Concurrent transactions should not step on each other’s toes.
What one transaction sees (or doesn’t see) from another transaction is controlled and predictable.
This is where things start getting tricky — and interesting.
Durability
Once a transaction is committed, it stays committed.
Even if the application crashes right after, the data is there.
Transactions Are Not Just About Databases
This is an important point that often gets overlooked.
Transactions:
- are implemented by the database
- but defined by your business logic
The database doesn’t know what an “order” or a “payment” is.
It only knows rows, tables, and constraints.
It’s the backend application that decides:
- what belongs together
- where the transactional boundary starts
- and where it ends
This is exactly why frameworks like Spring exist:
to help you define transactional boundaries in your application code, not in SQL scripts.
Why Transactions Are Fundamental in Backend Systems
Modern backend systems are:
- concurrent
- stateful
- failure-prone by nature
- Networks fail.
- Databases timeout.
- External services go down.
Transactions are your last line of defense against data corruption.
They don’t make failures disappear — but they make failures safe.
And that’s the key idea we’ll carry into Spring Boot and @Transactional.
3. Transactions in Spring Boot: The Big Picture
Before diving deeper into @Transactional, it’s important to understand how Spring manages transactions at a high level.
Not the full internal implementation — just enough to avoid the most common (and painful) mistakes.
Because with transactions in Spring, the “how” matters almost as much as the “what”.
Declarative vs Programmatic Transactions
Spring supports two ways of managing transactions:
1. Programmatic transactions
You explicitly start, commit, and rollback a transaction in code.
TransactionStatus status = transactionManager.getTransaction(definition);
try {
// business logic
transactionManager.commit(status);
} catch (Exception ex) {
transactionManager.rollback(status);
throw ex;
}
This works, but:
- it’s verbose
- it mixes infrastructure concerns with business logic
- it doesn’t scale well in complex services
You can use it, but in most Spring Boot applications, you shouldn’t.
2. Declarative transactions (the Spring way)
This is where @Transactional comes in.
You declare what should be transactional, not how to manage the transaction.
@Transactional
public void placeOrder() {
// business logic
}
Spring takes care of:
- opening the transaction
- committing it if everything goes well
- rolling it back if something goes wrong
This separation of concerns is one of the reasons why Spring-based backends are so readable and maintainable.
The Role of PlatformTransactionManager
At runtime, Spring delegates all transaction operations to a PlatformTransactionManager.
Think of it as an abstraction layer between:
- your application
- and the actual transaction implementation
Depending on what you use, Spring will plug in a different implementation:
-
DataSourceTransactionManager→ JDBC -
JpaTransactionManager→ JPA / Hibernate -
ReactiveTransactionManager→ reactive stacks
This abstraction is what allows you to write framework-agnostic transactional code, while still being tightly integrated with your persistence technology.
You almost never interact with it directly — but it’s always there.
4. What Actually Happens When You Use @Transactional
At first glance, @Transactional looks deceptively simple.
You put it on a method, and magically:
- a transaction starts
- your logic runs
- everything is committed or rolled back
And in happy-path demos, that’s exactly what happens.
In real-world applications, however, where and how you use @Transactional makes a huge difference.
Let’s break it down.
The Simplest (and Most Common) Use Case
The most basic usage looks like this:
@Transactional
public void placeOrder() {
orderRepository.save(order);
paymentRepository.save(payment);
}
If an exception is thrown during the method execution:
- Spring marks the transaction for rollback
- all database changes are reverted
If the method completes successfully:
- the transaction is committed
So far, so good.
But this simplicity hides a lot of assumptions.
One of the biggest misconceptions is thinking of @Transactional as something that adds behavior.
It doesn’t. It defines a transactional boundary:
In other words, when you annotate a method with @Transactional, Spring does NOT modify your method.
Instead, Spring:
- creates a proxy around your bean
- intercepts calls to transactional methods
- starts a transaction before the method execution
- commits or rolls back after the method returns or throws an exception
This is done using Spring AOP (Aspect-Oriented Programming).
In practice, the flow looks like this:
Client → Spring Proxy → Transaction Interceptor
→ Your Method → Transaction Commit / Rollback
Why is this important?
Because only method calls that go through the proxy are transactional.
This single sentence explains:
- why self-invocation doesn’t work
- why
@Transactionalonprivatemethods is ignored - why calling a transactional method from the same class can silently break everything
We’ll get back to this later in the pitfalls section, but keep this mental model in mind.
Where @Transactional Should Live (and Where It Shouldn’t)
A common question is: where do I put @Transactional?
The short answer:
On service-layer methods that define a business operation.
Typically:
- ❌ Controllers → no business logic, no transactions
- ⚠️ Repositories → usually too low-level
- ✅ Services → perfect place for transactional boundaries
Example:
@Service
public class OrderService {
@Transactional
public void placeOrder(CreateOrderCommand command) {
// validate input
// persist order
// update stock
// trigger side effects
}
}
This makes your transactional boundary:
- explicit
- easy to reason about
- aligned with business use cases
Class-Level vs Method-Level @Transactional
Spring allows you to annotate:
- a single method
- or the entire class
@Transactional
@Service
public class OrderService {
...
}
This means:
- every public method is transactional by default
- unless overridden at method level
This can be useful, but it’s also dangerous if overused.
From experience:
- class-level works well for simple services
- method-level is safer for complex ones
Explicit is better than implicit — especially with transactions.
At this point, we have a solid foundation:
- what a transaction is
- why it matters
- how Spring manages it under the hood
5. Understanding @Transactional Attributes (The Real Ones)
@Transactional is not just an on/off switch.
Behind that single annotation there are rules that control how transactions behave, especially when:
- multiple methods interact
- exceptions are thrown
- concurrent operations happen
Most bugs related to transactions come from default assumptions that turn out to be wrong.
Let’s go through the attributes that actually matter in real projects.
5.1 Propagation: How Transactions Interact with Each Other
Propagation defines what happens when a transactional method is called from another transactional method.
This is by far the most important attribute.
REQUIRED (Default)
@Transactional(propagation = Propagation.REQUIRED)
Meaning:
- join the existing transaction if there is one
- otherwise, create a new transaction
This is what you want most of the time.
Example:
@Transactional
public void placeOrder() {
orderService.saveOrder();
paymentService.charge();
}
If charge() fails:
- the entire transaction rolls back
- nothing is persisted
This is usually correct and desirable.
REQUIRES_NEW
@Transactional(propagation = Propagation.REQUIRES_NEW)
Meaning:
- suspend the existing transaction
- start a completely new one
Classic use case:
- audit logs
- technical events
- actions that must persist even if the main transaction fails
Example:
@Transactional
public void placeOrder() {
orderRepository.save(order);
auditService.logOrderAttempt(order); // REQUIRES_NEW
throw new RuntimeException("Payment failed");
}
Result:
- order → rolled back
- audit log → committed
This is powerful — and dangerous if misused.
Other Propagation Types (Quick but Honest)
-
SUPPORTS→ join if exists, otherwise run non-transactionally -
MANDATORY→ fail if no transaction exists -
NOT_SUPPORTED→ suspend any existing transaction -
NEVER→ fail if a transaction exists -
NESTED→ savepoints (DB-dependent)
👉 In most applications:
-
REQUIREDandREQUIRES_NEWcover 95% of use cases - the others are niche and should be used intentionally
5.2 Isolation: How Much You See of Other Transactions
Isolation defines how concurrent transactions affect each other.
Default:
@Transactional(isolation = Isolation.DEFAULT)
This delegates to the database default (often READ_COMMITTED).
The Practical Levels
- READ_COMMITTED You only see committed data. Good balance between consistency and performance.
- REPEATABLE_READ Data read once won’t change during the transaction. Prevents non-repeatable reads.
- SERIALIZABLE Full isolation. Transactions behave as if executed sequentially. Very safe. Very expensive.
Higher isolation = fewer anomalies = lower throughput.
👉 Rule of thumb:
- trust your DB defaults
- increase isolation only when you have a proven concurrency problem
5.3 Rollback Rules: The Most Common Source of Bugs
This is where many Spring developers get burned.
By default:
Spring rolls back only on unchecked exceptions (
RuntimeException) andError.
That means this will NOT rollback:
@Transactional
public void placeOrder() throws Exception {
orderRepository.save(order);
throw new Exception("Checked exception");
}
From a business perspective, this operation clearly failed.
From Spring’s perspective, however, this is a checked exception — and the transaction is committed.
No rollback. No warning. Just inconsistent data.
Yes, really.
Explicit Rollback Rules
You can override this:
@Transactional(rollbackFor = Exception.class)
Or the opposite:
@Transactional(noRollbackFor = BusinessException.class)
This is essential when:
- using checked exceptions
- modeling business failures explicitly
A Better Approach: Business Exceptions as Runtime Exceptions
In most real-world Spring Boot applications, business failures should invalidate the transaction.
A clean and effective way to model this is by using custom unchecked exceptions:
public class PaymentFailedException extends RuntimeException {
}
@Transactional
public void placeOrder() {
orderRepository.save(order);
throw new PaymentFailedException();
}
This approach has several advantages:
- rollback happens automatically
- transactional behavior is explicit
- no need for extra configuration
- business intent is clear
If the operation fails, the transaction fails. No ambiguity.
👉 Always be explicit if you rely on checked exceptions.
5.4 readOnly: Small Flag, Big Impact
@Transactional(readOnly = true)
This:
- hints the persistence provider
- may optimize flushing and dirty checking
- documents intent clearly
Perfect for:
- query-only service methods
- read-heavy paths
Not a silver bullet — but a good habit.
5.5 timeout: A Safety Net
@Transactional(timeout = 5)
If the transaction runs longer than 5 seconds:
- it’s rolled back
Useful for:
- protecting DB resources
- preventing stuck transactions
Especially relevant under load.
A Hard-Earned Lesson
Most transactional bugs are not caused by:
- wrong SQL
- broken databases
They come from:
- wrong assumptions about propagation
- unexpected rollback behavior
- hidden transactional boundaries
Understanding these attributes turns @Transactional from a “magic annotation” into a precise tool.
6. Common Transactional Pitfalls in Spring Boot
At this point, we understand how transactions should work.
Unfortunately, many transactional bugs don’t come from a lack of knowledge —
they come from small details that are easy to miss and hard to debug.
Let’s go through the most common pitfalls you’ll encounter in real Spring Boot applications.
6.1 Self-Invocation: The Silent Transaction Killer
This is probably the most famous Spring transactional pitfall.
@Service
public class OrderService {
public void placeOrder() {
saveOrder(); // ❌ no transaction
}
@Transactional
public void saveOrder() {
orderRepository.save(order);
}
}
At first glance, this looks fine.
It’s not.
What’s the problem?
The call to saveOrder() happens inside the same class.
It never goes through the Spring proxy.
Result:
-
@Transactionalis completely ignored - no transaction is started
- no error, no warning
This is one of the reasons transactional bugs feel “random”.
How to fix it
- move the transactional method to another bean
- or make sure it’s called from outside the class
@Service
public class OrderPersistenceService {
@Transactional
public void saveOrder(Order order) {
orderRepository.save(order);
}
}
6.2 @Transactional on private (or non-public) Methods
Another classic.
@Transactional
private void saveOrder() {
...
}
This will never work.
Spring proxies intercept public method calls only (by default).
Private, protected, or package-private methods are ignored.
Again:
- no exception
- no warning
- just no transaction
👉 Transactional methods must be public. Always.
6.3 Catching Exceptions and Accidentally Preventing Rollback
This one is subtle — and extremely common.
@Transactional
public void placeOrder() {
try {
paymentService.charge();
} catch (PaymentFailedException ex) {
log.error("Payment failed", ex);
}
}
Looks harmless, right?
But now:
- the exception is swallowed
- the method completes normally
- the transaction is committed
Spring rolls back only if the exception escapes the transactional boundary.
Correct approaches
Either:
- rethrow the exception
- or mark the transaction for rollback manually
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
But in most cases, rethowing is the cleanest solution.
6.4 Mixing Transactional and Non-Transactional Logic
Sometimes transactional methods grow too much:
@Transactional
public void placeOrder() {
orderRepository.save(order);
emailService.sendConfirmationEmail(); // ❌
}
Problems:
- emails are slow
- emails can fail
- emails should not be part of a DB transaction
If the email fails:
- do you really want to rollback the order?
Probably not.
Better approach
- keep transactions short
- isolate side effects
- use events or async processing
Transactional boundaries should protect data consistency, not external systems.
6.5 Unexpected Rollbacks (UnexpectedRollbackException)
Sooner or later, you’ll see this:
UnexpectedRollbackException: Transaction silently rolled back
This usually happens when:
- an inner transactional method marks the transaction as rollback-only
- but the outer method tries to commit
Common causes:
- caught exceptions inside nested transactional calls
- mixed propagation settings
- overuse of
REQUIRES_NEW
When you see this exception:
- don’t look at the commit
- look at what marked the transaction as rollback-only earlier
6.6 Long-Running Transactions
Technically correct. Practically dangerous.
Long transactions:
- lock rows for too long
- reduce throughput
- increase deadlock probability
Common causes:
- doing I/O inside transactions
- calling remote services
- waiting for user input (yes, it happens…)
Rule:
Transactions should be as short as possible, but as long as necessary.
A Pattern You’ll Start to Recognize
Most transactional problems share a common theme:
- the code looks correct
- the behavior is implicit
- Spring does exactly what you told it to do — not what you meant
That’s why:
- understanding proxies
- defining clear boundaries
- modeling failures explicitly
…is more important than memorizing annotations.
7. @Transactional and @Async: A Dangerous Combination
At some point, almost every Spring Boot developer tries to combine:
-
@Transactional→ consistency -
@Async→ performance
On paper, it sounds like a great idea.
In practice, it’s one of the most misunderstood and dangerous combinations in Spring.
Let’s clear things up.
The Core Problem: Different Threads, Different Transactions
@Transactional is thread-bound.
A transaction:
- is associated with the current thread
- lives and dies inside that thread
@Async, on the other hand:
- executes the method in a different thread
- outside the original call stack
So this code:
@Transactional
public void placeOrder() {
orderRepository.save(order);
asyncService.sendConfirmationEmail(order);
}
@Async
public void sendConfirmationEmail(Order order) {
// ...
}
does not mean:
“send the email in the same transaction, but asynchronously”
It means:
“start a completely separate execution, with no transaction at all (unless explicitly defined)”
The Most Common Wrong Assumption
Many developers assume:
“If the async method is called from a transactional one, it participates in the same transaction.”
It doesn’t.
Ever.
Different thread = different transactional context.
What Happens in Practice
Let’s look at a slightly more subtle example:
@Transactional
public void placeOrder() {
orderRepository.save(order);
asyncService.notifyWarehouse(order);
throw new RuntimeException("Payment failed");
}
Possible outcome:
- transaction rolls back
- order is NOT persisted
- async method still runs
- warehouse is notified about an order that doesn’t exist
This is how distributed inconsistencies are born.
Making @Async Transactional (Yes, But…)
You can put @Transactional on an async method:
@Async
@Transactional
public void notifyWarehouse(Order order) {
...
}
This creates:
- a new transaction
- completely independent from the original one
This might be fine — or disastrous — depending on intent.
Again: there is no shared transaction.
Better Patterns Than @Transactional + @Async
1. Transactional Events
Spring provides a much safer mechanism:
@Transactional
public void placeOrder() {
orderRepository.save(order);
applicationEventPublisher.publishEvent(new OrderPlacedEvent(order));
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent event) {
sendEmail(event);
}
Now:
- the event is processed only if the transaction commits
- no ghost side effects
- no inconsistent state
This pattern is gold.
2. Messaging / Event-Driven Architecture
For more complex systems:
- Kafka
- RabbitMQ
- cloud queues
Persist state first, then publish events.
Transactions protect your database, not the world.
A Simple Rule That Saves a Lot of Pain
Never assume an async operation is part of your transaction.
It never is.
If consistency matters:
- finish the transaction
- then trigger async behavior
Always in that order.
Final Thought on @Async
@Async is not dangerous by itself.
What’s dangerous is:
- mixing it with transactions
- without understanding thread boundaries
Once you internalize this model, the behavior becomes predictable — and safe.
8. Transaction Logging and Debugging
One of the most frustrating things about transactional bugs is that everything looks fine until it’s not.
No errors.
No stack traces.
Just data in the wrong state.
When that happens, logging is often the only way to understand what Spring is actually doing.
Let’s see how to make transactions visible.
Why Transactional Bugs Are Hard to Debug
Transactional behavior is:
- implicit
- proxy-based
- spread across multiple layers
So when a transaction:
- starts
- commits
- rolls back
- or is marked as rollback-only
…it usually happens outside your business code.
Without proper logs, you’re debugging blind.
Enabling Transaction Logs in Spring Boot
Spring exposes very useful logs — you just need to turn them on.
In application.yml (or application.properties):
logging:
level:
org.springframework.transaction: DEBUG
This alone already shows:
- when a transaction is created
- when it’s committed
- when it’s rolled back
Logging Transaction Boundaries
With transaction logging enabled, you’ll start seeing logs like:
Creating new transaction with name [OrderService.placeOrder]
Participating in existing transaction
Committing JDBC transaction
Rolling back JDBC transaction
This tells you:
- where the transaction starts
- which method owns it
- how nested calls behave
When debugging propagation issues, this is invaluable.
Hibernate / JPA Logs: Seeing What Actually Hits the DB
Transactions are about when changes are flushed.
To see what is executed, enable SQL logs:
logging:
level:
org.hibernate.SQL: DEBUG
Optionally, parameter binding:
logging:
level:
org.hibernate.type.descriptor.sql: TRACE
Now you can correlate:
- transaction boundaries
- SQL statements
- commit / rollback events
This is often where inconsistencies finally make sense.
Debugging Rollbacks That “Come from Nowhere”
If you’ve ever seen this:
UnexpectedRollbackException: Transaction silently rolled back
it means:
- the transaction was marked as rollback-only earlier
- but the outer layer tried to commit it anyway
To debug this:
- enable transaction logs
- look for a
setRollbackOnlyevent - check for swallowed exceptions
- inspect nested transactional methods
The rollback never comes from nowhere — it’s just hidden.
Business Logging vs Transaction Logging
A common mistake is relying only on business logs:
log.info("Order placed successfully");
This log may appear:
- before the transaction commits
- even if the transaction later rolls back
If you need certainty, log:
- after commit
- or via transactional events
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCommitted(OrderPlacedEvent event) {
log.info("Order committed: {}", event.getOrderId());
}
Now your logs reflect reality, not intent.
A Debugging Workflow That Actually Works
When dealing with transactional issues:
- Enable Spring transaction logs
- Enable SQL logs
- Identify transaction boundaries
- Track exception flow
- Verify commit / rollback timing
This approach turns “random behavior” into deterministic behavior.
Final Takeaway
Transactions don’t fail silently.
They fail quietly.
Logging is what gives them a voice.
Once you get used to reading transaction logs, you’ll start spotting problems before they reach production.
9. Conclusion & Takeaways
Transactions are one of the most powerful tools in Spring Boot — but they are also one of the most misunderstood.
Here’s what you should remember:
- Transactions are about business consistency, not just database operations. Define your transactional boundaries around business operations, not technical details.
-
@Transactionalis declarative, but precise. Understand propagation, isolation, rollback rules, and method visibility. -
Exceptions drive rollbacks.
- Unchecked exceptions → rollback by default
- Checked exceptions → explicit rollback required Model your business exceptions carefully.
-
Avoid common pitfalls:
- Self-invocation
- Private methods
- Catching exceptions and swallowing them
- Long-running transactions
- Mixing async and transactional logic without awareness
Logging is your friend.
Enable transaction and SQL logging to debug propagation, rollback, and commit behavior. Use@TransactionalEventListenerfor post-commit business logging.Async and transactions are tricky.
Transactions are thread-bound. Async methods run in a different thread and have a separate transactional context. Prefer events or queues for safe decoupling.
Final Thought
Spring Boot gives you powerful tools, but with great power comes great responsibility.
Transactions can protect your data, but only if you understand how they work — not just how they look.
💬 I’d love to hear from you:
- What are the trickiest transactional issues you’ve faced?
- Do you have any favorite patterns for async + transactional operations?
- Any hidden pitfalls you’ve learned the hard way?
Share your experiences in the comments — let’s learn from each other.
Top comments (0)