Kotlin Patterns
Day-to-day idiomatic Kotlin for JVM and Android code. Use android-testing for test infrastructure and mobile-technique for offensive audit of shipped APKs.
When to activate
- Writing or refactoring
.ktmodules, libraries, or Android features - Porting Java code to Kotlin without carrying over Java idioms
- Reviewing PRs for null-safety, coroutine misuse, or leaky lifecycle scopes
- Designing sealed state hierarchies,
Flowpipelines, or repository APIs - Deciding between
data class,value class,object, andsealed class
Core rules (high signal)
- Nullability is a type, not a runtime check. Never use
!!outside test scaffolding or provably non-null bridges — model with?,requireNotNull, or a sealed result. - Prefer immutability:
val,List/Map(read-only interfaces),data classwithcopy(). Reach forvarandMutableListonly when local mutation is clearer. - Suspend, don't block. In any
suspendfunction, blocking calls (JDBC,Thread.sleep, blocking I/O) must be wrapped inwithContext(Dispatchers.IO); never callrunBlockingin library, Android UI, or coroutine code. - Structured concurrency: every coroutine runs in a scope with a defined lifetime.
GlobalScopeis a smell; useviewModelScope,lifecycleScope, or an injectedCoroutineScopewithSupervisorJob. - Model states with
sealed class/sealed interface, not boolean flags or nullable pairs. Compiler-enforced exhaustivewhenis the primary correctness tool. - Errors as data at boundaries: return
Result<T>, a sealedOutcome, or a domain-specific type from network/repository layers; reserve exceptions for programmer errors and truly exceptional infra failures. data classfor values,value classfor zero-cost typed wrappers,objectfor singletons,classwhen identity or inheritance matters.- Extension functions extend readability, not surface area. Keep them
internalor file-private unless the API is intentionally public.
Outcome expectations
- Public APIs make nullability, suspension, and threading obvious at call sites.
- Domain state is modeled with sealed hierarchies;
whenexpressions are exhaustive without anelsebranch. - No unscoped coroutines; cancellation propagates through the call graph.
- No
!!, nolateinit varon shared state, norunBlockingin production paths. - ktlint/detekt run clean;
-Werrorand-Xexplicit-api=stricton library modules.
Recommended workflow
- Sketch the domain: sealed states, value-typed IDs (
@JvmInline value class UserId(val raw: String)), and repository contracts before implementation. - Choose the coroutine boundary. UI collects
StateFlow; repositories return coldFlow; suspend functions declare their dispatcher intent (withContext) at the leaf, not the caller. - Implement with small
internal/privatehelpers; split files by responsibility, not by class count. - Add null-safety and cancellation-awareness before adding features: every
suspendfun must letCancellationExceptionpropagate (never swallow it in a broadcatch (e: Exception)). - Run
./gradlew ktlintCheck detekt testbefore review.
Quick review checklist
- No
!!;lateinitonly for framework-injected non-null fields (@Inject, Android views) - No
GlobalScope,runBlocking, orThread.sleepin production code paths catch (e: Exception)blocks re-throwCancellationException(or usecatch (e: Throwable)withif (e is CancellationException) throw e)Flowcollectors run in a lifecycle-aware scope (repeatOnLifecycle(STARTED)for UI); noflow.collect { }insidelifecycleScope.launchwithout a lifecycle state gatesealedstate hierarchies use exhaustivewhen(noelse -> {}catchall on domain states)- Data classes representing domain state are
val-only; mutation goes throughcopy() - Coroutine builders (
launch,async) attach to a named scope with aSupervisorJobwhen child failure must not cancel siblings - Public API surface uses
internalwhere possible;expect/actualreserved for genuine multiplatform boundaries
Common anti-patterns to reject
!!sprinkled to satisfy the compiler on nullable receiversrunBlocking { }inside Android code (blocks the main thread) or library code (blocks the caller's thread)GlobalScope.launch { }— no lifecycle, leaks on config changelateinit varon shared mutable state instead ofval+ constructor injection- Swallowing
CancellationExceptionin a generictry/catch whenwithelse -> {}on a sealed hierarchy (silently ignores states added later)- Extension functions on
Any?in public APIs (pollutes autocompletion project-wide) - Java-style getters/setters via
@JvmField/@get:JvmNamewhen the Kotlin property already works for Kotlin consumers
Android-specific patterns
- ViewModel owns UI state (
StateFlow<UiState>); it never touchesContext, views, or navigation directly. Inject an application-scoped context only when strictly needed. viewModelScopefor work tied to the ViewModel;lifecycleScope+repeatOnLifecycle(Lifecycle.State.STARTED)for UI-tied collection. Without the state gate, background work continues on stopped screens.- Repository returns cold
Flow; caching viastateIn(scope, SharingStarted.WhileSubscribed(5_000), initial)at the ViewModel boundary.SharingStarted.Eagerlyleaks work;Lazilynever stops. - Do not hold
Context/View/Activityreferences across suspend points. UseapplicationContextfor long-lived scopes; capture what you need beforewithContext. - Room DAOs expose
suspendfor one-shots andFlow<T>for queries; never call blocking DAO methods from the main thread. - Compose: state hoisting;
rememberfor local UI state;derivedStateOffor computed state;LaunchedEffect(key)for side effects; never mutate state during composition.
Resources
Load on demand (progressive disclosure):
- references/coroutines.md — structured concurrency, dispatchers, cancellation,
Flowoperators,StateFlow/SharedFlow, testing withrunTestand virtual time; load when writing or reviewingsuspendcode or diagnosing coroutine leaks - references/nullability-and-types.md — null-safety strategies, platform types from Java,
value class,sealedmodeling, delegated properties; load when designing domain types or auditing!!andlateinitusage - references/errors-and-flow-control.md —
Result<T>vs sealedOutcome, exception hygiene, cancellation-safe try/catch,require/check/error; load when designing repository/API error contracts - references/java-interop.md — nullability annotations,
@JvmStatic/@JvmOverloads/@JvmField, SAM conversions, checked-exception boundary, calling Kotlin from Java without pain; load when publishing a Kotlin API consumed by Java or wrapping a Java library - references/android-architecture.md — ViewModel/repository/Flow shape, Hilt DI patterns, lifecycle-aware collection, Compose state hoisting; load when scaffolding a feature module or reviewing lifecycle/leak bugs