← Back to list

Part 3 — The Self Invocation Problem in Spring Transactions (Why @Transactional Sometimes Doesn’t…

1. Introduction

Varun Tewani · 2026-03-18 06:16 · 0 claps · 7.5 min read
#transactional #self-invocation #spring-transactional #springtransactionsseries #part-3
Open on Medium ↗

Part 3 — The Self Invocation Problem in Spring Transactions (Why @Transactional Sometimes Doesn’t Work)

1. Introduction

Have you ever added @Transactional to a method, tested your code, and watched in confusion as the transaction silently did not work? The data was saved anyway (or worse, partially saved), and you spent hours debugging, only to find that the transaction annotation was simply… ignored?

You’re not alone. This is one of the most common and frustrating issues Spring developers encounter.

In the previous article, we learned how Spring implements transactions internally using AOP and proxy objects. Spring creates a proxy around beans annotated with @Transactional, and this proxy intercepts method calls to manage transaction behavior.

However, this proxy‑based approach introduces an important limitation known as the Self Invocation Problem.

In this article, we will understand:

  • What self invocation means
  • Why @Transactional sometimes does not work
  • How Spring proxies cause this issue
  • Multiple ways to fix it in real applications
  • Best practices to avoid this pitfall

This is a very common interview question and a real‑world bug that can corrupt data if not understood properly.

2. What is Self Invocation?

Self invocation happens when a method inside a class calls another method of the same class.

Example

@Service
public class EmployeeService {

    public void processEmployee() {
        updateEmployee();  // internal call (self invocation)
    }

    @Transactional
    public void updateEmployee() {
        // database update logic
        employeeRepository.save(new Employee());
    }
}

Here:

  • processEmployee() calls updateEmployee()
  • Both methods belong to the same class (EmployeeService)

3. The Expected Behavior

A developer writing this code might expect the following flow:

processEmployee() called
      ↓
updateEmployee() called
      ↓
@Transactional starts a transaction
      ↓
Database update occurs inside a transaction
      ↓
Transaction commits (or rolls back on error)

But this is NOT what happens.

4. What Actually Happens

Because the call happens inside the same class, the Spring proxy is bypassed entirely.

Actual Flow

Controller
     ↓
EmployeeService.processEmployee()  ← called on the proxy
     ↓
Direct call to updateEmployee()    ← uses 'this', NOT the proxy
     ↓
Proxy NOT involved
     ↓
@Transactional ignored
     ↓
Database update runs WITHOUT a transaction

Visual Diagram

The red path shows the problem: updateEmployee() is called directly on the original object (this), so the proxy never gets a chance to start a transaction.

5. Why This Happens (The Technical Reason)

As explained in Part 2, Spring transactions work using proxy objects. The architecture looks like this:

Normal (Working) Transaction Flow

Transactions are applied only when the method call goes through the proxy.

The Problem: Internal Calls Use this

When processEmployee() calls updateEmployee() internally, Java uses the current object reference (this). In a proxied Spring bean, this refers to the original object, not the proxy.

EmployeeService Proxy (managed by Spring)
         ↑
         |  (outside call goes through proxy)
         |
Original EmployeeService object (contains 'this')
         |
         |  (internal call uses 'this' → bypasses proxy)
         ↓
updateEmployee() called directly

Since the proxy is skipped, Spring never intercepts the call, and the @Transactional annotation is ignored.

6. Complete Example Scenario

Let’s see this in a complete, runnable example:

@Service
public class EmployeeService {

    private final EmployeeRepository employeeRepository;

    public EmployeeService(EmployeeRepository employeeRepository) {
        this.employeeRepository = employeeRepository;
    }

    public void processEmployee() {
        System.out.println("Inside processEmployee - about to call updateEmployee");
        updateEmployee();  // self invocation!
    }

    @Transactional
    public void updateEmployee() {
        System.out.println("Inside updateEmployee - saving employee");
        employeeRepository.save(new Employee("John"));
        // If an exception occurs here, it should roll back
    }
}

Test it:

@RestController
public class EmployeeController {

    @Autowired
    private EmployeeService employeeService;

    @GetMapping("/test")
    public String test() {
        employeeService.processEmployee();  // ← calls the proxy
        return "Check logs and database";
    }
}

What you’ll see in the logs:

  • Inside processEmployee — about to call updateEmployee
  • Inside updateEmployee — saving employee
  • Employee saved to database

What you WON’T see:

  • No transaction start log
  • No transaction commit log
  • If an exception occurs in updateEmployee(), the employee is still saved because there's no transaction to roll back

7. When Transactions Work Correctly

Transactions work when the call comes from another bean, because the proxy is involved.

Correct Flow

@RestController
public class EmployeeController {

    @Autowired
    private EmployeeService employeeService;  // ← this is the proxy!

    public void createEmployee() {
        employeeService.updateEmployee();  // ← call goes through proxy
    }
}

Here the proxy intercepts the call, so the transaction works perfectly.

8. How to Fix the Self Invocation Problem

There are several ways to solve this issue. Let’s explore them from most recommended to least recommended.

Solution 1: Move the Method to Another Service (Recommended ✅)

The cleanest solution is to split the transactional logic into a separate service.

@Service
public class EmployeeService {

    private final EmployeeTransactionService transactionService;

    public EmployeeService(EmployeeTransactionService transactionService) {
        this.transactionService = transactionService;
    }

    public void processEmployee() {
        transactionService.updateEmployee();  // ← calls through proxy
    }
}

@Service
public class EmployeeTransactionService {

    @Transactional
    public void updateEmployee() {
        // database update logic
        employeeRepository.save(new Employee());
    }
}

Why this works: The call goes from EmployeeService (which may or may not be proxied) to EmployeeTransactionService (which is a separate bean). Spring injects the proxy of EmployeeTransactionService, so @Transactional is applied.

Pros:

  • Clean separation of concerns
  • Follows Single Responsibility Principle
  • Easy to test
  • No circular dependencies

Cons:

  • Slightly more classes
  • May feel like overkill for simple cases

Solution 2: Self-Injection (Use with Caution ⚠️)

You can inject the service into itself, then call the transactional method on the injected reference.

@Service
public class EmployeeService {

    @Autowired
    private EmployeeService self;  // self-reference (proxy)

    public void processEmployee() {
        self.updateEmployee();  // ← calls through proxy
    }

    @Transactional
    public void updateEmployee() {
        employeeRepository.save(new Employee());
    }
}

Why this works: self is the Spring‑managed proxy, not the original object. Calling self.updateEmployee() goes through the proxy.

Pros:

  • Minimal code change
  • Keeps all logic in one class

Cons:

  • Creates a circular reference (Spring handles it, but it’s conceptually messy)
  • Can confuse developers and static analysis tools
  • May cause issues with some AOP configurations
  • Harder to unit test (mocking self is awkward)

When to use: Only as a temporary fix or in very simple cases where creating a new service feels overkill. Not recommended for production codebases.

Solution 3: Use Programmatic Transaction Management

Instead of relying on @Transactional, you can manually control transactions using TransactionTemplate.

@Service
public class EmployeeService {

    private final TransactionTemplate transactionTemplate;
    private final EmployeeRepository employeeRepository;

    public EmployeeService(TransactionTemplate transactionTemplate,
                          EmployeeRepository employeeRepository) {
        this.transactionTemplate = transactionTemplate;
        this.employeeRepository = employeeRepository;
    }

    public void processEmployee() {
        updateEmployee();  // internal call is fine now
    }

    public void updateEmployee() {
        transactionTemplate.executeWithoutResult(status -> {
            employeeRepository.save(new Employee());
        });
    }
}

Why this works: TransactionTemplate directly uses the transaction manager, bypassing the need for proxies.

Pros:

  • Works regardless of how the method is called
  • Fine‑grained control over transaction boundaries

Cons:

  • More verbose
  • Mixes transaction logic with business logic
  • Loses declarative style

When to use: When you need complex transaction logic (like multiple savepoints) or when you’re already using programmatic transactions elsewhere.

Solution 4: Use AspectJ Compile-Time Weaving (Advanced)

Spring can also use AspectJ to weave transaction logic directly into the bytecode, eliminating the need for proxies entirely.

@Configuration
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
public class TransactionConfig {
    // configuration
}

Pros:

  • Solves self invocation completely
  • Works with internal calls

Cons:

  • Requires compile‑time weaving or load‑time weaving setup
  • More complex build process
  • Not as commonly used in typical Spring Boot applications

When to use: Large enterprise applications where proxy limitations become a major issue and you can manage the build complexity.

9. Comparison of Solutions

10. Why This Knowledge is Important

Understanding the self invocation problem helps developers avoid common issues:

  • @Transactional silently not working — Data gets saved when it shouldn't, or not saved when it should
  • Unexpected database commits — Partial updates when exceptions occur
  • Difficult debugging scenarios — Hours wasted wondering why transactions are ignored
  • Data inconsistency — In production, this can corrupt business data

This concept is also frequently discussed in backend interviews. Knowing the why and how to fix it separates experienced Spring developers from beginners.

11. Common Interview Questions

Q1: What is the self invocation problem? A: It occurs when a method inside a Spring bean calls another method of the same bean that is annotated with @Transactional. Because the call bypasses the Spring proxy, the transaction is not applied.

Q2: Why does self invocation bypass the proxy? A: The proxy is only used for external calls. Internal calls use the this reference, which points to the original object, not the proxy.

Q3: How can you fix the self invocation problem? A: Move the transactional method to a separate service, use self‑injection, use programmatic transactions, or switch to AspectJ weaving.

Q4: Does @Transactional work on private methods? A: No, because the proxy cannot access private methods. The same proxy limitation applies.

Key Takeaways

  • Spring transactions work using AOP proxies
  • The proxy intercepts method calls to apply transaction logic
  • Self invocation (internal method calls) bypasses the proxy
  • When the proxy is bypassed, @Transactional is ignored
  • Transactions work only when methods are called through the proxy
  • Moving transactional logic to a separate service is the cleanest solution
  • Self‑injection can work but is not recommended for production
  • Programmatic transactions and AspectJ are alternative approaches

🧠 Quick Challenge for You!

Consider the following code:

@Service
public class UserService {

    @Autowired
    private UserService self;

    public void process() {
        update();
    }

    @Transactional
    public void update() {
        // update database
    }
}

Questions:

  1. Will update() run inside a transaction when process() is called from a controller?
  2. What change would make it transactional?
  3. Why is self‑injection considered less clean than a separate service?

👇 Drop your answers in the comments below! This is a common interview question.

💬 Let’s Discuss!

  • Have you ever debugged a transaction that wasn’t rolling back due to self invocation?
  • Which solution do you prefer and why?
  • Any other proxy‑related pitfalls you’ve encountered?

Leave a comment below — let’s learn together! 👇

🔜 What’s Next

In the next article, we’ll dive into Hibernate Persistence Context.

👉 **Part 4 — Understanding Hibernate Persistence Context**

We’ll explore:

  • What is a persistence context?
  • How it relates to JPA and Hibernate
  • The lifecycle of managed, detached, and transient entities
  • How the persistence context enables features like dirty checking and lazy loading
  • First-level cache and its role in performance

Understanding the persistence context is crucial before we tackle dirty checking and lazy loading in later articles.

📚 Spring Transactions & Hibernate Internals Series


메타데이터
post_id
d2f9e1641b51
slug
the-self-invocation-problem-in-spring-transactions-why-transactional-sometimes-doesnt-work-d2f9e1641b51
url
https://medium.com/@varuntewani01/the-self-invocation-problem-in-spring-transactions-why-transactional-sometimes-doesnt-work-d2f9e1641b51
canonical_url
https://medium.com/@varuntewani01/the-self-invocation-problem-in-spring-transactions-why-transactional-sometimes-doesnt-work-d2f9e1641b51
author_url
https://medium.com/@varuntewani01
status
ok
fetched_at
2026-07-13 06:23:13