Why does @Transactional fail on final methods, and how does this bite Kotlin developers specifically?
answer
- CGLIB = runtime subclass + override
- final can't be overridden -> no advice
- Kotlin final-by-default landmine
- kotlin-spring / all-open opens Spring beans
- Silent, same as private
basics
~20 sCGLIB proxies work by creating a subclass that overrides your methods. A final method can't be overridden, so the transaction advice never attaches. In Kotlin, classes and methods are final by default, so this happens unless you open them.
solid answer
~40 sThe default proxy strategy, CGLIB, subclasses your bean and overrides each method to insert transaction logic. Java's `final` keyword forbids overriding, so CGLIB cannot wrap a final method and @Transactional is silently skipped — same silent failure as private methods. Kotlin makes this a landmine because Kotlin classes and members are `final` by default; you must explicitly mark them `open`. The idiomatic fix is the **kotlin-spring** Gradle/Maven plugin (the all-open plugin preconfigured for Spring), which automatically opens classes annotated with `@Component`, `@Service`, `@Transactional`, etc. Without that plugin, every @Transactional Kotlin bean would silently do nothing. That's why the plugin is included in Spring Initializr's Kotlin projects by default.
code
kotlin · 16 lines// build.gradle.kts
// plugins { id("org.jetbrains.kotlin.plugin.spring") version "..." }
// This 'all-open' plugin auto-opens classes/methods annotated with
// @Component/@Service/@Transactional, so CGLIB can subclass them.
@Service
class BillingService(private val repo: InvoiceRepository) {
// Without kotlin-spring this compiles to a FINAL method ->
// CGLIB can't override it -> @Transactional silently ignored.
// With the plugin it is implicitly 'open' and the proxy works.
@Transactional
fun charge(id: Long) {
repo.markPaid(id)
}
}go deeper
Enough to know final can't be proxied and Kotlin is final by default.
Should explain CGLIB subclassing and name the kotlin-spring/all-open plugin as the fix.
Should note @Bean-registered classes aren't auto-opened and contrast with AspectJ.
Should weigh proxy-vs-weaving tradeoffs and the org-wide policy of relying on the all-open plugin.
### The mechanism Spring's default AOP proxy uses **CGLIB**, which at runtime generates a **subclass** of your bean and **overrides** each advised method. The override calls the transaction interceptor and then `super.yourMethod()`. Overriding is the only seam CGLIB has. ### Why `final` breaks it Java's `final` on a method means "cannot be overridden." CGLIB therefore **cannot** generate an overriding method, so it leaves the method as-is (inherited unchanged) with **no advice**. As with private methods, this is **silent** — no error at startup, the method just runs non-transactionally. A final **class** is worse: CGLIB can't subclass it at all, and depending on configuration you'll either get a startup failure or Spring falling back to a plain instance with no proxying. ### The Kotlin trap In **Kotlin, everything is `final` by default** — classes, and every member function. To allow overriding you must write `open class` and `open fun`. This means a naive Kotlin `@Service` with a `@Transactional fun` would compile to a final method that CGLIB cannot wrap, and transactions would silently not work. The standard solution is the **`kotlin-spring` compiler plugin** (a preconfigured flavor of the Kotlin **all-open** plugin). It automatically makes classes and their members `open` when they carry Spring meta-annotations such as `@Component`, `@Configuration`, `@Service`, `@Repository`, `@Async`, or `@Transactional`. Spring Initializr adds it to Kotlin projects by default (`id("org.jetbrains.kotlin.plugin.spring")` in Gradle). With it, you write normal idiomatic Kotlin and Spring can still generate CGLIB subclasses. ### Edge cases and gotchas - The all-open plugin opens based on **annotations**; a bean that is only registered via a `@Bean` factory method (no stereotype annotation on the class) is **not** auto-opened — you may need to mark it `open` manually. - **`val` properties compiled to final getters**: annotating a Kotlin property getter path can hit the same wall. - Switching to **JDK dynamic proxies** (interface-based) sidesteps final on the *class*, but the interface method you call must exist — and JDK proxies require an interface, which many services don't have. - **AspectJ weaving** (`AdviceMode.ASPECTJ`) weaves bytecode directly and is not subject to the final/private limitation, but adds build complexity. ### When to worry Any time transactions "aren't rolling back" in a Kotlin project, check first that the `kotlin-spring` plugin is applied and the class/method are effectively `open`. In Java, check for a stray `final`.
- Your Kotlin @Transactional methods aren't rolling back. What's the first thing you check?That the kotlin-spring (all-open) compiler plugin is applied so the class and method are effectively open, letting CGLIB subclass and advise them. Without it, Kotlin's final-by-default makes the proxy a no-op.
- Does making the whole class final also break things, differently from a final method?Yes. A final method is skipped silently; a final class can't be subclassed by CGLIB at all, so Spring may fail to create the proxy (or fall back to no proxy), affecting every advised method on that bean.