Kotlin has no checked exceptions, and your codebase is Kotlin. Does the 'checked exception commits by default' pitfall still apply, and how should you set a team-wide policy?
answer
- Kotlin drops 'checked' keyword, JVM hierarchy unchanged
- extends Exception -> still commits, even in Kotlin
- most Kotlin exceptions extend RuntimeException -> luck
- meta-annotation rollbackFor=Exception::class
- enforce with ArchUnit, don't hope
basics
~20 sYes it can still apply. Spring tests the runtime type, not the throws keyword. A Kotlin exception extending java.lang.Exception (not RuntimeException) still commits by default. In practice most Kotlin exceptions extend RuntimeException, so they roll back — but relying on that is fragile; set an explicit policy.
solid answer
~40 sKotlin drops checked exceptions at the language level, but the JVM class hierarchy is unchanged and Spring's rule is a runtime instanceof RuntimeException||Error test. So a Kotlin class 'MyException : Exception()' (extends java.lang.Exception, not RuntimeException) still will NOT roll back by default, even though Kotlin never forces a throws declaration. The nuance: because Kotlin idioms favour RuntimeException-derived exceptions, most code accidentally rolls back — but that's luck, not design. For a team-wide policy I'd make it explicit and uniform rather than per-annotation: either standardise on a meta-annotation like @BusinessTransaction that sets rollbackFor = Exception::class, or ensure all domain exceptions extend a common RuntimeException base. Document the rule, add an ArchUnit/detekt check, and avoid mixing conventions so rollback behaviour is predictable regardless of exception type.
code
kotlin · 28 lines// The pitfall survives in Kotlin — it's about the type, not the keyword.
class DomainException(msg: String) : Exception(msg) // NOT a RuntimeException
@Service
class PaymentService(private val repo: PaymentRepository) {
@Transactional // default rules
fun chargeBroken(p: Payment) {
repo.save(p)
throw DomainException("declined") // extends Exception -> tx COMMITS!
}
}
// Team-wide policy: one meta-annotation everyone uses.
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
@Transactional(rollbackFor = [Exception::class])
annotation class BusinessTransactional
@Service
class PaymentServiceFixed(private val repo: PaymentRepository) {
@BusinessTransactional // any exception now rolls back
fun charge(p: Payment) {
repo.save(p)
throw DomainException("declined") // rolled back
}
}go deeper
Unlikely to face the Kotlin nuance.
Should realise 'no checked exceptions' doesn't remove the type-hierarchy rule.
Can explain the JVM-type basis and recommend a meta-annotation or base class.
Weighs uniformity vs nuance, interop surfaces, propagation interplay, and enforcement via ArchUnit/detekt.
## Kotlin and checked exceptions Kotlin has **no checked exceptions** — the compiler never forces a `throws`/try-catch. Newcomers therefore assume 'the checked-exception rollback pitfall doesn't exist in Kotlin.' That's a half-truth. What actually matters is the **runtime class hierarchy**, because `DefaultTransactionAttribute.rollbackOn` does `ex instanceof RuntimeException || ex instanceof Error`. The 'checked' concept is purely a Java *compiler* notion; the JVM only sees the type. So: ```kotlin class DomainException(msg: String) : Exception(msg) // extends java.lang.Exception class DomainRtException(msg: String) : RuntimeException(msg) ``` - Throwing `DomainException` from a default `@Transactional` Kotlin method -> **commits** (it's not a RuntimeException). The pitfall is alive. - Throwing `DomainRtException` -> **rolls back**. Because idiomatic Kotlin (and Kotlin's own stdlib exceptions like `IllegalStateException`, `IllegalArgumentException`) extend `RuntimeException`, most Kotlin code rolls back 'for free'. But if anyone extends `Exception` (or throws a Java checked exception from an interop call — `IOException`, `SQLException`), the commit-on-throw surprise returns. ## Designing a team-wide policy The goal is **predictable rollback regardless of exception type**, not per-method vigilance. Options, roughly in order of preference: ### A. Blanket rollback via a meta-annotation Define one annotation the whole codebase uses: ```kotlin @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) @Transactional(rollbackFor = [Exception::class]) annotation class BusinessTransactional ``` Every service uses `@BusinessTransactional`. Now ANY exception rolls back; nobody has to remember `rollbackFor`. Downside: you lose the (rarely used) ability to commit-on-checked as a deliberate signal — usually a good trade. ### B. Common exception base Make all domain exceptions extend a shared `RuntimeException` base (`abstract class DomainException : RuntimeException`). Then default rollback semantics 'just work'. Enforce with an ArchUnit rule: 'exceptions in domain packages must extend DomainException'. ### C. Enforcement, not hope Whichever you choose, add a guardrail: - ArchUnit test asserting service exceptions extend the RuntimeException base, or - A detekt/custom rule flagging `@Transactional` without `rollbackFor` where the method can throw checked types, or - Code-review checklist item. ## Trade-offs to articulate at principal level - **Uniformity vs nuance**: a blanket `rollbackFor = Exception.class` erases the checked-vs-unchecked distinction. If your domain genuinely wants 'business exception commits partial work' semantics (rare), a blanket rule removes it. Most teams find the default more surprising than useful and prefer uniformity. - **Interop surfaces**: JDBC/JMS/file IO throw Java checked exceptions. Even in Kotlin these hit the pitfall. Wrapping them in `DataAccessException`-style unchecked wrappers (as Spring's `JdbcTemplate` already does) both rolls back and decouples callers from provider APIs. - **Nested transactions**: policy interacts with propagation. An inner `REQUIRES_NEW` that commits-on-checked while the outer rolls back can leave inconsistent state; a uniform rollback policy reduces such surprises. - **Observability**: whichever policy, log rollbacks so a committed-on-throw doesn't silently corrupt state. ## Summary The pitfall is about the JVM type hierarchy, which Kotlin does not change. Don't rely on 'Kotlin has no checked exceptions.' Choose an explicit, enforced policy — a meta-annotation with `rollbackFor = Exception::class` or a mandatory RuntimeException base — so rollback is predictable across Java interop and future code.
- If all your Kotlin domain exceptions already extend RuntimeException, do you still need rollbackFor?Not strictly for those — they roll back under the default. But Java-interop calls (JDBC, file IO) still throw checked exceptions, and a future dev may extend Exception. An explicit policy protects against both, so it's still worth standardising.
- What's a downside of putting rollbackFor = Exception.class everywhere?It erases the deliberate 'checked exception = commit business outcome' semantics. Rarely wanted, but if your design relied on it you'd lose it. In practice most teams accept this because uniform rollback is more predictable.
saying these in an interview costs you the question
- Asserting the pitfall cannot happen in Kotlin because Kotlin has no checked exceptions
- Assuming every Kotlin exception is unchecked at the JVM level (only true if it extends RuntimeException)
- Relying on ad-hoc per-method rollbackFor instead of an enforced team-wide convention