Binding style rules for server-side Kotlin on the JVM, target Kotlin 2.3 and JDK 21+. This guide extends the generic Kotlin guide. Where the JVM guide adds a stricter rule, it wins for JVM code; the generic guide remains the canonical Kotlin baseline.
This guide exists because the JVM has its own contract. Reflection, bytecode-level ABI, framework conventions (Spring/JPA/Ktor), the Java standard library, Loom, and the JVM's GC all impose rules that the generic Kotlin guide can't address.
Same as the generic guide:
- Clarity
- Simplicity
- Concision
- Maintainability
- Consistency
Plus one JVM-specific tiebreaker: Java callers don't read Kotlin documentation. If a public Kotlin symbol is consumed from Java or reflectively (Spring, Jackson, JPA), the Java-side ergonomics drive the design.
| # | Document | Scope |
|---|---|---|
| 01 | Java Interop | @Jvm* annotations, platform types, null annotations, file-level annotations, mangled internal names |
| 02 | JVM Concurrency | Virtual threads (Loom), coroutines vs Loom, Reactor at boundaries, CompletableFuture bridges, ThreadLocal/MDC + coroutines |
| 03 | JVM Frameworks (Spring/Ktor) | Constructor injection, no field injection, @Configuration over @Component, @ConfigurationProperties, transactional boundaries |
| 04 | Persistence | JPA + kotlin-jpa, entity-vs-data-class, LAZY defaults, equality by business key, Exposed/jOOQ when SQL-first is right |
| 05 | Serialization on JVM | kotlinx.serialization vs Jackson, kotlin-module, time types, never expose entities, null-vs-absent JSON semantics |
| 06 | Logging on JVM | SLF4J + kotlin-logging, lazy messages, MDC for correlation (with coroutines), PII masking, no println |
| 07 | JVM Performance | JFR, async-profiler, escape analysis, value-class boxing, GC awareness, GraalVM caveats |
| 08 | Build & Distribution | Gradle Kotlin DSL, version catalogs, toolchains, binary-compatibility-validator, shadow jars, GraalVM native-image |
These add to the 15 rules in the generic guide root. When the rules below conflict with the generic ones (they shouldn't, but in edge cases like lateinit for Spring DI), the JVM rule wins for JVM code.
Step-by-step reasoning:
- Every
publicKotlin symbol becomes a Java-callable, reflection-targetable surface in the compiled bytecode. - Renaming, reordering parameters, or changing nullability is a binary-incompatible change. Java callers, Spring's reflection, Jackson's
kotlin-module, JPA's bytecode-weaving — all see the bytecode, not your source. - Reach for
internalaggressively (generic guide §10.1). For modules that publish to other modules or external consumers, enforce stability withbinary-compatibility-validatorin CI. - New public symbols are easier to add than to remove. Don't ship symbols you wouldn't bet your refactor on.
Step-by-step reasoning:
- Each interop annotation is a stylistic choice with real cost. Sprinkle them on every symbol and you bloat bytecode, lose abstractions (
@JvmFieldstrips the property), and signal that you didn't think about your callers. Omit them where they're needed and Java callers either can't call you or call you awkwardly. - Java callers can't pass named arguments.
fun f(a: Int = 1, b: Int = 2)is callable from Java only asf(a, b)unless you add@JvmOverloads, which generates the overloads (f(),f(a),f(a, b)). - Companion-object methods need
@JvmStaticto be callable asType.method(...)from Java instead ofType.Companion.method(...). - Top-level functions live in
<FileName>Ktfrom Java's view. Use@file:JvmName("Util")to name the class. - The annotations are not decoration — they're how Java sees the contract. Missing them in Java-consumed code is a bug; adding them in pure-Kotlin code is noise. First question: does this symbol have a Java caller (including framework reflection that emulates one — Spring, Jackson, JPA)? If yes, annotate deliberately. If no, leave them off.
- See chapter 01 for the per-annotation guidance.
Step-by-step reasoning:
- A value coming from Java without nullability metadata has platform type
String!— neitherStringnorString?. Kotlin lets you use it either way and won't catch the NPE. - At the Kotlin-Java boundary, every value gets either (a) an explicit type ascription (
val name: String = javaThing.getName(), which throws on null), (b) a nullable-aware coercion (val name = javaThing.getName() ?: error("...")or?.let { ... }), or (c) an@Nullable/@NotNullannotation on the Java side that disambiguates. - Don't let platform types leak past the adapter layer. Internal code should see only proper Kotlin types.
- Lint: detekt's
PlatformTyperule warns on functions whose return type is platform.
Step-by-step reasoning:
- Spring proxies require
openclasses/methods. JPA entities require a no-arg constructor. Hibernate requiresopenfor lazy loading. - The wrong fix: hand-writing
open class ...andconstructor()constructors throughout. It rots, gets forgotten on new code, and lies about the inheritance contract. - The right fix: compiler plugins —
kotlin-spring(opens@Component/@Service/@Configuration/etc.),kotlin-jpa(no-arg for@Entity/@Embeddable/@MappedSuperclass),kotlin-noarg/kotlin-allopen(configurable variants). - The plugins are the contract. They run at compile time and produce exactly what the framework expects. Configure them once; don't write hand-rolled workarounds.
Step-by-step reasoning:
- JDK 21+ has virtual threads (Project Loom). The Kotlin coroutine ecosystem still exists and isn't going away.
- Decision:
- Coroutines — cancellation-aware, structured concurrency,
Flow, integrates withkotlinx-coroutines-*. Use when your code issuspend-shaped or you need cooperative cancellation. - Virtual threads — for blocking I/O without cancellation semantics. Cheap to spawn, you write code as if it were synchronous. Use via
Executors.newVirtualThreadPerTaskExecutor()or as a coroutine dispatcher (asCoroutineDispatcher()). - Reactor — only at boundaries with frameworks that demand it (Spring WebFlux, certain reactive libraries). Bridge to coroutines with
kotlinx-coroutines-reactor.
- Coroutines — cancellation-aware, structured concurrency,
- Never mix without naming the seam. A method that returns
Mono<T>to a caller that wantsFlow<T>is a boundary — convert at exactly one point. - See chapter 02 for the full decision tree.
Step-by-step reasoning:
- Threads, classloaders, connection pools, native file handles, large off-heap buffers — these outlive
use { }blocks. The GC doesn't help. - Bind their lifecycle to a named lifecycle: a Spring
DisposableBean, a Ktormonitor.subscribe(ApplicationStopping), a coroutineSupervisorJobwith explicitcancel()on shutdown. - Test it: kill -SIGTERM the running process and verify clean shutdown logs.
- Leaks here become OOMs and file-descriptor exhaustion in production, not GC pressure.
Zero technical debt holds here as everywhere: what ships meets the design goals. Perfection over technical debt — debt never gets paid. A JVM service runs unattended for months; the platform-type leak or hand-rolled open shortcut taken today is the NPE or framework break someone else debugs in production.
- Kotlin documentation: Java interop, Calling Kotlin from Java — canonical interop reference.
- Spring Boot reference, Kotlin section.
- JEP 444 (Virtual Threads) and
kotlinx-coroutinesdocs onDispatchers.IOvs virtual threads. - Kotlin binary-compatibility-validator.