Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Language · Kotlin 2.4 · Coroutines 1.11 · JDK 25 LTS

Kotlin

Null-safety, data classes, coroutines and sealed types.

kotlincoroutinesjvm

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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/apiVersion 2.4 and -progressive.
  • JVM target: Java 25 LTS via jvmToolchain(25) (Kotlin 2.4 supports up to Java 26). Never set sourceCompatibility by 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-core 1.11.0.
  • Serialization: kotlinx-serialization-json 1.11.0 + the org.jetbrains.kotlin.plugin.serialization plugin. 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-datetime 0.7.x for calendar types (LocalDate, TimeZone).
  • Immutable collections: kotlinx-collections-immutable 0.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 old add/put/set/clear now @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 over java.util.UUID for 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.test assertions facade; MockK 1.14; kotlinx-coroutines-test 1.11.0; Turbine 1.2 for Flow.

Project conventions

  • One top-level public declaration per file when it's a class; group tightly-related small types (a sealed hierarchy, its data subclasses) in one file named after the parent.
  • Package = directory; lowercase, no underscores (com.acme.billing.invoice). Source root src/main/kotlin, tests mirror the package under src/test/kotlin.
  • Naming: PascalCase types, camelCase funcs/vals, UPPER_SNAKE const val. No Hungarian, no I-prefix on interfaces, no Impl suffix 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, including kotlinx.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), and optIn only 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 (throws IllegalArgumentException).
    • checkNotNull(cache[key]) { "cache miss for $key" } for internal-state invariants (throws IllegalStateException).
    • val user = repo.find(id) ?: return Result.NotFound — Elvis with early return/throw/continue.
    • value?.let { ... } / ?. chains for optional transforms.
  • Java interop returns platform types (String!). Never trust them: assign to an explicit String?, or wrap at the boundary and validate once. With -Xjsr305=strict, annotated Java (@NotNull) is enforced by the compiler — rely on it.
  • lateinit var only for genuinely-deferred non-null init (DI/test setup) and never for primitives or nullable types; guard reads with ::field.isInitialized when init is conditional.
  • Model "absent" as T? or a sealed None, not sentinels like -1 or "".

Immutability

  • val by default; introduce var only when reassignment is the clearest expression, and prefer folding/reduce over accumulator loops.
  • data class for values; evolve with copy(...), never mutate. Give every property a val.
  • Expose read-only interfaces (List, Map, Set) from APIs — never leak a MutableList. Read-only ≠ immutable: if callers must not observe later mutation, return a persistentListOf(...)/.toPersistentList() or a defensive .toList().
  • Build collections with buildList { }, buildMap { }, buildString { } instead of create-then-mutate.
  • data object for stateless singletons (sealed leaves, sentinels) — gives a sensible toString()/equals.

Sealed hierarchies and when

  • Model closed domains with sealed interface (preferred) or sealed class; put shared state in the parent, variants as data class/data object.
  • when over a sealed type must be exhaustive with no else — so adding a variant is a compile error at every decision site. Adding else to 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.entries over the deprecated values(); make when on enums exhaustive too.

Idioms: scope functions, expressions, extensions

  • Everything that can be an expression is one: if/when/try return 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/apply to 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-named Extensions.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/state backing-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 bare CoroutineScope(...) without a lifecycle that cancels it.
  • coroutineScope { } to fan out and await siblings (one failure cancels all); supervisorScope { } when siblings are independent.
  • suspend for one-shot async work; return the value, don't take callbacks. Use withContext(dispatcher) to switch context; use async { }/await() only for real concurrency (multiple in-flight), not to wrap a single call.
  • Dispatchers: Dispatchers.Default for CPU work, Dispatchers.IO for blocking I/O, Dispatchers.Main only on UI. Never Dispatchers.Unconfined in prod. Inject the dispatcher via constructor (class X(private val io: CoroutineDispatcher = Dispatchers.IO)) so tests can substitute a TestDispatcher — never hardcode Dispatchers.IO inside a function.
  • Cancellation is cooperative: check ensureActive()/isActive or call yield() in long CPU loops. Never swallow CancellationException — rethrow it. This is why runCatching/try { } catch (e: Exception) is a footgun in coroutines: it catches CancellationException. 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)
}
  • Flow for streams: cold by default; do transforms upstream and set thread with flowOn(io) (affects upstream only). Expose hot state as StateFlow/SharedFlow; build them with .stateIn(scope, SharingStarted.WhileSubscribed(5_000), initial) so collection stops shortly after the last subscriber leaves. Never make a Flow collector block; use collectLatest/conflate/buffer for backpressure.
  • runBlocking is banned in production (bridges blocking ↔ suspend, wastes a thread, deadlocks on the main/single-thread dispatchers). Allowed only in fun main() and in tests (but prefer runTest).
  • Top-level uncaught handling: a CoroutineExceptionHandler on the root scope; children still cancel the scope unless under supervisorScope.

Errors: Result/sealed for the domain, exceptions for the exceptional

  • Model expected outcomes (validation, not-found, business rules) as a sealed interface or Result<T> — the caller must handle them via exhaustive when. Reserve exceptions for programmer errors and truly exceptional I/O.
  • Don't use exceptions for control flow; don't catch Exception/Throwable broadly. Catch the specific type and always let CancellationException propagate (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 multiplatform kotlin.concurrent.atomics (AtomicInt, AtomicReference) — still Experimental in 2.4, so it needs @OptIn(ExperimentalAtomicApi::class). For aggregate state, swap an immutable snapshot atomically. @Volatile alone does not make compound updates safe.
  • Prefer StateFlow/Channel/Mutex over synchronized/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 a StandardTestDispatcher); use advanceUntilIdle()/advanceTimeBy() to control virtual time, and backgroundScope for 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 user and verify with coVerify. Use relaxed = true sparingly. 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/CoroutineDispatcher rather than mocking time or threads.

Security

  • Never hardcode secrets; read from env/secret manager. Keep keys out of data class toString() (wrap secrets in a class whose toString() returns "***").
  • Use java.security.SecureRandom (or Uuid.random(), which delegates to it — needs @OptIn(ExperimentalUuidApi::class)) for tokens/IDs — never kotlin.random.Random or Math.random() for anything security-sensitive.
  • Compare secrets/HMACs in constant time: MessageDigest.isEqual(a, b), never a == b/contentEquals.
  • Deserialization: with kotlinx.serialization keep ignoreUnknownKeys deliberate and never enable open polymorphism on untrusted input. Never use Jackson default typing / enableDefaultTyping. Validate deserialized objects in init { 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 TrustManager overrides); 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/requireNotNull with messages at the top of a function to establish invariants.
  • Prefer immutable data class + copy(); expose read-only collection types.
  • Inject CoroutineDispatcher and Clock; keep suspend functions main-safe (their own withContext).
  • Use SharingStarted.WhileSubscribed(5_000) for stateIn/shareIn.
  • Make when over sealed/enum exhaustive without else.
  • Turn on explicitApi(), allWarningsAsErrors, -progressive, -Xjsr305=strict.

Avoid

  • !!requireNotNull(x) { "..." }, ?:, ?.let.
  • var by default, mutable fields, leaking MutableList/MutableStateFlowval, copy(), expose List/StateFlow.
  • runBlocking in prod code → suspend + structured scope.
  • GlobalScope.launch → a lifecycle-owned CoroutineScope/coroutineScope.
  • Swallowing CancellationException via try/catch (Exception) or runCatching in suspend code → rethrow it explicitly.
  • Hardcoded Dispatchers.IO inside functions → constructor-injected dispatcher.
  • else branch added just to satisfy a sealed when → handle each variant.
  • Enum.values()Enum.entries. Context receivers → context parameters. _state/state backing-property pair → explicit backing field.
  • Jackson polymorphic default typing, Random for tokens, == for secret comparison → kotlinx.serialization, SecureRandom, MessageDigest.isEqual.
  • Mockito, Thread.sleep in tests, Thread/synchronized/wait/notify → MockK, runTest virtual 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 pass allWarningsAsErrors.
  • When you change a sealed hierarchy, fix every now-non-exhaustive when the compiler flags rather than adding else.
  • 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 vs Result vs 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.

Back to top ↑