Overview
Idiomatic, production-grade Kotlin 2.x on the JVM: null safety, data and sealed classes, coroutines with structured concurrency, Flow, Gradle Kotlin DSL builds, Ktor, Spring with Kotlin, and JUnit 5/kotest testing. Assumes the reader writes Kotlin already. The content is the part that goes wrong in production: runCatching swallowing cancellation, GlobalScope leaks, blocking calls on the wrong dispatcher, platform types from Java, final classes breaking Spring proxies.
Tooling
Gradle with the Kotlin DSL is the default build; the K2 compiler is the default since Kotlin 2.0. Use the wrapper (./gradlew) and keep versions in the catalog (gradle/libs.versions.toml).
| Task | Command |
|---|---|
| Full gate | ./gradlew check (tests plus detekt/ktlint when their plugins are applied) |
| Compile main and tests | ./gradlew testClasses |
| Unit tests | ./gradlew test |
| One test | ./gradlew test --tests 'com.acme.FooTest.bar' |
| Static analysis | ./gradlew detekt |
| Format | ./gradlew ktlintFormat (or spotlessApply with ktlint or ktfmt) |
| Dependency tree | ./gradlew dependencies --configuration runtimeClasspath |
// build.gradle.kts
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.detekt)
alias(libs.plugins.ktlint)
}
kotlin {
jvmToolchain(21)
compilerOptions {
allWarningsAsErrors = true
freeCompilerArgs.add("-Xjsr305=strict") // Java nullability annotations become Kotlin types
}
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
testImplementation(kotlin("test")) // kotlin.test on JUnit 5 once useJUnitPlatform() is set
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
}
tasks.test { useJUnitPlatform() }
- Share build logic across subprojects through convention plugins in an included
build-logicbuild (orbuildSrc), applied by id.allprojects {}/subprojects {}blocks couple projects and defeat the configuration cache. settings.gradle.ktsholdsrootProject.name,include(":app")anddependencyResolutionManagement. Catalog accessors (libs.kotlinx.coroutines.core) are generated and type-checked../gradlew testis skipped asUP-TO-DATEwhen its inputs are unchanged;--rerunforces it.- Libraries:
kotlin { explicitApi() }requires visibility modifiers and return types on every public declaration. - Annotation processing: KSP where the processor supports it; kapt is in maintenance mode and slower.
- Maven Kotlin builds use
kotlin-maven-pluginwith<extensions>true</extensions>; in mixed Java/Kotlin modules the Kotlin compilation must run beforemaven-compiler-plugin.
Null Safety and Errors
fun displayName(user: User?): String {
val name = user?.profile?.nickname ?: user?.fullName ?: return "anonymous"
return name.trim()
}
class Session(private val store: TokenStore) {
private var token: String? = null
fun header(): String {
val current = token ?: store.load().also { token = it } // a local val smart-casts; the var does not
return "Bearer $current"
}
}
- Smart casts need a stable value. They fail on
varproperties,openproperties, properties with custom getters and properties declared in another module. Copy into a localvaland branch on it. - Platform types: a value from Java (
String!) is unchecked, and Kotlin trusts whatever type you declare. State the type at the boundary (val name: String? = javaUser.getName()) and annotate Java code you own with JSpecify. !!throws a bareNullPointerException.requireNotNull(x) { "config key $key missing" }andcheckNotNullfail with a message.require(cond) { msg }throwsIllegalArgumentException,check(cond) { msg }anderror(msg)throwIllegalStateException.lateinit varis for fields a framework or test setup assigns; reading it early throwsUninitializedPropertyAccessException. It does not apply to primitive or nullable types.List<String?>(nullable elements) andList<String>?(nullable list) differ.filterNotNull(),mapNotNull {}andfirstOrNull {}keep null handling in the types.- Kotlin has no checked exceptions. Java callers see none unless the function carries
@Throws(IOException::class). - Model expected domain failures as sealed result types so callers must handle each case; throw for bugs and infrastructure faults.
runCatching {}catches everyThrowable,CancellationExceptionandOutOfMemoryErrorincluded. In coroutine code it breaks cancellation (see Anti-Patterns).use {}closes aCloseable/AutoCloseableon every path.
Data, Sealed and Value Classes
sealed interface PaymentResult {
data class Approved(val id: String, val amountCents: Long) : PaymentResult
data class Declined(val reason: String) : PaymentResult
data object Pending : PaymentResult
}
fun describe(result: PaymentResult): String = when (result) { // exhaustive: no else branch
is PaymentResult.Approved if result.amountCents == 0L -> "approved, zero amount"
is PaymentResult.Approved -> "approved ${result.id}"
is PaymentResult.Declined -> "declined: ${result.reason}"
PaymentResult.Pending -> "pending"
}
@JvmInline
value class Email(val value: String) {
init { require('@' in value) { "invalid email: $value" } }
}
data classderivesequals,hashCode,toString,copyandcomponentNfrom the primary constructor only; properties declared in the body take no part.copyis shallow and runsinitblocks.- Keep
varout of data classes used as map keys or set elements: a mutation changes the hash code and strands the entry. - A
whenover a sealed type or enum needs noelse, so a new subtype breaks compilation wherever it must be handled. Guard conditions (is X if ...) are stable since Kotlin 2.2. data object(1.9) gives singleton variants a readabletoString.Enum.entries(1.9) replacesvalues(), which allocates a new array per call.- Value classes erase to their field at runtime but box when used as a nullable type, a generic type argument or through an interface. Functions taking them get mangled JVM names, so Java callers need
@JvmNameon those functions. - Destructuring is positional:
val (id, amount) = approvedchanges meaning silently when constructor parameters are reordered.
Coroutines and Structured Concurrency
suspend fun loadDashboard(userId: UserId): Dashboard = coroutineScope {
val profile = async { profileClient.fetch(userId) }
val orders = async { orderClient.recent(userId) }
Dashboard(profile.await(), orders.await()) // a failure in either cancels the other, then rethrows
}
class ReportReader(private val io: CoroutineDispatcher = Dispatchers.IO) { // injectable for tests
suspend fun read(path: Path): String = withContext(io) {
Files.readString(path) // blocking call moved off the caller's dispatcher
}
}
- Every coroutine runs in a scope that waits for its children.
coroutineScope {}fails fast: a child failure cancels the siblings and the scope rethrows.supervisorScope {}isolates child failures, and each child handles its own errors. GlobalScope(@DelicateCoroutinesApi) escapes that structure: nothing cancels or awaits its work. Launch background work from a scope you own, such asCoroutineScope(SupervisorJob() + dispatcher)cancelled on shutdown, or the framework's scope.launchwhen no result is needed,asyncwhen one is. In a regular scope a failedasynccancels the parent at once, before anyone callsawait().- Dispatchers:
Defaultfor CPU work (sized to the cores),IOfor blocking calls (at least 64 threads),Dispatchers.IO.limitedParallelism(n)for a bounded pool per resource. Calls that already suspend (Ktor client, R2DBC) need nowithContext(Dispatchers.IO). - Cancellation is cooperative. kotlinx suspend functions check it; a CPU loop must call
ensureActive()oryield(); blocking Java calls ignore it unless wrapped inrunInterruptible {}, which turns cancellation into thread interruption. withTimeoutthrowsTimeoutCancellationException, which is aCancellationException: insidelaunchit ends the coroutine quietly, with nothing logged.withTimeoutOrNullreturnsnullinstead.runBlockingbelongs at the top ofmainand in bridges from blocking code. Inside a coroutine it blocks the dispatcher thread.- The compiler rejects a suspension point inside
synchronized {}. Usekotlinx.coroutines.sync.Mutex(not reentrant) or confine the state to one coroutine.
Flow
fun prices(symbol: String): Flow<Price> = flow {
while (true) {
emit(api.quote(symbol))
delay(1_000)
}
}
.flowOn(Dispatchers.IO) // upstream on IO; the collector keeps its context
.catch { e -> log.warn("quote stream failed", e) } // catches upstream failures only
val latest: StateFlow<Price?> = prices("ACME")
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), initialValue = null)
flow {}is cold: the block runs once per collector.shareIn/stateInshare one upstream among many collectors.flowOnchanges the context of the operators above it. CallingwithContextinsideflow {}and emitting from there throwsIllegalStateException(flow invariant violated).catchsees upstream exceptions only; a failure insidecollect {}passes it by.StateFlowis conflated and skips equal values: assigning the current value emits nothing, and a slow collector sees only the latest. Change it withstate.update { it.copy(...) }(atomic compare-and-set);state.value = state.value.copy(...)loses concurrent updates.collectLatestcancels the previous block when a new value arrives.
Ktor
fun Application.module() {
install(ContentNegotiation) { json() }
install(StatusPages) {
exception<IllegalArgumentException> { call, cause ->
call.respond(HttpStatusCode.BadRequest, mapOf("error" to (cause.message ?: "bad request")))
}
}
routing {
get("/users/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest)
val user = userService.find(id) ?: return@get call.respond(HttpStatusCode.NotFound)
call.respond(user) // User is @Serializable
}
}
}
fun main() {
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}
- Route handlers are suspend functions on the engine's threads. Wrap blocking calls (JDBC, file IO) in
withContext(Dispatchers.IO). - Create one
HttpClientper application and close it on shutdown; each instance owns an engine and a connection pool. SetexpectSuccess = trueto turn non-2xx responses intoResponseException; by default any status returns normally. - Keep routes thin: parse and validate in the handler, call a service, map domain results to status codes in one place (
StatusPages).
Spring with Kotlin
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring) // opens classes annotated @Component, @Transactional, @Async, @Cacheable, @SpringBootTest
alias(libs.plugins.kotlin.jpa) // no-arg constructors for @Entity, @Embeddable, @MappedSuperclass
alias(libs.plugins.spring.boot)
}
allOpen { // lazy-loading proxies subclass entities
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
@ConfigurationProperties("spool")
data class SpoolProperties(val directory: String, val maxFiles: Int = 100)
@Service
class SpoolService(private val repository: SpoolRepository) { // primary constructor injection
@Transactional
fun archive(id: Long) {
val entry = repository.findByIdOrNull(id) ?: throw EntryNotFoundException(id)
entry.archived = true
}
}
- Kotlin classes and methods are final. Without
plugin.spring,@Configurationclasses fail at startup and@Transactionalon a final method is skipped silently, since the proxy cannot override it. - Entities are regular classes with
varproperties. Adata classentity derivesequals/hashCode/toStringfrom mutable fields and lazy associations. jackson-module-kotlinon the classpath (Boot registers it) lets Jackson construct classes through primary constructors and honour default values.-Xjsr305=strictturns Spring's nullability annotations into Kotlin nullable and non-null types.- WebFlux controllers may be
suspendfunctions and may returnFlow. - Self-invocation, rollback rules and
open-in-viewbehave as in Java Spring (seeloom-java).
Testing
class UploaderTest {
private val client = mockk<UploadClient>()
@Test
fun retriesOnceAfterTransientFailure() = runTest {
coEvery { client.upload(any()) } throws IOException("reset") andThen Receipt("r1")
val receipt = Uploader(client, retryDelay = 5.seconds).upload(Payload("x"))
assertEquals(Receipt("r1"), receipt) // the 5 s retry delay ran on virtual time
coVerify(exactly = 2) { client.upload(any()) }
}
@Test
fun `rejects an empty payload`() {
assertFailsWith<IllegalArgumentException> { Payload("") }
}
}
kotlin("test")withuseJUnitPlatform()runskotlin.testtests on JUnit 5, and JUnit 5 features (@TempDir,@ParameterizedTest) work alongside. Backtick names read well in reports; the JVM method name then contains the spaces.runTestruns on virtual time:delayreturns at once and the test clock advances. Code that switches to a real dispatcher leaves virtual time, so inject dispatchers and passStandardTestDispatcher(testScheduler)in tests.runBlockingin a test waits in real time.- MockK:
every/coEverystub,verify/coVerifycheck.relaxed = truereturns defaults for unstubbed calls and hides a missing stub.mockkObject/mockkStaticpatch global state; callunmockkAll()in@AfterEach. - kotest: spec styles (
FunSpec,StringSpec,BehaviorSpec), matchers (shouldBe,shouldThrow<T> {}), property tests (checkAll). Specs run on the JUnit Platform throughkotest-runner-junit5; the matcher and property modules also work inside plain JUnit tests. - Flows: Turbine (
flow.test { assertEquals(first, awaitItem()); awaitComplete() }). - Ktor:
testApplication { application { module() }; assertEquals(HttpStatusCode.OK, client.get("/health").status) }runs the module in memory.
Loom Test Runner Adapter
Adapter. A Kotlin project built with Gradle uses gradle; a Maven Kotlin build uses maven. loom project detect prints the choice per package. build.gradle.kts or settings.gradle.kts make a directory kind kotlin; pom.xml, build.gradle or settings.gradle make it kind java. For both kinds the runner is gradle when a build.gradle* exists, else maven. So a Kotlin project built by Maven or by a Groovy-DSL build.gradle reports kind java with the loom-java skill, and a Java project with a build.gradle.kts reports kind kotlin; the runner is right in each case. A contract's optional runner: field (runner: maven, runner: gradle) names the adapter when detection picks the wrong one.
app kinds=kotlin runner=gradle skills=loom-kotlin
Single-test command. loom runs it with the package directory as the working directory:
# gradle, when gradlew exists in the package directory
./gradlew test --tests '{test}'
# gradle, without a wrapper there
gradle test --tests '{test}'
# maven
mvn -q test -Dtest='{test}'
The commands are fixed. Subprojects of a multi-project build hold no gradlew, so loom runs gradle from PATH there, and the maven adapter always runs mvn from PATH; install a matching tool on the host when either applies. Android and Kotlin Multiplatform modules run unit tests through variant or target tasks (testDebugUnitTest, jvmTest), while the command targets the plain JVM test task: keep contract tests in a JVM module.
The test field.
| Adapter | test value |
Example |
|---|---|---|
gradle |
package.Class.method |
com.acme.upload.UploaderTest.retriesOnceAfterTransientFailure |
maven |
Class#method |
UploaderTest#retriesOnceAfterTransientFailure |
Class is the class holding the test function; top-level functions are never tests. A backtick-named test keeps its spaces in the method part (com.acme.upload.UploaderTest.rejects an empty payload), and the command quotes it, but the value must match character for character.
No-match behaviour. D5 marks gradle and maven "documented": no fixture was captured, and loom's parsers for them were written from the runners' documented output. Gradle fails the task with No tests found for given includes; Surefire fails with No tests matching pattern ... were executed. Both exit non-zero, like a failing test, so the exit code cannot tell a misspelled name from a red test. loom reads the runner's summary instead: a name that selects nothing classifies as NotSelected, which fails the freeze ("the runner did not select the test") and fails the completion check ("contract test not selected").
Writing contract tests. Test sources live under src/test/kotlin/ in the directory of their package, for example src/test/kotlin/com/acme/upload/UploaderTest.kt. For the adapter to select exactly one test:
- one contract per
@Testfunction in a top-level class;@Nestedinner classes change the class name the filter sees (Outer$Inner); - a camelCase function name, unique in its class;
- kotest specs are selected by spec class, and their tests are strings rather than methods, so write each contract as a
@Testfunction in a plain class. It runs next to the kotest specs on the JUnit Platform oncekotlin("test")orjunit-jupiteris on the test classpath.
contracts:
- id: retries-once-after-transient-failure
file: src/test/kotlin/com/acme/upload/UploaderTest.kt
test: com.acme.upload.UploaderTest.retriesOnceAfterTransientFailure
scenario: the upload client throws IOException("reset") once, then returns a receipt
rejects: an uploader that returns the first IOException to the caller without retrying
In a Maven package the same contract reads test: UploaderTest#retriesOnceAfterTransientFailure.
Build failures. A contract test that does not compile yet counts as red at freeze time: loom classifies the compiler failure as BuildFailed. compileTestKotlin compiles the whole test source set, so one uncompilable contract file makes every test in that module BuildFailed until the implementation exists. Freeze and completion run each command under a 300-second limit: run the single-test command once before freezing so the Gradle daemon start and dependency download happen outside the timed run. A contract that needs a new test dependency edits a build file, which must match one of the stage's harness globs.
Anti-Patterns
// !! chains: the NPE names no value
val city = user!!.address!!.city!! // BAD
val city = user?.address?.city ?: error("user $id has no city") // GOOD
// runCatching in a coroutine swallows cancellation
val result = runCatching { client.fetch(id) } // BAD
val result = try { // GOOD
Result.success(client.fetch(id))
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
Result.failure(e)
}
// Fire-and-forget outside structured concurrency
GlobalScope.launch { audit(event) } // BAD: nothing cancels or awaits it
appScope.launch { audit(event) } // GOOD: scope cancelled on shutdown
// Blocking call on the caller's dispatcher
suspend fun load() = Files.readString(path) // BAD
suspend fun load() = withContext(Dispatchers.IO) { Files.readString(path) } // GOOD
Quick swaps: if (x != null) x.foo() else null → x?.foo(); filter { }.first() → first { }; map { }.filterNotNull() → mapNotNull { }; for (i in 0 until list.size) → for (item in list) or withIndex(); lateinit var for a value known at construction → a constructor val; Java-style getX()/setX() → properties; mutable state in a companion object → an injected dependency; MutableList in a public signature → a private _items backing property exposed as List; let/apply/also nested three deep → named locals.
Expert Practices
Collections and Sequences
ListandMapare read-only interfaces, which is weaker than immutable: the object may be aMutableListthat its owner still changes. Copy withtoList()at trust boundaries.- Collection operators are eager and allocate a list per step. For long chains over large inputs use
asSequence()(lazy, one pass); for small collections the eager form is faster. associateBykeeps the last element per key without warning;groupBykeeps them all.single()throws unless exactly one element matches.
Scope Functions
let(transform a nullable,it),apply(configure the receiver, returns it),also(side effect, returns the value),run(compute with a receiver). Nesting them makesthisanditambiguous; past one level, name a local.
Java Interop
- Default arguments are invisible to Java callers without
@JvmOverloads.@JvmStaticexposes companion members as static methods,@JvmFieldexposes a field without accessors,@JvmNameresolves signature clashes,@Throwsdeclares checked exceptions for Java. - An
objectisFoo.INSTANCEfrom Java.fun interfacegives Kotlin interfaces SAM conversion.
Security and Serialization
- kotlinx.serialization: annotate classes
@Serializable. The defaultJsonrejects unknown keys;Json { ignoreUnknownKeys = true }tolerates evolving input. Polymorphic decoding works through registered sealed hierarchies, so input never names a class to instantiate. - String templates make injection easy to write:
"select * from users where name = '$name'"andProcessBuilder("sh", "-c", "tar $file")are both injectable. Bind parameters (Exposed's DSL, JDBCPreparedStatement) and pass argument lists. - The Java rules hold unchanged: no
ObjectInputStreamon untrusted bytes, XML parsers with DTDs disabled,SecureRandomfor tokens, normalized paths checked against their base.
Performance
inlinefunctions with lambda parameters avoid allocating the lambda;reifiedtype parameters requireinline. Inlining a large function copies its body into every call site.lazy {}synchronizes by default;lazy(LazyThreadSafetyMode.NONE)skips the lock for single-threaded use.- Coroutines are cheap, but one
launchper item over a million items still allocates a million coroutines. Bound the fan-out with aSemaphore,limitedParallelism, orflatMapMerge(concurrency = n). - Top-level
valinitializers run when the file's facade class (FooKt) loads. Useconst valfor compile-time constants andlazyfor expensive values.
Verification Checklists
Before marking Kotlin work done:
-
./gradlew checkpasses withallWarningsAsErrors; detekt and ktlint (or ktfmt) clean - New behaviour has tests; the test report shows a non-zero count for the new classes
- No
!!on values that can be absent; Java platform types given explicit Kotlin types at the boundary - Public API exposes read-only collection types; no
varin data classes used as keys -
whenover sealed types and enums has noelsebranch - No string templates in SQL, shell commands or log patterns built from untrusted input
Coroutines and framework review:
- No
GlobalScope; every launched coroutine has an owning scope that is cancelled -
CancellationExceptionis rethrown; norunCatchingaround suspend calls - Blocking calls run under
withContext(Dispatchers.IO)or an injected dispatcher; norunBlockinginside coroutines -
StateFlowchanges useupdate {}; flows switch context withflowOn - Spring:
plugin.springandplugin.jpaapplied; entities are plain classes;-Xjsr305=strictset - Coroutine tests use
runTestwith injected test dispatchers; mocks of global state are undone after each test