I just learnt the hard way (like many others) that Spring proxies can’t proxy calls to methods in the same instance. Spring proxies are wrappers around the real instance, and it is this wrapper that you actually get when a dependency is wired in. Therefore calls made to wrapper are proxied, but once you’re inside the wrapper and into the real instance any internal calls will not be.

This can easily trip you up, for example:

class ThingService {
    ...

    fun updateThings() {
        things.forEach { updateThing(it) }
    }

    @Transactional
    fun updateThing() {
        ...
    }
}

So if you wire in a ThingService, then calling updateThing (singular) will use a transaction, but calling updateThings (plural) will not use transactions at all.

So what to do? - the answer could be AspectJ transactions. AspectJ uses a process they call weaving to incorporate the code for an aspect (like @Transactional) into the method bytecode, so it will be used whether for an internal call or even for private methods. AspectJ can weave in the aspects like this at compile time, or load time - the results are the same either way, but load-time weaving requires a classloader that knows how to weave.

Using AspectJ transactions in Spring boot

With either type of weaving, it is necessary to tell Spring that you are going to use AspectJ transactions by adding mode = AdviceMode.ASPECTJ to @EnableTransactionManagement, i.e.:

@SpringBootApplication
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
class Application

Compile-time weaving

Doing the weaving at compile-time ought to bring consistent results, and requires no further modifications to your application. When using gradle it can be as simple as the following:

  1. Add io.freefair.aspectj.post-compile-weaving plugin to build.gradle.kts i.e.:
plugins {
    ...
    id("io.freefair.aspectj.post-compile-weaving") version "6.4.1"
}
  1. Add AspectJ dependencies to build.gradle.kts:
dependencies {
    ...
    implementation("org.aspectj:aspectjrt:1.9.9.1")
    implementation("org.aspectj:aspectjweaver:1.9.9.1")
}
  1. Name the module with aspects that you wish to weave (custom aspects in the host gradle module are automatically processed):
dependencies {
    ...
    aspect("org.springframework:spring-aspects")
}

One drawback of compile-time weaving with Kotlin is that Kotlin class files are harder to decompile than Java ones. This can make it hard (compared to Java) to verify that you have correctly woven in the aspects. IntelliJ in particular shows the method body as { /* compiled code */ }, I had some luck with other decompilers - enough to confirm that the weaving had worked at least.

Load-time weaving

If you can’t modify your build chain, load-time weaving may be a better option. However, it requires a custom classloader that can weave in the aspects. Some environments may provide a classloader that can already do this - for example Apache Tomcat according to the Spring docs. However, I couldn’t get this to work, at least with the embedded Tomcat in a Spring Boot application. I took the easy route to a proof of concept by using a java agent:

  1. Add AspectJ dependencies to build.gradle.kts:
dependencies {
    ...
    implementation("org.aspectj:aspectjrt:1.9.9.1")
    implementation("org.springframework.boot:spring-boot-starter-aop")
    implementation("org.springframework:spring-aspects")
    implementation("org.springframework:spring-instrument")
}
  1. Add META-INF/aop.xml to configure weaving, something like (the verbose output option is particularly useful):
<aspectj>
    <weaver options="-verbose">
        <include within="org.springframework.transaction.*"/>
    </weaver>
</aspectj>
  1. Configure the javaagent for weaving classes, e.g. -javaagent:<PATH_TO_GRADLE_CACHES>/modules-2/files-2.1/org.springframework/spring-instrument/5.3.18/8c8240406471d2d80482c32ecbdd918405350595/spring-instrument-5.3.18.jar. This may need to be done in different ways for different use cases, for example tests can be configured as below:
tasks.test {
    useJUnitPlatform()
    jvmArgs = listOf("-javaagent:<PATH_TO_GRADLE_CACHES>/modules-2/files-2.1/org.springframework/spring-instrument/5.3.18/8c8240406471d2d80482c32ecbdd918405350595/spring-instrument-5.3.18.jar")
}

I find this approach less pleasant, as it’s necessary to supply the javaagent argument in multiple places, as well as having to somehow scrape the value of it. As well as in tests, the actual running application needs the argument (or to get the Tomcat classloader doing the job), and similarly for any custom run configuration.

Another issue is that if @Transactional weaving just didn’t occur in the real deployment, you might not even notice until your database was full of half-completed transactions. I suspect most live deployments won’t have monitoring set up to perform an action that will fail and verify that the transaction is rolled back.

Conclusion

AspectJ-style @Transactional did exactly what it was supposed to do, and makes it a lot harder to accidentally have no transactions, as well as adding additional flexibility (e.g. annotating private methods).

Unfortunately I hit one strange issue in testing that - due to an abundance of caution - stops me from using it for the time being.