| name | kotlin-engineer |
| description | Encodes the project's Kotlin 2.x implementation policy and the pitfalls that recur in review. Use whenever writing, modifying, refactoring, or explaining Kotlin code, especially coroutines, Flow, null-safety, Java interop, Gradle Kotlin DSL, and public API design.
|
Kotlin — policy & pitfalls
Baseline Kotlin knowledge (data/sealed/value classes, scope functions, null-safety operators, extension functions, suspend, Flow, when exhaustiveness) is assumed. This skill does not teach the language — it encodes the project policy and the traps that keep appearing in code review.
When to Use
Use kotlin-engineer for implementation work in Kotlin or Kotlin DSL:
- Writing or changing
.kt, .kts, or Kotlin Multiplatform source.
- Refactoring Java-style Kotlin into idiomatic Kotlin.
- Designing public Kotlin APIs, result/state types, or domain identifiers.
- Touching coroutines,
Flow, StateFlow, SharedFlow, suspend functions, or
cancellation behavior.
- Reviewing Kotlin code for correctness.
This skill is the baseline for the Kotlin inside a test body too. But the
test-writing conventions themselves — JUnit 5 + Kotest structure, Spec / XKtSpec
naming, the backticked @Nested layout, and testlib helpers — live in
.agents/skills/kotlin-jvm-tester/SKILL.md. Defer there when writing or reviewing
test code.
Fast Path for Agents
- Run the setup check below only for non-trivial Kotlin changes or when the
module's Kotlin/JDK/tooling baseline is unclear.
- Apply the MUST / MUST NOT rules while editing.
- Load only the reference file that matches the risky part of the task:
coroutines, API idioms, or build setup.
- Verify with the narrowest relevant Gradle compile/test task.
Setup Check
Run this before non-trivial Kotlin changes, or when the module's
Kotlin/JDK/tooling baseline is unclear (see the Fast Path above):
- Kotlin version — target 2.x when possible. Check
build.gradle(.kts) (kotlin("jvm") version "2.x") or libs.versions.toml.
- JDK target — Use JDK 21+ for virtual threads or JDK 17+ for record interop.
- Lint —
detekt / ktlint configured? Follow the existing rules; don't introduce new violations.
- Build wrapper — use
./gradlew
MUST DO
- Null-safety via
?, ?., ?:, let, requireNotNull. Use !! only when null is a true contract violation — document why on the same line.
- Sealed hierarchies for closed result / state types (
sealed interface Result { data class Success(...); data class Failure(...) }) + exhaustive when without else.
- Value classes (
@JvmInline value class) for domain identifiers (UserId, Email) — zero-overhead type-safety.
data class only for pure value types. Not for entities, services, or anything with behavior / lifecycle.
- Structured concurrency — inject
CoroutineScope, use coroutineScope { } / framework scopes (viewModelScope). Never GlobalScope.launch.
- Always rethrow
CancellationException in generic catch (e: Exception) blocks — swallowing it disables cancellation.
- Expose read-only Flow types —
val state: StateFlow<X> = _state.asStateFlow(). Never leak MutableStateFlow / MutableSharedFlow from an API.
withContext(Dispatchers.IO) / Default for blocking / CPU work inside suspend. Encapsulate dispatcher choice in the repository / data-source layer — not at call sites.
- Immutability by default —
val over var, List over MutableList in public API, copy() on data classes instead of mutation.
- Named arguments for 3+ parameters — prevents silent argument swaps at call sites.
- Deprecated API only on the operator's explicit instruction. When a human operator directs you to use a deprecated API, confine the call to the narrowest scope and suppress the warning right there —
@Suppress("DEPRECATION") ("DEPRECATION_ERROR" for error-level) with a // Reason: … comment that names the sanctioning instruction and the replacement to migrate to.
MUST NOT DO
- No
!! without a commented reason. Refactor to ?.let { } / requireNotNull(x) { "why" }.
- No
runBlocking in production — only in main and tests. Inside a suspend function it's always a bug.
- No
GlobalScope.launch / GlobalScope.async — leaks, no structured cancellation.
- No swallowing
CancellationException. try/catch(Exception) without a cancellation rethrow silently disables cancellation.
- No
.first() / .single() on a hot Flow without a timeout — a source that never emits hangs the coroutine forever.
- No
async { }.await() sequentially when you want parallelism — it's the same as calling suspend directly. Use coroutineScope { val a = async { .. }; val b = async { .. }; a.await() + b.await() }.
- No
Dispatchers.Main / Dispatchers.IO references from common / multiplatform code unless the module is JVM-only.
- No platform-type leaks (
String!) in public API — annotate Java interop returns with @NotNull / @Nullable on the Java side, or cast explicitly.
- No catching
Throwable — you'll catch OutOfMemoryError, StackOverflowError, and cancellation. Use Exception and rethrow cancellation.
- No
lateinit var on primitives or nullable types — compile error. Use Delegates.notNull() for primitives.
- No deprecated API in new or changed code without that explicit instruction — reach for the replacement named in the
@Deprecated / ReplaceWith message or the library's migration notes instead.
Reference Guide
| Load when | File |
|---|
| Async / reactive code — coroutines, Flow, StateFlow/SharedFlow, cancellation, testing | references/coroutines.md |
API design — scope functions, value/data/sealed classes, extension functions, inline/reified, delegates, Result<T> | references/idioms.md |
| Gradle / tooling — Kotlin DSL, version catalogs, KSP vs kapt, multi-module layout, compiler plugins | references/build-setup.md |
Output Format
When producing code:
- A short plan (1–3 bullets) of what's changing.
- The code.
- A checklist of the non-obvious MUST rules applied.
When reviewing code: call out MUST-DO / MUST-NOT violations explicitly and suggest the minimal fix. End with a one-line verdict — APPROVE, APPROVE WITH CHANGES, or REQUEST CHANGES — so orchestrators such as pre-pr can aggregate the result. Use APPROVE when no MUST rules are violated.