DEV Community

Olivia Craft
Olivia Craft

Posted on

7 CLAUDE.md Rules That Make AI Write Idiomatic Kotlin (Not Java in a Kotlin Hat)

If you've ever asked Claude Code, Cursor, or Copilot to "add a feature" to a Kotlin project, you know the output: !! on every nullable, GlobalScope.launch from a 2019 Android tutorial, RxJava interleaved with coroutines for no reason, lateinit var everywhere, and runBlocking used as a "bridge" (it's not — it's a deadlock). It compiles. It also looks like Java wearing a Kotlin hat.

The cause is the training data. A lot of Kotlin in the wild is converted Java, transcribed Android tutorials, and snippet-grade examples that elide structured concurrency, error modeling, and dispatcher hygiene. Production Kotlin looks nothing like this.

Drop a CLAUDE.md at the repo root and the AI reads it before every task. Here are the seven rules I find most load-bearing for real Kotlin work — Android apps, Spring backends, and multiplatform libs alike.


Rule 1: Never !! — null safety is the whole point

Every !! is a future production crash. AI uses them because they're shorter than handling the real case, and the resulting NullPointerException has no message — just a line number. Use ?., ?:, and requireNotNull(token) { "auth token missing — login state inconsistent" } to fail fast with a real error message Crashlytics can tell you something about. If you reach for !!, the upstream type is wrong — fix it there. Platform types from Java (String!) get resolved to String or String? immediately at the boundary, never propagated through the codebase.

Rule 2: No GlobalScope. Ever.

GlobalScope.launch { ... } is AI's go-to because it "just works" in tutorials. In a real app it leaks coroutines past screen destruction, fires network calls after the user signs out, and crashes when the response arrives at a dead ViewModel. ViewModels use viewModelScope. UI uses lifecycleScope + repeatOnLifecycle(STARTED) so flows stop collecting when the screen is backgrounded. Spring controllers are suspend fun and run inside the request scope. Repositories accept a scope — they never create their own.

Rule 3: Inject the Dispatcher, never hardcode Dispatchers.IO

Production code always specifies a dispatcher: withContext(Dispatchers.IO) for disk/network, Dispatchers.Default for CPU, never block Dispatchers.Main. But the dispatcher gets injected via the constructor — not read directly inside the class — so tests can swap in UnconfinedTestDispatcher and runTest { } makes virtual time predictable. Hardcoded Dispatchers.IO is the #1 reason coroutine tests are flaky.

Rule 4: Sealed types over exceptions for recoverable errors

Model recoverable failures as sealed interface AppError and return your own Result<T, AppError> (kotlin.Result is reserved for inline workings). The compiler forces every caller to handle every error variant via exhaustive when — no else branch, no silent additions. Exceptions stay where they belong: programmer errors and IO faults the caller can't recover from. Wrap throwing third-party APIs in runCatching { }.onFailure { ... }.getOrElse { ... } at the boundary, then never let raw exceptions touch domain code.

Rule 5: Cooperate with cancellation — don't swallow CancellationException

The pattern AI gets wrong: try { ... } catch (e: Exception) { log(e); fallback() }. CancellationException extends Exception, so you've just turned a structured cancellation into a swallowed bug — the coroutine "completed successfully" while the parent thinks it was cancelled. The correct pattern is catch (e: CancellationException) { throw e } catch (e: Exception) { ... }. Long-running loops check isActive or call yield(). Detekt's SwallowedException rule fails the build on violations.

Rule 6: KSP, not kapt — and Kotlin DSL for Gradle

kapt is dead. KSP (Kotlin Symbol Processing) is 2× faster, and Hilt, Room, Moshi, and Compose all support it. Still seeing kapt in build files is a signal that AI is regurgitating 2021 templates. Same applies to Gradle: every build script is build.gradle.kts (Kotlin DSL), every plugin is declared in the plugins { } block, and dependency versions live in gradle/libs.versions.toml so all modules agree. No Groovy, no apply plugin: '...', no scattered version literals.

Rule 7: Repository tests run against a real database via Testcontainers

Use Testcontainers (Postgres for Spring, in-memory Room for Android) for any code that talks to a database. Mocked DAO tests pass while broken queries ship — I've shipped that bug, you've shipped that bug, AI ships it by default. Coroutine tests use runTest { } from kotlinx-coroutines-test with StandardTestDispatcher. Property-based tests via Kotest's forAll { ... } for parsers, validators, and serialization round-trips. Hand-picked example inputs miss the cases that crash production.


Wrapping up

These seven rules don't replace Effective Kotlin or the official coroutines guide — they encode the failure modes AI repeats most often when writing Kotlin. No !!, no GlobalScope, injected dispatchers, sealed errors over exceptions, cooperative cancellation, KSP + Kotlin DSL, real DB in tests. That's the line between "compiles, looks Kotliny" and "I'd merge this."

Drop the file at the root of your project. The next AI prompt produces Kotlin your future self won't have to apologise for during the next post-mortem.


Free sample (this article + Gist): CLAUDE.md — Kotlin Edition on GitHub Gist

Get the full CLAUDE.md Rules Pack (40+ languages and frameworks): oliviacraftlat.gumroad.com/l/skdgt

Top comments (0)