Home Java Spring Boot @Transactional - Private Method Costs $12k: A Real Production Postmortem
Beginner 6 min · July 14, 2026
Spring Boot Annotations Cheat Sheet

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.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.

Follow
Production
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
Before you start⏱ 15-20 min read
  • 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
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

• 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

✦ Definition~90s read
What is Spring Boot Annotations?

Spring's @Transactional annotation on a private method is a no-op because the AOP proxy cannot intercept private method calls, meaning the transaction boundary is never established and your code runs without transactional guarantees.

Think of a restaurant where the waiter (Spring proxy) takes your order and tells the kitchen to start cooking.
Plain-English First

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.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

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.

PaymentService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class PaymentService {

    @Autowired
    private PaymentRepository paymentRepository;

    // THIS WON'T WORK - private method with @Transactional
    @Transactional
    private void processRefund(Long paymentId) {
        Payment payment = paymentRepository.findById(paymentId)
            .orElseThrow(() -> new RuntimeException("Payment not found"));
        payment.setStatus(Status.REFUNDED);
        paymentRepository.save(payment);
        // If this throws, transaction should rollback - but it won't
        auditService.logRefund(paymentId);
    }

    public void initiateRefund(Long paymentId) {
        processRefund(paymentId); // Direct call - no proxy involved
    }
}
Output
No transaction is created. The processRefund method runs without any transactional boundary. If auditService.logRefund throws an exception, the payment status change is NOT rolled back.
⚠ The Silent Failure
📊 Production Insight
In production, we added a custom AspectJ pointcut that logs a WARN message when @Transactional is detected on a non-public method. This catches the issue at startup, not at 2 AM when payments fail.
🎯 Key Takeaway
Private methods with @Transactional are silently ignored. Always use public methods for transaction boundaries.
spring-boot-annotations Spring @Transactional Proxy Architecture Layered stack showing proxy and transaction management Application Layer Service Bean | Private Method AOP Proxy Layer JDK Dynamic Proxy | CGLIB Proxy Transaction Interceptor TransactionInterceptor | PlatformTransactionManager Persistence Layer DataSource | EntityManager THECODEFORGE.IO
thecodeforge.io
Spring Boot Annotations

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.

PaymentServiceFix.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class PaymentService {

    @Autowired
    private PaymentRepository paymentRepository;
    @Autowired
    private PaymentService self; // Self-injection pattern

    // Make it public and call through proxy
    public void initiateRefund(Long paymentId) {
        // This goes through the proxy because 'self' is the injected proxy
        self.processRefund(paymentId);
    }

    @Transactional
    public void processRefund(Long paymentId) {
        Payment payment = paymentRepository.findById(paymentId)
            .orElseThrow(() -> new RuntimeException("Payment not found"));
        payment.setStatus(Status.REFUNDED);
        paymentRepository.save(payment);
        auditService.logRefund(paymentId);
    }
}
Output
Transaction is created. If auditService.logRefund throws an exception, the payment status change is rolled back. The self-injection pattern ensures the proxy is invoked.
🔥Self-Injection Gotcha
📊 Production Insight
In a high-throughput payment system, we switched to AspectJ compile-time weaving for critical transaction paths. The build time increased by 30 seconds, but it eliminated all proxy-related issues.
🎯 Key Takeaway
Self-invocation bypasses the proxy. Use self-injection or AspectJ weaving to handle internal transactional calls.

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.

TransactionPropagationExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class AuditService {

    @Autowired
    private AuditLogRepository auditLogRepository;

    // REQUIRES_NEW: always starts a new transaction
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logPaymentAttempt(Long paymentId, String status) {
        AuditLog log = new AuditLog();
        log.setPaymentId(paymentId);
        log.setStatus(status);
        log.setTimestamp(LocalDateTime.now());
        auditLogRepository.save(log);
    }

    // NESTED: uses savepoints (not reliable with JPA)
    @Transactional(propagation = Propagation.NESTED)
    public void logNested(Long paymentId) {
        // This might not roll back correctly with Hibernate
    }
}
Output
logPaymentAttempt always runs in its own transaction. If the main transaction rolls back, the audit log is still committed. logNested may have inconsistent behavior with JPA.
💡NESTED + JPA = Data Corruption
📊 Production Insight
In a payment gateway, we used REQUIRES_NEW for audit logs and REQUIRED for the main payment flow. This ensured audit data survived even catastrophic failures.
🎯 Key Takeaway
Use REQUIRED for most cases, REQUIRES_NEW for independent sub-operations. Avoid NESTED with JPA.
spring-boot-annotations Public vs Private @Transactional Methods Trade-offs in transaction management with Spring AOP Public Method Private Method Proxy Interception Fully intercepted by AOP proxy Bypassed; no transaction created Transaction Rollback Automatic on RuntimeException No rollback; partial updates persist Code Encapsulation Less encapsulated; exposed to callers Better encapsulation; hidden logic Testing Complexity Easier to mock and test Harder to test transaction behavior Performance Overhead Slight proxy overhead No proxy overhead, but risky THECODEFORGE.IO
thecodeforge.io
Spring Boot Annotations

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().

RollbackRulesExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class PaymentService {

    @Transactional(rollbackFor = Exception.class)
    public void processPayment(PaymentRequest request) throws Exception {
        try {
            // Payment processing logic
            paymentGateway.charge(request); // throws IOException (checked)
        } catch (IOException e) {
            // WRONG: caught exception, transaction won't roll back
            log.error("Payment failed", e);
            // Should rethrow or call:
            // TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
            throw e; // This is the fix - rethrow
        }
    }
}
Output
With the catch block swallowing the exception, the transaction commits. With the throw e, it rolls back because we set rollbackFor = Exception.class.
⚠ The Silent Commit
📊 Production Insight
We added a custom AOP aspect that logs a warning whenever a transactional method catches an exception without rethrowing. This caught several bugs during code review.
🎯 Key Takeaway
Always set rollbackFor = Exception.class for critical transactions. Never swallow exceptions in transactional methods.

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.

TransactionTimeoutExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class BatchService {

    @Transactional(timeout = 10) // 10 seconds
    public void processBatch(List<Payment> payments) {
        for (Payment payment : payments) {
            // If this takes more than 10 seconds, transaction times out
            processSinglePayment(payment);
        }
    }

    @Transactional(timeout = 5) // This is ignored if called from processBatch
    public void processSinglePayment(Payment payment) {
        // The outer transaction's timeout applies
    }
}
Output
The processBatch transaction times out after 10 seconds. The processSinglePayment timeout is ignored because it joins the existing transaction.
⚠ Timeout Ignorance
📊 Production Insight
We monitor transaction duration with Micrometer metrics and alert if any transaction takes longer than 80% of its timeout. This catches slow queries before they cause timeouts.
🎯 Key Takeaway
Always set explicit timeouts on transactions. Test that the timeout actually works with your database and connection pool.

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.

ReadOnlyTransactionExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class ReportService {

    @Transactional(readOnly = true)
    public Report generateReport(Long customerId) {
        Customer customer = customerRepository.findById(customerId)
            .orElseThrow();
        // BAD: modifying entity in read-only transaction
        customer.setLastReportGenerated(LocalDateTime.now());
        // This will still be flushed to DB!
        return new Report(customer);
    }
}
Output
The customer entity is modified and flushed to the database despite readOnly = true. The readOnly flag does NOT prevent writes in Hibernate.
💡Read-Only != Immutable
📊 Production Insight
We added a Hibernate interceptor that logs a warning when an entity is modified in a transaction marked as readOnly. This caught several bugs in reporting services.
🎯 Key Takeaway
Use readOnly = true only for true read operations. Don't modify entities in read-only transactions.

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.

IsolationLevelExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class AccountService {

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) {
        Account from = accountRepository.findById(fromAccountId)
            .orElseThrow();
        Account to = accountRepository.findById(toAccountId)
            .orElseThrow();
        
        if (from.getBalance().compareTo(amount) < 0) {
            throw new InsufficientFundsException();
        }
        
        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
    }
}
Output
With SERIALIZABLE, concurrent transfers are serialized, preventing race conditions. However, throughput drops significantly under load. Use PESSIMISTIC_WRITE locks for better performance.
🔥SERIALIZABLE Performance
📊 Production Insight
In a high-volume payment system, we used READ_COMMITTED for most operations and added PESSIMISTIC_WRITE locks on account balances. This gave us the right balance of performance and consistency.
🎯 Key Takeaway
Choose isolation levels based on your concurrency requirements. Test under load to catch deadlocks and race conditions.

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.

TransactionDebugEndpoint.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
public class TransactionDebugController {

    @GetMapping("/actuator/transaction-status")
    public Map<String, Object> getTransactionStatus() {
        Map<String, Object> status = new HashMap<>();
        status.put("currentTransactionName", 
            TransactionSynchronizationManager.getCurrentTransactionName());
        status.put("isActualTransactionActive", 
            TransactionSynchronizationManager.isActualTransactionActive());
        status.put("currentReadOnlyStatus", 
            TransactionSynchronizationManager.isCurrentTransactionReadOnly());
        return status;
    }
}
Output
Returns JSON with current transaction name, active status, and read-only status. Useful for debugging transaction boundaries in real-time.
🔥Production Debugging
📊 Production Insight
We created a custom Spring Boot starter that exposes transaction metrics (active transactions, commit rate, rollback rate) via Micrometer. This helped us detect a transaction leak that was causing connection pool exhaustion.
🎯 Key Takeaway
Use dynamic logging and actuator endpoints to debug transactions in production without restarts.
● Production incidentPOST-MORTEMseverity: high

The $12k Private Method Incident

Symptom
Random payment failures during peak hours (10 AM - 2 PM UTC). Transaction logs showed 'No transaction in progress' errors. Payments were partially processed — some succeeded, some failed with no clear pattern.
Assumption
The team assumed the database connection pool was exhausted or the network was unreliable. They doubled the pool size and added retry logic, which made things worse by masking the underlying issue.
Root cause
A private method annotated with @Transactional in the PaymentService class. Spring's CGLIB proxy couldn't intercept the private method, so the transaction boundary was never created. The method ran without any transactional context.
Fix
Changed the method visibility from private to public. Added @Transactional on the public method that calls it, ensuring the proxy intercepts the call. Also added transaction logging with 'logging.level.org.springframework.transaction=TRACE' to detect similar issues in the future.
Key lesson
  • 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
Production debug guideStep-by-step approach to diagnose transaction issues in live systems4 entries
Symptom · 01
Data inconsistency after exceptions
Fix
Enable TRACE logging for org.springframework.transaction and org.springframework.orm.jpa. Check if the transaction is being created and rolled back. Look for 'No transaction in progress' errors.
Symptom · 02
Connection pool exhaustion
Fix
Check for long-running transactions using SELECT * FROM pg_stat_activity (PostgreSQL) or SHOW PROCESSLIST (MySQL). Look for idle-in-transaction connections. Set explicit timeouts on @Transactional.
Symptom · 03
Unexpected commits despite exceptions
Fix
Verify that the exception is a RuntimeException or that rollbackFor is set correctly. Check if the exception is being caught and swallowed inside the transactional method.
Symptom · 04
Transactions not rolling back in tests
Fix
Ensure tests are using @Transactional correctly. Check if the test method is public. Verify that the test is not catching exceptions. Use @Rollback on test methods.
★ Quick Transaction Debug Cheat SheetRapid-fire commands and actions for common transaction issues
Transaction not starting
Immediate action
Check if method is public
Commands
logging.level.org.springframework.transaction=TRACE
curl /actuator/transaction-status
Fix now
Change method to public or use self-injection
Transaction not rolling back+
Immediate action
Check exception type and rollbackFor
Commands
logging.level.org.springframework.orm.jpa=TRACE
SELECT * FROM pg_stat_activity WHERE state = 'idle in transaction'
Fix now
Add rollbackFor = Exception.class and rethrow exceptions
Connection pool exhausted+
Immediate action
Kill idle transactions
Commands
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'idle in transaction' AND age > interval '5 minutes'
SHOW VARIABLES LIKE '%timeout%'
Fix now
Set @Transactional(timeout = 30) and reduce pool size
FeaturePrivate Method @TransactionalPublic Method @Transactional
Proxy interceptionNot intercepted — annotation ignoredIntercepted by proxy
Transaction creationNo transaction createdTransaction created as configured
Self-invocationBypasses proxyBypasses proxy (same class)
AspectJ weavingWorks with compile-time weavingWorks with both proxy and weaving
Debugging difficultyHard — no error or warningEasy — logging shows boundaries
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
PaymentService.java@ServiceThe Proxy Problem
PaymentServiceFix.java@ServiceWhat the Official Docs Won't Tell You
TransactionPropagationExample.java@ServiceTransaction Propagation
RollbackRulesExample.java@ServiceTransaction Rollback Rules
TransactionTimeoutExample.java@ServiceTransaction Timeout
ReadOnlyTransactionExample.java@ServiceRead-Only Transactions
IsolationLevelExample.java@ServiceTransaction Isolation Levels
TransactionDebugEndpoint.java@RestControllerDebugging Transactions in Production

Key takeaways

1
Never put @Transactional on private methods
it's silently ignored by Spring's proxy
2
Self-invocation bypasses the proxy; use self-injection or AspectJ weaving for internal calls
3
Always set rollbackFor = Exception.class for critical transactions and never swallow exceptions
4
Enable transaction logging (TRACE) in development to verify transaction boundaries
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Why does @Transactional not work on private methods in Spring?
Q02SENIOR
Explain the self-invocation problem in Spring transactions and how to so...
Q03JUNIOR
What is the default rollback behavior of @Transactional and how do you c...
Q01 of 03SENIOR

Why does @Transactional not work on private methods in Spring?

ANSWER
Spring uses AOP proxies to intercept method calls and manage transactions. Private methods are not part of the public interface, so the proxy cannot intercept them. When a private method is called internally, it goes directly to the 'this' reference, bypassing the proxy entirely. The annotation is silently ignored.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Can I use @Transactional on a private method with AspectJ weaving?
02
What happens if I call a @Transactional method from the same class without self-injection?
03
Does @Transactional work on methods in a @Configuration class?
04
How do I test that @Transactional is working correctly?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.

Follow
Verified
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

6 min read · try the examples if you haven't

Previous
Building a REST API with Spring Boot
5 / 121 · Spring Boot
Next
Spring Boot with MySQL and JPA