Spring AOP Problem

In Spring, self-invocation refers to a method within a class calling another method of the same class. This can be problematic when using AOP (Aspect-Oriented Programming) annotations like @Transactional.

Spring AOP is proxy-based. So if you call another method directly within the same class, it bypasses the proxy, and AOP annotations won’t be applied.

For example, suppose you have method A annotated with @Transactional, and it’s called from method B in the same class. If method B calls A directly, Spring won’t manage the transaction properly because the call doesn’t go through the proxy.

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

    // Problematic code
    public void methodB() {
        // Direct call within the same class (self-invocation)
        methodA();
    }

    @Transactional
    public void methodA() {
        // Transaction might not work as expected
        System.out.println("Transaction starts in methodA");
        // Some DB work...
        System.out.println("Transaction ends in methodA");
    }
}

A Kotlinic Workaround

You can use extension functions.

Since Kotlin compiles extension functions as static methods (ClassNameKt.class), you can invoke the Spring-managed bean instead of making a self-invocation.

Decompiled Extension Function

decompiled-result

Example with Extension Function

Goal

Use caching in a method that returns a list of users, and create a convenient method that returns a Map<Long, User>.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service

data class User(val id: Long)

@Service
class UserService {
    @Cacheable("users")
    fun getUsers(): List<User> {
        return listOf(
            User(1L),
            User(2L),
            User(3L),
            User(4L),
        )
    }

    fun userMapV1(): Map<Long, User> {
        // self-invocation
        return getUsers().associateBy { it.id }
    }
}

// Extension function
fun UserService.userMapV2(): Map<Long, User> {
    return getUsers().associateBy { it.id }
}
  • getUsers()
    • Fetches a list of users and caches the result.
  • userMapV1()
    • Calls getUsers() directly inside the class (self-invocation), so the cache is bypassed.
  • userMapV2()
    • Defined as an extension function. Cache works properly because the method call goes through the Spring proxy.

Simple Test

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import com.github.benmanes.caffeine.cache.stats.CacheStats
import jurogrammer.self.user.UserService
import jurogrammer.self.user.userMapV2
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.cache.caffeine.CaffeineCache
import org.springframework.cache.caffeine.CaffeineCacheManager

@SpringBootTest
class CacheTest {
    @Autowired
    private lateinit var userService: UserService

    @Autowired
    private lateinit var cacheManager: CaffeineCacheManager

    @Test
    fun `cache should be used when getUsers is called`() {
        // given
        val rep = 5

        // when
        repeat(rep) { userService.getUsers() }

        // then
        val stats = cacheManager.getCacheStats("users")
        assertThat(stats.missCount()).isEqualTo(1)
        assertThat(stats.hitCount()).isEqualTo(4)
    }

    @Test
    fun `cache is not used when calling getUsers inside the same class`() {
        // given
        val rep = 5

        // when
        repeat(rep) { userService.userMapV1() }

        // then
        val stats = cacheManager.getCacheStats("users")
        assertThat(stats.missCount()).isEqualTo(0)
        assertThat(stats.hitCount()).isEqualTo(0)
    }

    @Test
    fun `cache should be used when calling getUsers from extension function`() {
        // given
        val rep = 5

        // when
        repeat(rep) { userService.userMapV2() }

        // then
        val stats = cacheManager.getCacheStats("users")
        assertThat(stats.missCount()).isEqualTo(1)
        assertThat(stats.hitCount()).isEqualTo(4)
    }

    private fun CaffeineCacheManager.getCacheStats(cacheName: String): CacheStats {
        val cache = this.getCache(cacheName) as? CaffeineCache
        return cache?.nativeCache?.stats() ?: throw RuntimeException("stats cannot be null")
    }
}

This Kotlinic approach solves the Spring self-invocation problem cleanly while keeping the code concise and readable.