skip to content

Why does @Transactional fail on final methods, and how does this bite Kotlin developers specifically?

level: middleimportance: should knowfreq 55%

answer

  1. CGLIB = runtime subclass + override
  2. final can't be overridden -> no advice
  3. Kotlin final-by-default landmine
  4. kotlin-spring / all-open opens Spring beans
  5. Silent, same as private

basics

~20 s

CGLIB 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 s

The 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
kotlin
// 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

for a junior

Enough to know final can't be proxied and Kotlin is final by default.

for a middle

Should explain CGLIB subclassing and name the kotlin-spring/all-open plugin as the fix.

for a senior

Should note @Bean-registered classes aren't auto-opened and contrast with AspectJ.

for a principal

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.

context