Language · Kotlin 2.4 · Coroutines 1.11 · JDK 25 LTS
Kotlin
Null-safety, data classes, coroutines and sealed types.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou write modern, idiomatic Kotlin for the JVM. "Good" here means null-safe by construction, immutable by default, structured-concurrent, and exhaustive over sealed domains — code that compiles under -Werror with explicit API mode and needs no !!, no runBlocking, and no defensive null checks the type system already guarantees.
Stack
- Language: Kotlin 2.4.0 (K2 compiler, default since 2.0). Enable
languageVersion/apiVersion2.4and-progressive. - JVM target: Java 25 LTS via
jvmToolchain(25)(Kotlin 2.4 supports up to Java 26). Never setsourceCompatibilityby hand — use the toolchain. - Build: Gradle 9.6.1 with the Kotlin DSL (
build.gradle.kts), a version catalog (gradle/libs.versions.toml), and the configuration cache enabled (org.gradle.configuration-cache=true). Kotlin 2.4's Gradle plugin officially supports Gradle 7.6.3–9.5.0; a newer wrapper (9.6.x) builds fine but may emit KGP deprecation warnings. - Concurrency:
kotlinx-coroutines-core1.11.0. - Serialization:
kotlinx-serialization-json1.11.0 + theorg.jetbrains.kotlin.plugin.serializationplugin. Prefer it over Jackson/Gson (compile-time, reflection-free, no polymorphic-gadget attack surface). - Dates/time:
kotlin.time.Instant,kotlin.time.Clock(stdlib, Stable since 2.3) for timestamps;kotlinx-datetime0.7.x for calendar types (LocalDate,TimeZone). - Immutable collections:
kotlinx-collections-immutable0.5.0 (requires Kotlin ≥ 2.3;persistentListOf,PersistentList) when you need true immutability, not just read-only views. In 0.5.x the copy-returning ops are participial —adding/putting/replacingAt/cleared, with the oldadd/put/set/clearnow@Deprecated. - IDs:
kotlin.uuid.Uuid— the core API is Stable in 2.4, but the V4/V7 generators (Uuid.random(),generateV7()) stay Experimental and require@OptIn(ExperimentalUuidApi::class). Prefer it overjava.util.UUIDfor new multiplatform-friendly code. - Lint/format: ktlint 1.x (via Spotless or the ktlint Gradle plugin) for formatting; detekt for static analysis. Both run in CI and fail the build.
- Test: JUnit 5 (Jupiter) platform or Kotest 6.1.11;
kotlin.testassertions facade; MockK 1.14;kotlinx-coroutines-test1.11.0; Turbine 1.2 forFlow.
Project conventions
- One top-level public declaration per file when it's a class; group tightly-related small types (a sealed hierarchy, its
datasubclasses) in one file named after the parent. - Package = directory; lowercase, no underscores (
com.acme.billing.invoice). Source rootsrc/main/kotlin, tests mirror the package undersrc/test/kotlin. - Naming:
PascalCasetypes,camelCasefuncs/vals,UPPER_SNAKEconst val. No Hungarian, noI-prefix on interfaces, noImplsuffix unless there's exactly one impl behind an interface for DI. - No wildcard imports (ktlint
no-wildcard-imports) and no unused imports — import every symbol explicitly, includingkotlinx.coroutines.flow.*members. - Format with ktlint's official Kotlin style (4-space indent, 120 col, trailing commas on). Do not hand-align.
- Libraries: turn on
explicitApi()(strict) so every public declaration needs an explicit visibility and return type. - Compiler flags:
allWarningsAsErrors = true,-Xjsr305=strict(honor Java@Nullable/@NotNull), andoptInonly per-callsite via@OptIn, never blanket in the build.
Null safety
!!is banned. If you reach for it, the type is wrong or the value should be validated. Replacements:requireNotNull(config.host) { "host must be set" }for caller-contract violations (throwsIllegalArgumentException).checkNotNull(cache[key]) { "cache miss for $key" }for internal-state invariants (throwsIllegalStateException).val user = repo.find(id) ?: return Result.NotFound— Elvis with earlyreturn/throw/continue.value?.let { ... }/?.chains for optional transforms.
- Java interop returns platform types (
String!). Never trust them: assign to an explicitString?, or wrap at the boundary and validate once. With-Xjsr305=strict, annotated Java (@NotNull) is enforced by the compiler — rely on it. lateinit varonly for genuinely-deferred non-null init (DI/test setup) and never for primitives or nullable types; guard reads with::field.isInitializedwhen init is conditional.- Model "absent" as
T?or a sealedNone, not sentinels like-1or"".
Immutability
valby default; introducevaronly when reassignment is the clearest expression, and prefer folding/reduceover accumulator loops.data classfor values; evolve withcopy(...), never mutate. Give every property aval.- Expose read-only interfaces (
List,Map,Set) from APIs — never leak aMutableList. Read-only ≠ immutable: if callers must not observe later mutation, return apersistentListOf(...)/.toPersistentList()or a defensive.toList(). - Build collections with
buildList { },buildMap { },buildString { }instead of create-then-mutate. data objectfor stateless singletons (sealed leaves, sentinels) — gives a sensibletoString()/equals.
Sealed hierarchies and when
- Model closed domains with
sealed interface(preferred) orsealed class; put shared state in the parent, variants asdata class/data object. whenover a sealed type must be exhaustive with noelse— so adding a variant is a compile error at every decision site. Addingelseto silence it defeats the purpose.
sealed interface PaymentResult {
data class Ok(val txId: Uuid) : PaymentResult
data class Declined(val reason: String) : PaymentResult
data object Pending : PaymentResult
}
fun render(r: PaymentResult): String = when (r) { // expression, exhaustive
is PaymentResult.Ok -> "paid ${r.txId}"
is PaymentResult.Declined -> "declined: ${r.reason}"
PaymentResult.Pending -> "pending"
}
- Use guard conditions to flatten nested branches:
is Loaded if it.items.isEmpty() -> Empty. - Prefer
Enum.entriesover the deprecatedvalues(); makewhenon enums exhaustive too.
Idioms: scope functions, expressions, extensions
- Everything that can be an expression is one:
if/when/tryreturn values; use single-expression bodies (fun f() = ...) when the body is one expression. - Scope functions, each for its one job — do not nest them:
?.let { }— run a block on a non-null value.apply { }— configure a receiver, return it (builders).run { }/with(x) { }— compute a result in a receiver scope.also { }— side effect (logging) without breaking a chain.- Never use
let/applyto fake a multi-statement expression where a named local reads clearer.
- Destructure
data class/Pair/Map.Entry:val (id, name) = user;for ((k, v) in map). - Extension functions to add cohesion at call sites (
fun Order.taxable(): Money) instead of util classes; keep them in the same module as the type or a clearly-namedExtensions.kt. Don't declare extensions on nullable receivers unless the null case is intentional (String?.orEmpty()-style). - Context parameters (stable in 2.4) for cross-cutting dependencies (logger, transaction, clock) — this replaces the removed context receivers:
context(clock: Clock)
fun Order.isExpired(): Boolean = clock.now() > expiresAt
- Explicit backing fields (stable in 2.4) replace the
_state/statebacking-property boilerplate:
val state: StateFlow<UiState>
field = MutableStateFlow(UiState.Loading) // inside the class, `state` is the MutableStateFlow
Coroutines
- Structured concurrency always. Launch inside a scope you own; child failures/cancellations propagate. Never
GlobalScope, never bareCoroutineScope(...)without a lifecycle that cancels it. coroutineScope { }to fan out and await siblings (one failure cancels all);supervisorScope { }when siblings are independent.suspendfor one-shot async work; return the value, don't take callbacks. UsewithContext(dispatcher)to switch context; useasync { }/await()only for real concurrency (multiple in-flight), not to wrap a single call.- Dispatchers:
Dispatchers.Defaultfor CPU work,Dispatchers.IOfor blocking I/O,Dispatchers.Mainonly on UI. NeverDispatchers.Unconfinedin prod. Inject the dispatcher via constructor (class X(private val io: CoroutineDispatcher = Dispatchers.IO)) so tests can substitute aTestDispatcher— never hardcodeDispatchers.IOinside a function. - Cancellation is cooperative: check
ensureActive()/isActiveor callyield()in long CPU loops. Never swallowCancellationException— rethrow it. This is whyrunCatching/try { } catch (e: Exception)is a footgun in coroutines: it catchesCancellationException. Guard it:
suspend fun load(): Result<User> = try {
Result.success(api.fetch())
} catch (e: CancellationException) {
throw e // never swallow cancellation
} catch (e: IOException) {
Result.failure(e)
}
Flowfor streams: cold by default; do transforms upstream and set thread withflowOn(io)(affects upstream only). Expose hot state asStateFlow/SharedFlow; build them with.stateIn(scope, SharingStarted.WhileSubscribed(5_000), initial)so collection stops shortly after the last subscriber leaves. Never make aFlowcollector block; usecollectLatest/conflate/bufferfor backpressure.runBlockingis banned in production (bridges blocking ↔ suspend, wastes a thread, deadlocks on the main/single-thread dispatchers). Allowed only infun main()and in tests (but preferrunTest).- Top-level uncaught handling: a
CoroutineExceptionHandleron the root scope; children still cancel the scope unless undersupervisorScope.
Errors: Result/sealed for the domain, exceptions for the exceptional
- Model expected outcomes (validation, not-found, business rules) as a
sealed interfaceorResult<T>— the caller must handle them via exhaustivewhen. Reserve exceptions for programmer errors and truly exceptional I/O. - Don't use exceptions for control flow; don't catch
Exception/Throwablebroadly. Catch the specific type and always letCancellationExceptionpropagate (see above). - Fail fast on contract violations with
require/check/error("...")carrying a message.
Concurrency safety
- No unsynchronized shared mutable state. Prefer confinement (a single actor coroutine, or a
Mutex.withLock { }) over locks-and-hope. - For counters/flags use
java.util.concurrent.atomic(AtomicInteger,AtomicReference) on the JVM, or the multiplatformkotlin.concurrent.atomics(AtomicInt,AtomicReference) — still Experimental in 2.4, so it needs@OptIn(ExperimentalAtomicApi::class). For aggregate state, swap an immutable snapshot atomically.@Volatilealone does not make compound updates safe. - Prefer
StateFlow/Channel/Mutexoversynchronized/wait/notify.
Testing
- Framework: JUnit 5 (Jupiter) or Kotest 6.1.11. Assertions via
kotlin.test(assertEquals,assertFailsWith) or Kotest matchers (result shouldBe ...). Name tests in backticks:fun `returns Declined when balance is insufficient`(). - Coroutines: wrap suspend tests in
runTest { }(auto-skips delays via aStandardTestDispatcher); useadvanceUntilIdle()/advanceTimeBy()to control virtual time, andbackgroundScopefor collectors that outlive the test body. Flow: test with Turbine —flow.test { assertEquals(x, awaitItem()); awaitComplete() }. Assert every emission and terminal event; don't.toList()a hot flow.- Mocks: MockK. Stub suspend funcs with
coEvery { repo.fetch(id) } returns userand verify withcoVerify. Userelaxed = truesparingly. Do not use Mockito on Kotlin (final classes, no suspend support). - Test behavior and edge cases (empty, boundary, cancellation, error branches), not getters. Prefer real objects over mocks for value types. Property-based tests (Kotest
checkAll) for pure functions with invariants. - Inject a fake
Clock/CoroutineDispatcherrather than mocking time or threads.
Security
- Never hardcode secrets; read from env/secret manager. Keep keys out of
data classtoString()(wrap secrets in a class whosetoString()returns"***"). - Use
java.security.SecureRandom(orUuid.random(), which delegates to it — needs@OptIn(ExperimentalUuidApi::class)) for tokens/IDs — neverkotlin.random.RandomorMath.random()for anything security-sensitive. - Compare secrets/HMACs in constant time:
MessageDigest.isEqual(a, b), nevera == b/contentEquals. - Deserialization: with
kotlinx.serializationkeepignoreUnknownKeysdeliberate and never enable open polymorphism on untrusted input. Never use Jackson default typing /enableDefaultTyping. Validate deserialized objects ininit { require(...) }. - SQL/shell: parameterize; never string-interpolate untrusted input into queries or
ProcessBuilder. - Don't log PII/tokens. Scrub before logging; prefer structured logging with explicit allow-listed fields.
- Enforce TLS (no
TrustManageroverrides); validate certs.
Do
- Return
T?,Result<T>, or a sealed type — make illegal states unrepresentable. - Keep functions small and pure where possible; push side effects to the edges.
- Use
require/check/requireNotNullwith messages at the top of a function to establish invariants. - Prefer immutable
data class+copy(); expose read-only collection types. - Inject
CoroutineDispatcherandClock; keep suspend functions main-safe (their ownwithContext). - Use
SharingStarted.WhileSubscribed(5_000)forstateIn/shareIn. - Make
whenover sealed/enum exhaustive withoutelse. - Turn on
explicitApi(),allWarningsAsErrors,-progressive,-Xjsr305=strict.
Avoid
!!→requireNotNull(x) { "..." },?:,?.let.varby default, mutable fields, leakingMutableList/MutableStateFlow→val,copy(), exposeList/StateFlow.runBlockingin prod code →suspend+ structured scope.GlobalScope.launch→ a lifecycle-ownedCoroutineScope/coroutineScope.- Swallowing
CancellationExceptionviatry/catch (Exception)orrunCatchingin suspend code → rethrow it explicitly. - Hardcoded
Dispatchers.IOinside functions → constructor-injected dispatcher. elsebranch added just to satisfy a sealedwhen→ handle each variant.Enum.values()→Enum.entries. Context receivers → context parameters._state/statebacking-property pair → explicit backing field.- Jackson polymorphic default typing,
Randomfor tokens,==for secret comparison →kotlinx.serialization,SecureRandom,MessageDigest.isEqual. - Mockito,
Thread.sleepin tests,Thread/synchronized/wait/notify→ MockK,runTestvirtual time, coroutines +Mutex. - Wildcard imports, hand-alignment, util
objects of top-level helpers → explicit imports, ktlint, extension functions.
When you code
- Make the smallest diff that solves the task; match surrounding style; don't reformat untouched lines.
- After editing, run
./gradlew ktlintCheck detekt compileKotlin test(or the project's task) and fix everything — the code must passallWarningsAsErrors. - When you change a sealed hierarchy, fix every now-non-exhaustive
whenthe compiler flags rather than addingelse. - Add/extend tests for every behavior you touch, including the error and cancellation branches.
- Ask before: adding a new dependency, changing the public API of a library module (
explicitApi), introducing a new coroutine scope's lifecycle, or picking an error-handling strategy (exceptions vsResultvs sealed) when the codebase isn't already consistent. - Prefer stdlib and the existing stack over new libraries; if you introduce one, pin it in the version catalog and justify it.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Kotlin 2.4 · Coroutines 1.11 · JDK 25 LTS.