If you've used Claude Code, Cursor, or Copilot on a Kotlin codebase, you've seen it: the suggestions look like Java in a Kotlin trench coat. !! everywhere. lateinit on fields the model can't reason about. Thread { ... }.start() instead of coroutines. MutableList exposed as a public field. companion object turned into a XxxUtils dumping ground. Groovy build files in 2026.
The fix isn't more careful prompting. It's a CLAUDE.md at the root of your project — a checked-in policy file the model reads on every turn. The rules survive across sessions, they apply equally to every contributor (human or AI), and they shift the burden from "I have to remember to ask for the Kotlin idiom" to "the assistant is told what idiomatic means in this codebase."
Below are 13 rules I'd put in any Kotlin CLAUDE.md today, on Kotlin 2.0+, Coroutines 1.9+, and JDK 21. They cover backend (Ktor / Spring Boot) and Android — the rules are framework-agnostic where possible, with explicit notes when a rule changes shape on either side.
1. Coroutines and Flow over threads, callbacks, and RxJava
Kotlin coroutines give you structured concurrency for free: parent cancellation, exception propagation, and a stable CoroutineScope lifecycle. AI tools still default to Thread { ... }.start(), raw ExecutorService, or — worst — mixed RxJava/coroutine code. Pick one model. Cold reactive streams are Flow; hot streams are SharedFlow / StateFlow.
suspend fun loadUser(id: Long): User = withContext(Dispatchers.IO) {
api.fetchUser(id)
}
fun userStream(id: Long): Flow<User> = flow {
while (currentCoroutineContext().isActive) {
emit(api.fetchUser(id))
delay(5.seconds)
}
}.flowOn(Dispatchers.IO)
The CLAUDE.md rule:
All async work uses coroutines. No raw
Thread,ExecutorService, or RxJava in new code. Cold reactive streams arekotlinx.coroutines.Flow; hot streams areSharedFlow/StateFlow. EveryCoroutineScopeis bound to a lifecycle owner.
2. Null safety without !! or platform-type roulette
Kotlin's null safety is the single biggest reason to choose it over Java — but !!, untyped Java interop, and lateinit without justification re-introduce the NPEs you escaped from. AI tools sprinkle !! to silence the compiler; that's a KotlinNullPointerException waiting for a real user.
fun greet(user: User?) {
val name = user?.name ?: return
println("Hello, $name")
}
The rule:
Never use
!!to silence the compiler. If a value can be null, the type must say so.lateinitis allowed only for DI fields and Android view bindings initialized before any read. All Java interop boundaries declare nullability explicitly (@Nullable/@NotNullor wrapped types).
3. data class for value types, plain class for behavior
A data class gives you equals, hashCode, toString, copy, and componentN for free — exactly what you want for DTOs, domain values, and config. AI tools often produce a 200-line POJO with hand-written equals and a clone() method; that's not Kotlin.
data class User(val id: UserId, val name: String, val email: Email)
val updated = user.copy(name = "Alice")
The rule:
Domain values, DTOs, and config use
data class. Never hand-writeequals,hashCode,toString, or a copy method.classis reserved for types whose behavior dominates (services, resource holders) or that are intentionally referentially identified.
4. Extension functions over utility singletons (but not as hidden inheritance)
Extensions let you add methods to types you don't own — String.slugify(), LocalDate.isWeekend() — without StringUtils.slugify(s) ceremony. They're statically resolved, so no virtual dispatch surprises. Don't abuse them: extensions on Any?, on framework types you don't own across module boundaries, or to fake polymorphism are all anti-patterns.
fun String.slugify(): String =
lowercase().replace("\\s+".toRegex(), "-")
The rule:
Prefer extension functions over
object Utils. Scope them tightly: extend the specific type you need, notAny?. File-level (top-level) functions over extensions when the receiver is incidental. Never use extensions to fake polymorphism on a sealed hierarchy.
5. Sealed classes / interfaces for state and results
A sealed hierarchy gives the compiler an exhaustive list of subtypes, so when is checked at compile time and adding a new case forces you to update every consumer. This is the right encoding for state machines, domain results, and protocol messages — far better than a String status field.
sealed interface UiState {
data object Loading : UiState
data class Success(val user: User) : UiState
data class Error(val cause: Throwable) : UiState
}
when (state) {
is UiState.Loading -> showSpinner()
is UiState.Success -> render(state.user)
is UiState.Error -> showError(state.cause)
}
The rule:
Model finite, named states with
sealed interface(preferred) orsealed class.whenover a sealed hierarchy must be exhaustive — noelsebranch unless the universe is genuinely open. Stringly-typed status fields are bugs.
6. Scope functions: pick the right one, don't chain them
let, run, apply, also, with all "do something with this object" — but each has different semantics for receiver (this vs it) and return value (object vs lambda result). Random choice produces unreadable code. Pick by intent:
-
apply— configure (returns receiver,thisinside) -
let— null-safe transform (returns lambda result,itinside) -
also— side effect like logging (returns receiver,itinside) -
run— block expression on a receiver (returns lambda result,thisinside) -
with— when you'd otherwise writeobj.run { ... }and the receiver is non-null
The rule:
Use scope functions by intent. Don't chain three or more scope functions; break into named locals.
7. Test coroutines with runTest and a TestDispatcher
runBlocking in tests blocks the JVM thread, makes delay(...) actually sleep, and hides race conditions. runTest from kotlinx-coroutines-test gives you virtual time, advanceTimeBy, and deterministic dispatchers. Inject the dispatcher; never reference Dispatchers.Main directly in production code you want to test.
class UserService(private val io: CoroutineDispatcher) {
suspend fun load(id: Long) = withContext(io) { api.fetch(id) }
}
@Test fun loadsUser() = runTest {
val service = UserService(StandardTestDispatcher(testScheduler))
val job = launch { service.load(1) }
advanceTimeBy(1.seconds)
assertEquals(expected, job.await())
}
The rule:
Tests that exercise suspend functions use
runTest, neverrunBlocking. Production code takes aCoroutineDispatcherparameter so tests can substitute aTestDispatcher. No direct references toDispatchers.Mainin domain code.
8. Companion objects for type-bound factories, top-level for utilities
A companion object is the right place for static factories tied to a class (User.fromJson(...)), constants used by that class, and serializer instances. It's the wrong place for unrelated helpers — those are top-level functions in the package. AI tools frequently dump every utility into a companion, recreating Java's XxxUtils antipattern.
class User(val id: UserId, val name: String) {
companion object {
const val MAX_NAME_LENGTH = 100
fun fromJson(s: String): User = Json.decodeFromString(s)
}
}
// validation/Email.kt — top level, not a companion of User
fun isValidEmail(email: String): Boolean = email.contains("@")
The rule:
A companion object holds factories, serializers, and constants tied to its enclosing class. Generic helpers belong in top-level functions in a focused package, not in a companion.
object Utilsand JVM-styleXxxUtilsclasses are forbidden.
9. when expressions over if/else chains and switch-style fall-through
when is an expression (returns a value), supports range, type, and predicate matching, and gives exhaustiveness checks over sealed types and enums. Long if/else if chains are smells in Kotlin. Use the expression form so the compiler can verify the branches.
fun classify(n: Int): String = when {
n < 0 -> "negative"
n == 0 -> "zero"
n in 1..9 -> "small"
n in 10..99 -> "medium"
else -> "large"
}
The rule:
Prefer
when(as an expression) over chainedif/elsewhen matching multiple conditions or types.whenover a sealed hierarchy or enum must be exhaustive withoutelse. Each branch is a single expression; pull complex bodies into named functions.
10. Immutability by default: val, List, and copy()
val is the default; var is opt-in with a reason. Read-only collections (List, Map, Set) over MutableList/MutableMap for parameters and return types. data class.copy(...) produces new instances instead of mutating in place. This makes code thread-safe by construction and removes a whole category of bugs.
data class Cart(val items: List<Item> = emptyList()) {
val total: Double get() = items.sumOf { it.price }
fun add(item: Item): Cart = copy(items = items + item)
}
The rule:
valby default;varrequires a justification. Public APIs returnList/Map/Set, never theMutable*variants. Domain types usedata class+copy()for state changes.MutableListonly as a local builder inside a function.
11. suspend functions over callbacks and Future-style APIs
Suspend functions read like sequential code, propagate errors as exceptions, and cancel correctly. Callback-based APIs leak onSuccess/onError ladder logic; CompletableFuture doesn't compose with structured concurrency. Convert callback APIs to suspend at the boundary with suspendCancellableCoroutine.
suspend fun loadProfile(id: Long): Profile {
val user = api.fetchUser(id)
val posts = api.fetchPosts(user.id)
return Profile(user, posts)
}
suspend fun callbackFetch(id: Long): User = suspendCancellableCoroutine { cont ->
api.fetchUser(id, onOk = cont::resume, onErr = cont::resumeWithException)
}
The rule:
New async APIs are
suspend fun. Callback-based libraries are wrapped withsuspendCancellableCoroutinein a single adapter file at the boundary. NoCompletableFuture/ListenableFuturein domain code; convert with.await()/asDeferred()at the integration edge.
12. Gradle Kotlin DSL, pinned versions, version catalog
build.gradle.kts gives type-safe build scripts, IDE autocomplete, and refactor support that Groovy build.gradle can't. A libs.versions.toml catalog centralizes versions and lets Renovate / Dependabot bump them safely. AI tools still produce apply plugin: Groovy snippets and 'group:artifact:1.0' strings buried in subprojects — both age badly.
// build.gradle.kts
plugins { alias(libs.plugins.kotlin.jvm) }
dependencies {
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlinx.coroutines.test)
}
# gradle/libs.versions.toml
[versions]
kotlin = "2.0.21"
coroutines = "1.9.0"
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
The rule:
All build scripts are
*.gradle.kts(Kotlin DSL). Dependency coordinates live ingradle/libs.versions.toml(version catalog). No Groovy build files in new modules. Pinned versions only; no+orlatest.release.
13. Framework-agnostic domain layer (Android/backend symmetry)
Domain code that imports android.* or a Spring @Component annotation can't be tested in JVM unit tests, can't move between Android and a backend service, and ties business rules to framework lifecycles. Keep the domain pure: input types in, output types out, with CoroutineDispatcher and Clock injected. Frameworks live in the outer layer.
// :domain — pure Kotlin, JVM/Android/Native compatible
class CheckoutService(
private val orders: OrderRepository,
private val clock: Clock,
private val io: CoroutineDispatcher,
private val log: Logger,
) {
suspend fun checkout(cart: Cart): Order = withContext(io) {
log.info("starting checkout")
orders.save(cart.toOrder(clock.now()))
}
}
// :app-android — wires android.util.Log adapter and Dispatchers.IO
// :app-server — wires SLF4J adapter and a server-tuned dispatcher
The rule:
Domain modules import only
kotlin.*,kotlinx.coroutines,kotlinx.datetime, and the project's own pure types. Noandroid.*, no Spring annotations, no frameworkLoggertypes. Cross-cutting dependencies (Clock,Dispatcher,Logger) are constructor parameters injected by the outer (framework) module.
Wrapping up
These 13 rules don't cover every Kotlin pitfall — there's no rule for inline functions, no rule for Result<T> vs typed errors, no rule for KMP (multiplatform) yet. They're the highest-leverage subset: the rules whose absence I've seen cause real production incidents on real Kotlin codebases.
Drop them at the root of your repo as CLAUDE.md. Cursor users: same content as .cursorrules or under .cursor/rules/. Copilot users: .github/copilot-instructions.md. The file format is just markdown — every modern AI coding tool reads it on every turn.
If you want this for 35+ stacks (Kotlin, Go, Rust, TypeScript, Swift, Python, Java, C#, Astro, Flutter, Scala, C++, Next.js 15, Node) with production-tested rules ready to paste in, the CLAUDE.md Rules Pack is $27 at oliviacraftlat.gumroad.com/l/skdgt.
— Olivia (@OliviaCraftLat)
Top comments (0)