Here’s the English translation of the article body.


You added @Transactional, but nothing rolls back when an exception is thrown. You added @Cacheable, but a query hits the database every single time. You added @Async, but for some reason it runs synchronously. These three symptoms look unrelated at first glance, but the root cause is almost always the same.

In every case, the method is being called without going through the Spring AOP proxy. That’s all there is to it.

This article is not a comprehensive tour of how proxies work. It’s a troubleshooting guide focused on “what to check and how to fix it when the annotation doesn’t take effect.” Start with the diagnostic checklist, figure out which pattern your code matches, and jump to the corresponding section.

Start with the Diagnostic Checklist

SymptomWhat’s actually happening
No rollbackTransactionInterceptor is not being invoked
Not cached, hits the DB every timeCacheInterceptor is not being invoked
Runs synchronouslyAsyncExecutionInterceptor is not being invoked

The interceptors live inside the proxy, so if the call bypasses the proxy, nothing happens. There are five typical patterns that bypass the proxy:

  1. Calling the method from within the same class via this.method() (self-invocation)
  2. Putting the annotation on a private or final method
  3. The class is final, so CGLIB cannot create a subclass
  4. Instances created with new, or static methods
  5. Forgetting to enable the feature with @EnableAsync / @EnableCaching, etc.

If none of these apply, see “Similar Symptoms with Different Causes” near the end of the article.

Why Only “External Calls” Work

Spring AOP does not rewrite the Bean itself. Instead, it creates a proxy that wraps the Bean and registers that proxy in the container. What gets injected into other Beans is this proxy, not the actual Bean.

呼び出し元Bean
   │ userService.register()

[プロキシ] ── TransactionInterceptor / CacheInterceptor / AsyncExecutionInterceptor


[実Bean] UserService.register()
   │ this.saveWithTx()  ← プロキシを通らず実Beanを直接呼ぶ

[実Bean] UserService.saveWithTx()  … アノテーションは無視される

When you call userService.register() from outside, the proxy runs the interceptors and then delegates to the actual Bean. But when you write this.saveWithTx() inside the actual Bean, this refers to the actual Bean itself, so the call skips the entire proxy layer.

For the AOP terminology itself (Aspect, Advice, JoinPoint), see the AOP fundamentals article. Here, we’ll focus solely on “whether the call goes through the proxy.”

Pattern 1: Calls Within the Same Class (Self-Invocation)

This is by far the most common one. Let’s look at code that reproduces it.

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void register(User user) {
        // 同じクラス内の呼び出し = this.saveWithTx(user)
        saveWithTx(user);
    }

    @Transactional
    public void saveWithTx(User user) {
        userRepository.save(user);
        throw new IllegalStateException("わざと失敗");
    }
}

When register() is called from outside, the @Transactional on saveWithTx() is ignored. Since no transaction is started, save() is auto-committed at that point, and throwing an exception does not roll anything back.

The structure is identical for @Cacheable and @Async.

public List<User> findAllTwice() {
    findAll(); // 毎回DBに行く
    return findAll();
}

@Cacheable("users")
public List<User> findAll() {
    return userRepository.findAll();
}

public void notify(User user) {
    sendMail(user); // 呼び出し元スレッドで同期実行される
}

@Async
public void sendMail(User user) {
    log.info("thread={}", Thread.currentThread().getName());
}

If you look at the log output from sendMail(), you’ll see a request thread name like http-nio-8080-exec-1 instead of task-1. That’s the proof that the call did not go through the proxy.

@Retryable fails to retry for the same reason. This is covered in more detail in the Spring Retry article.

Pattern 2: Annotating private or final Methods

Spring Boot’s default proxy is CGLIB, which dynamically generates a subclass of the target class. Methods that cannot be overridden in a subclass cannot be intercepted.

@Transactional
private void saveInternal(User user) { // 静かに無視される
    userRepository.save(user);
}

@Transactional
public final void saveFinal(User user) { // これも無視される
    userRepository.save(user);
}

This does not produce a compile error. IntelliJ IDEA will warn you with “Methods annotated with @Transactional must be overridable,” but a CLI build says nothing, which makes it tricky. For final methods, Spring does print “Final method … cannot get proxied via CGLIB” in the startup log, so that’s another clue.

Note that since Spring Framework 6.0, @Transactional also works on protected and package-private methods, but only with class-based proxies. That said, the official documentation still recommends public, so just make it public and move on.

Once you’ve changed it to public, also check whether that method is being called from within the same class (Pattern 1). These two issues are frequently intertwined.

Pattern 3: final Class That CGLIB Cannot Subclass

If the class itself is final, CGLIB cannot create a subclass. In this case, rather than being silently ignored, the application fails at startup.

Caused by: org.springframework.aop.framework.AopConfigException:
  Could not generate CGLIB subclass of class com.example.UserService:
  Common causes of this problem include using a final class or a non-visible class

It’s rare to intentionally mark a class final in Java, but watch out with Kotlin. Kotlin classes are final by default, so you’ll hit the same error unless you’ve added the kotlin-spring (allopen) plugin.

The fix is either to remove final or to extract an interface and switch to a JDK Dynamic Proxy.

Pattern 4: Instances Created with new, or static Methods

The proxy is inserted when the DI container creates the Bean. An object you instantiate yourself with new is a plain class, so none of the annotations take effect.

// NG: プロキシではない素のUserService
UserService service = new UserService(userRepository);
service.saveWithTx(user); // ロールバックされない

I often see people do this in test code and then wonder why “the transaction isn’t working.” static methods don’t go through an instance either, so they are likewise out of scope.

Always receive Beans via @Autowired or constructor injection. The basics of Bean registration are summarized in the @Component article.

Pattern 5: Forgetting @EnableXxx or the Required Dependencies

Let’s sort out what Spring Boot does automatically versus what you have to write yourself.

AnnotationCondition for automatic activationWhat you must write yourself
@TransactionalAutomatic if a TransactionManager exists (auto-configured when the JPA/JDBC starter is present)Nothing. Won’t work without a DataSource
@CacheableNot enabled automatically@EnableCaching is required
@AsyncNot enabled automatically@EnableAsync is required
@RetryableNot enabled automaticallyspring-retry + spring-boot-starter-aop + @EnableRetry

If you forget @EnableCaching or @EnableAsync, there is no error and no warning. @Cacheable is simply passed through, and @Async just runs synchronously.

Once you add @EnableCaching, Spring Boot auto-configures a ConcurrentMapCacheManager even if no CacheManager is defined. For switching to Caffeine or configuring TTLs, see the caching article; for Executor configuration, see the async processing article.

Verify with Facts Whether It’s Actually Working

Fixing things based on guesswork means doing the work twice, so verify with facts whether the call goes through the proxy. There are four approaches.

@Component
@RequiredArgsConstructor
public class ProxyCheckRunner implements CommandLineRunner {

    private final UserService userService;

    @Override
    public void run(String... args) {
        // (a) プロキシかどうか
        log.info("isAopProxy={}, isCglibProxy={}, class={}",
                AopUtils.isAopProxy(userService),
                AopUtils.isCglibProxy(userService),
                userService.getClass().getName());
    }
}

If it’s a proxy, the class name will contain $$SpringCGLIB$$, like class=com.example.UserService$$SpringCGLIB$$0. You can also tell just by inspecting the variable in a debugger.

Inside the method, the following two checks are handy.

@Transactional
public void saveWithTx(User user) {
    // (c) トランザクションが実際に張られているか
    log.info("txActive={}", TransactionSynchronizationManager.isActualTransactionActive());
    userRepository.save(user);
}

@Async
public void sendMail(User user) {
    // (d) Executorのスレッド(task-1など)で動いているか
    log.info("thread={}", Thread.currentThread().getName());
}

And then there’s (b), the TRACE logs.

logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.cache=TRACE

If the call goes through the proxy, you’ll see lines like Getting transaction for [com.example.UserService.saveWithTx] or No cache entry for key '...' in cache(s) [users]. If nothing appears, the call never reached the interceptor. For how to configure log levels, see the logging article.

Comparing the Workarounds

Once you’ve confirmed self-invocation is the cause, there are five ways to fix it.

WorkaroundReadabilityTestabilityCircular reference riskSetup cost
Extract to a separate BeanNoneNone
Self-injection (@Lazy / ObjectProvider)YesNone
APIs such as TransactionTemplateNoneNone
AopContext.currentProxy()×NoneexposeProxy=true
AspectJ modeNoneHigh

The first choice is extracting to a separate Bean. AspectJ mode (mode = AdviceMode.ASPECTJ) weaves bytecode without relying on proxies, so it does solve self-invocation, but the build configuration for compile-time or load-time weaving is heavy, and we won’t adopt it in this article. AopContext.currentProxy() requires @EnableAspectJAutoProxy(exposeProxy = true) and obscures the intent of the code, so consider it a last resort.

Move the method that owns the transaction boundary into a separate class so that it’s called from outside.

@Service
@RequiredArgsConstructor
public class UserRegistrationTx {

    private final UserRepository userRepository;

    @Transactional
    public void save(User user) {
        userRepository.save(user);
        throw new IllegalStateException("わざと失敗");
    }
}

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRegistrationTx registrationTx;

    public void register(User user) {
        registrationTx.save(user); // 別Beanのプロキシ経由なのでロールバックされる
    }
}

“What happens inside the transaction” and “what happens before and after it” are now separated at the class level, which clarifies responsibilities, and in unit tests you can swap UserRegistrationTx for a mock.

Workaround 2: Self-Injection (@Lazy / ObjectProvider)

If you have a reason you can’t split the class, inject a proxied reference to the Bean itself and call through that.

@Service
public class UserService {

    private final UserRepository userRepository;

    // プロキシ経由で自分を呼ぶための自己注入(self-invocation回避)
    @Autowired
    @Lazy
    private UserService self;

    public void register(User user) {
        self.saveWithTx(user); // プロキシを通る
    }

    @Transactional
    public void saveWithTx(User user) { /* ... */ }
}

Since you’re injecting the Bean into itself, this creates a circular reference. Circular references are prohibited by default since Spring Boot 2.6, so without @Lazy the application fails at startup with “The dependencies of some of the beans in the application context form a cycle.” Avoid receiving the self-reference via constructor injection.

Using ObjectProvider achieves the same effect.

private final ObjectProvider<UserService> selfProvider;

public void register(User user) {
    selfProvider.getObject().saveWithTx(user);
}

Either way, “why is this Bean injecting itself?” won’t be obvious to readers, so leave a comment explaining the intent. The background on Bean creation order and circular references is covered in the Bean scope article.

Workaround 3: Direct Control with TransactionTemplate / CacheManager

If you define the boundaries via APIs instead of annotations, the proxy problem disappears entirely.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final TransactionTemplate transactionTemplate;
    private final CacheManager cacheManager;

    public void register(User user) {
        transactionTemplate.executeWithoutResult(status -> {
            userRepository.save(user);
            throw new IllegalStateException("ロールバックされる");
        });
    }

    public List<User> findAll() {
        Cache cache = cacheManager.getCache("users");
        List<User> cached = cache.get("all", List.class);
        if (cached != null) {
            return cached;
        }
        List<User> users = userRepository.findAll();
        cache.put("all", users);
        return users;
    }
}

@Async can likewise be replaced by injecting a TaskExecutor and using CompletableFuture.supplyAsync(supplier, executor). The advantage is that the boundaries are visible in the code; the disadvantage is more verbosity.

There are two kinds of proxies.

  • JDK Dynamic Proxy is interface-based. It can only be injected as an interface type
  • CGLIB generates a subclass of the class. It can be injected as a concrete class type as well

Since Spring Boot 2.0, spring.aop.proxy-target-class=true is the default, so CGLIB is used even when an interface exists. If you want to set it explicitly:

# デフォルトはtrue(CGLIB)。falseにするとインターフェースがあるBeanはJDKプロキシになる
spring.aop.proxy-target-class=true

In a project where this setting is false, trying to inject by concrete class type produces the following error.

BeanNotOfRequiredTypeException: Bean named 'userService' is expected to be of type
'com.example.UserService' but was actually of type 'jdk.proxy2.$Proxy87'

A type name like jdk.proxy...$Proxy is the sign that a JDK Dynamic Proxy is in use. Fix it by injecting as the interface type or by switching back to proxy-target-class=true. You can check with AopUtils.isJdkDynamicProxy(bean).

Similar Symptoms with Different Causes

If you’ve confirmed everything above and the call does go through the proxy but still doesn’t work, the cause lies elsewhere.

  • No rollback when a checked exception is thrown is by design. You need to specify rollbackFor; this is explained along with propagation levels and readOnly behavior in the transaction management article
  • If caching is happening but the DB is still hit every time, suspect the key or condition / unless settings. The caching article covers this
  • If execution is asynchronous but you’re still kept waiting, the Executor’s thread pool may be exhausted. Check the async processing article

Summary

When you boil it down, there are only three conditions for an annotation to take effect.

  • It’s called from a different Bean (an external call via the proxy)
  • The method is public and not final
  • The Bean is managed by the DI container

Add @EnableXxx and the required dependencies to that list, and all five patterns covered here are accounted for. When in doubt, confirm the facts with AopUtils.isAopProxy() and TRACE logs before fixing anything. The first-choice fix is extracting to a separate Bean; when you truly can’t split the class, use @Lazy self-injection.

“I added the annotation but it doesn’t work” is a rite of passage for anyone working with Spring. Once you understand the mechanism, you should be able to pinpoint the cause in minutes next time.


Translation complete. Markdown structure, code blocks (including Japanese comments and string literals inside them), and all /ja/ internal links were left unchanged per the rules; only prose, headings, and table text were translated.