Spring Boot @Transactional - Private Method Costs $12k: A Real Production Postmortem
Learn how a private method with @Transactional caused a $12k production incident in a SaaS billing system.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- ✓Java 17+ installed
- ✓Spring Boot 3.2+ project with spring-boot-starter-data-jpa
- ✓Basic understanding of AOP and proxies
- ✓A MySQL or PostgreSQL database for testing
• Private methods with @Transactional are ignored by Spring's AOP proxy because they can't be intercepted • Always use public methods for transaction boundaries • Self-invocation bypasses the proxy entirely • Enable transaction logging to catch misconfigurations early • Use compile-time weaving or AspectJ for private method transactions if absolutely necessary
Think of a restaurant where the waiter (Spring proxy) takes your order and tells the kitchen to start cooking. If the chef (private method) tries to order ingredients directly without going through the waiter, the kitchen won't know to start a new cooking session (transaction). The waiter can only handle public requests. Private methods are like the chef whispering to themselves — nobody else hears it.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
I've been building payment systems since Spring 3.0, and I've seen my share of production fires. But the $12k incident in March 2023 at a SaaS billing platform still haunts me. The root cause? A single private method annotated with @Transactional. That's it. One annotation on the wrong visibility modifier cost the company twelve thousand dollars in failed transactions, manual refunds, and customer churn. The symptom was subtle: some payments would randomly fail during peak hours. Our monitoring showed transaction rollbacks, but the code looked fine. We spent three days blaming the database, the network, even the JVM. When we finally found the private method with @Transactional, I wanted to scream. This isn't a bug — it's a fundamental misunderstanding of how Spring's AOP proxies work. Spring creates a proxy around your bean to intercept method calls. When you call a private method, the proxy can't intercept it because private methods aren't part of the public interface. The annotation is silently ignored. Your transaction never starts. This article is my attempt to save you from the same pain. We'll dive deep into proxy mechanics, common pitfalls, and production debugging techniques. By the end, you'll never make this mistake again.
The Proxy Problem: Why Private Methods Are Invisible
Spring's transaction management relies on AOP proxies. When you annotate a method with @Transactional, Spring creates a proxy object that wraps your bean. When a client calls a method on the proxy, the proxy intercepts the call, starts a transaction, delegates to the actual method, and then commits or rolls back. The key word here is 'intercepts.' The proxy can only intercept calls that go through its public interface. Private methods are internal implementation details — they're not exposed through the proxy. When you call a private method within the same class, you're calling it directly on the 'this' reference, not through the proxy. The annotation is completely ignored. I've seen teams spend days debugging why transactions aren't rolling back, only to find a private method. The fix is simple: make the method public or move the transactional logic to a public method that calls the private one. Let's look at the code.
What the Official Docs Won't Tell You
The Spring documentation mentions that @Transactional works on public methods, but it doesn't emphasize the severity of getting it wrong. It says 'proxy mode' and 'self-invocation' in passing, but new developers don't connect the dots. Here's what they don't tell you: self-invocation is the silent killer. When method A in the same class calls method B, and method B has @Transactional, the transaction is ignored. The proxy is bypassed entirely. This is because Spring uses a proxy-based AOP model by default. The proxy wraps your bean, but internal calls within the bean don't go through the proxy. There are two solutions: either make the transactional method public and call it from another bean (inject the service into itself), or switch to AspectJ compile-time weaving which can intercept private methods. AspectJ weaving requires a build plugin and changes your compilation process. I've used it in two projects where we had legacy code with private transactional methods that we couldn't refactor. It works, but it adds complexity. The simpler fix is to refactor the code. Also, note that @Transactional on interfaces works only with JDK dynamic proxies, not CGLIB. If you're using interface-based proxies, the annotation must be on the interface method, not the implementation.
Transaction Propagation: The Hidden Pitfall in Nested Calls
Even when you get the proxy right, propagation levels can bite you. The default propagation is REQUIRED, which means if a transaction exists, the method joins it. But what if you need a new transaction for a sub-operation? That's where REQUIRES_NEW comes in. I once worked on a billing system where we had to log every payment attempt, even if the main transaction failed. We used REQUIRES_NEW on the log method. But because of a private method issue (yes, again), the log was never written. The fix was to make the log method public and ensure it was called through the proxy. Another common mistake is using NESTED propagation, which uses savepoints. This works with JDBC but not with JPA in many cases. The Hibernate documentation explicitly says savepoints are unreliable with JPA. I've seen teams use NESTED and then wonder why their data is inconsistent. Stick to REQUIRED and REQUIRES_NEW for most cases. NEVER use MANDATORY unless you're absolutely sure a transaction exists — it throws an exception if there's no transaction. I've seen this in batch processing where the batch framework didn't start a transaction, and suddenly all jobs failed.
Transaction Rollback Rules: Checked Exceptions Don't Roll Back
Here's another classic: by default, @Transactional only rolls back for RuntimeException and Error, not checked exceptions. I've seen teams write code like this: @Transactional on a method that throws SQLException (checked), and they expect it to roll back. It doesn't. The transaction commits, and you have corrupted data. The fix is to explicitly set rollbackFor on the annotation. I always use rollbackFor = Exception.class in payment systems because any exception should abort the transaction. But be careful — if you catch the exception within the method, Spring doesn't know about it, and the transaction commits anyway. You need to rethrow or mark the transaction for rollback manually using TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(). I've debugged countless incidents where someone caught an exception, logged it, and returned a failure response, but the transaction committed because Spring never saw the exception. The golden rule: if you catch an exception in a transactional method, either rethrow it or call setRollbackOnly().
Transaction Timeout: The Silent Killer of Long-Running Operations
Default transaction timeout is -1, meaning no timeout. In a web application, this is dangerous. A slow database query or a deadlock can hold a transaction open indefinitely, consuming connections from the pool. I've seen a production incident where a reporting query ran for 30 minutes inside a transaction, exhausting the connection pool and taking down the entire application. The fix was to set a timeout on the @Transactional annotation. For payment processing, I use timeout = 5 (seconds). For batch jobs, I use timeout = 300 (5 minutes). But here's the catch: the timeout starts when the transaction begins, not when the method is called. If you have nested transactional methods, the timeout applies to the outermost transaction. Also, the timeout is only enforced if the underlying database supports it. MySQL with InnoDB supports it, but some embedded databases don't. Always test that your timeout actually works. I once set a timeout on a PostgreSQL transaction and it was ignored because the connection was configured with a different timeout. Check your connection pool settings too.
Read-Only Transactions: The Optimization That Backfires
Marking a transaction as readOnly = true is supposed to optimize performance by skipping dirty checks and allowing the database to use read-only connections. But I've seen two common mistakes. First, people put @Transactional(readOnly = true) on methods that actually write data. Spring doesn't enforce this — it's just a hint. The write still happens. Second, in MySQL with replication, read-only transactions might be routed to a read replica, but if you then try to write, you get an error because the replica is read-only. I've debugged a production issue where a 'read-only' service method was writing to an audit table, and the transaction was routed to a read replica, causing a 'cannot execute statement in a read-only transaction' error. The fix was to remove the readOnly flag or use a separate transaction for the write. Also, readOnly = true doesn't prevent Hibernate from flushing changes. If you modify an entity in a read-only transaction, Hibernate will still flush it to the database. Use @Transactional(readOnly = true) only for methods that truly only read data, and never modify entities within them.
Transaction Isolation Levels: When Dirty Reads Cost You Money
Isolation levels control how transaction visibility affects concurrent access. The default is usually READ_COMMITTED, which prevents dirty reads but allows non-repeatable reads and phantom reads. In a payment system, READ_COMMITTED can cause issues: if two transactions read the same account balance, they might both think there's enough money, and both succeed, causing an overdraft. This is a real problem I've seen in a fintech startup. They used READ_COMMITTED and had a race condition that allowed double-spending. The fix was to use SERIALIZABLE for the balance check, but that comes with a performance cost. We ended up using PESSIMISTIC_WRITE locks instead, which are more granular. Another issue is with REPEATABLE_READ in MySQL: it can cause gap locks that lead to deadlocks under high concurrency. I've spent nights debugging deadlocks caused by REPEATABLE_READ in a high-traffic e-commerce system. The lesson: understand your isolation level and test under load. For most web applications, READ_COMMITTED is fine. For financial transactions, consider using SERIALIZABLE or explicit locks.
Debugging Transactions in Production: Tools and Techniques
When transactions go wrong in production, you need visibility. Spring provides excellent logging capabilities. Set 'logging.level.org.springframework.transaction=TRACE' and 'logging.level.org.springframework.orm.jpa=TRACE'. This will show you when transactions start, commit, and roll back. But in production, you can't always enable TRACE logging due to volume. Use a dynamic logging framework like Logback with a JMX-based appender that allows you to change log levels at runtime. I've used this to debug a transaction issue in a live system without restarting. Another technique is to use Spring's TransactionSynchronizationManager to get the current transaction status. You can expose this through an actuator endpoint for debugging. Also, consider using Spring Cloud Sleuth or Micrometer tracing to correlate transactions with requests. In one incident, we used distributed tracing to find that a transaction was timing out because a downstream service was slow. The trace showed the transaction span lasting 30 seconds, while the database query was only 2 seconds. The culprit was an HTTP call within the transaction. Always move external calls outside the transaction if possible.
The $12k Private Method Incident
- Never put @Transactional on private methods — it's silently ignored
- Always enable transaction logging in development to verify boundaries
- Use integration tests that actually verify transaction rollback behavior
- Add a custom annotation processor or ArchUnit test to catch private @Transactional at compile time
logging.level.org.springframework.transaction=TRACEcurl /actuator/transaction-status| File | Command / Code | Purpose |
|---|---|---|
| PaymentService.java | @Service | The Proxy Problem |
| PaymentServiceFix.java | @Service | What the Official Docs Won't Tell You |
| TransactionPropagationExample.java | @Service | Transaction Propagation |
| RollbackRulesExample.java | @Service | Transaction Rollback Rules |
| TransactionTimeoutExample.java | @Service | Transaction Timeout |
| ReadOnlyTransactionExample.java | @Service | Read-Only Transactions |
| IsolationLevelExample.java | @Service | Transaction Isolation Levels |
| TransactionDebugEndpoint.java | @RestController | Debugging Transactions in Production |
Key takeaways
Interview Questions on This Topic
Why does @Transactional not work on private methods in Spring?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
That's Spring Boot. Mark it forged?
6 min read · try the examples if you haven't