Kotlin for Android
Write Kotlin the way a senior Android engineer would in a current codebase: null-safe, immutable by default, structured concurrency everywhere, Compose that skips work it does not need to do, and a build that is reproducible from the version catalog. Prefer the boring, documented API over a clever one.
When to use
- Writing new Kotlin code in an Android app: ViewModels, use cases, repositories, composables, DAOs, workers.
- Refactoring Java, callback or RxJava code to coroutines and Flow.
- Migrating XML screens to Jetpack Compose, kapt to KSP, Groovy Gradle to Kotlin DSL.
- Fixing recomposition, lifecycle, coroutine-leak or nullability bugs.
- Updating code for a new
targetSdk(edge-to-edge, predictive back, foreground service rules).
Not for: architecture choice (use mobile-mvvm, mobile-mvi), a security audit (use mobile-security),
legacy Java maintenance (use mobile-java), or reviewing someone else's diff (use mobile-code-review).
Workflow
- Read the project before writing code.
gradle/libs.versions.toml: Kotlin, AGP, Compose BOM, coroutines, Hilt, Room versions.- Module
build.gradle.kts:minSdk,targetSdk,compileSdk, KSP or kapt, Compose enabled. - Existing conventions: package layout, base classes, dispatcher qualifiers, error types, test style.
- Match the existing stack. Do not add a library the project does not use without saying so.
Do not change
minSdk,targetSdkorcompileSdkwithout asking. - Load the reference for the area being touched: Coroutines and Flow, Compose, Hilt and Room, Build and tooling, Platform changes, Testing.
- Model first: domain types, UI state, events, errors as sealed or data types. Then data layer, then ViewModel, then UI.
- Write the code following the Rules below. Keep functions small,
privateby default, and public API explicit about types. - Add or update unit tests for new domain and data logic, and a Compose test for new interactive UI.
- Run the build, the tests and the linters (
./gradlew detekt ktlintCheck testDebugUnitTestor the project's equivalents). Report failures honestly; do not declare done on a red build. - Summarize what changed, any new dependency, and any manifest, permission or SDK impact.
Baseline stack
| Area | Default | Avoid in new code |
|---|---|---|
| Language | Kotlin 2.x, K2 compiler | Kotlin 1.x language versions, synthetic view imports |
| UI | Jetpack Compose + Material 3 | New XML layouts, findViewById |
| State | StateFlow + immutable data class |
LiveData in new ViewModels, mutable public state |
| Async | Coroutines + Flow | AsyncTask, raw threads, new RxJava |
| DI | Hilt with constructor injection | Manual singletons, service locators |
| Persistence | Room (suspend + Flow), DataStore | SharedPreferences for new code |
| Serialization | kotlinx.serialization | Reflection-based parsers without keep rules |
| Codegen | KSP | kapt where a KSP processor exists |
| Build | Kotlin DSL + libs.versions.toml |
Hardcoded versions in module files |
Rules
1. No !!. Fail with a message or handle the null
// BAD: NullPointerException with no context
val id = savedStateHandle.get<String>("id")!!
// GOOD: invariant violation explains itself
val id = requireNotNull(savedStateHandle.get<String>("id")) { "id nav argument missing" }
// GOOD: absence is a normal case
val name = user?.displayName ?: context.getString(R.string.anonymous)
checkNotNullfor state invariants,requireNotNullfor arguments. Java platform types are nullable.lateinitonly for framework-injected fields (@Inject lateinit varin an entry point), never for data.
2. State is immutable; variants are sealed
// BAD: mutable fields, impossible states (loading and error at once)
class ProfileState { var loading = false; var error: String? = null; var user: User? = null }
// GOOD
sealed interface ProfileUiState {
data object Loading : ProfileUiState
data class Content(val user: User, val isRefreshing: Boolean = false) : ProfileUiState
data class Error(val reason: UiError) : ProfileUiState
}
whenover a sealed type has noelse, so a new variant is a compile error, not a silent fallthrough.- Use
valand read-only collection types in data classes. Change state withcopy. - Guard conditions in
when(stable since Kotlin 2.2) replace nestedifinside a branch:is ProfileUiState.Content if it.isRefreshing -> ....
3. Typed IDs use value classes
// BAD: easy to swap arguments, compiler cannot help
fun assign(deviceId: String, userId: String)
assign(user.id, device.id) // compiles, wrong
// GOOD: zero-cost wrapper on the JVM
@JvmInline value class DeviceId(val value: String)
@JvmInline value class UserId(val value: String)
fun assign(deviceId: DeviceId, userId: UserId)
- Keep value classes in the domain layer. Map to primitives at DTO and entity boundaries unless the library supports them (kotlinx.serialization does).
- Validate in
initwhen the wrapper carries an invariant:init { require(value.isNotBlank()) }.
4. Every coroutine has an owner. No GlobalScope
// BAD: outlives the screen, cannot be cancelled or tested
GlobalScope.launch { repository.sync() }
// GOOD: tied to the ViewModel
viewModelScope.launch { repository.sync() }
// GOOD: work that must outlive a screen uses an injected application scope or WorkManager
class SyncTrigger @Inject constructor(@ApplicationScope private val scope: CoroutineScope)
- Repositories and use cases expose
suspendfunctions orFlow. They do not launch and returnUnit. runBlockingonly inmainfunctions and legacy bridges, never on the main thread.
5. Suspend functions are main-safe; dispatchers are injected
// BAD: hardcoded dispatcher, caller must remember to switch threads
class FileStore { fun read(): ByteArray = file.readBytes() }
// GOOD
class FileStore @Inject constructor(
@IoDispatcher private val io: CoroutineDispatcher,
) {
suspend fun read(): ByteArray = withContext(io) { file.readBytes() }
}
- The function that does the blocking work switches context. Callers never wrap it again.
Dispatchers.Defaultfor CPU work, IO dispatcher for blocking IO. Main is only for UI.
6. CancellationException is always rethrown
// BAD: runCatching and catch-all both swallow cancellation
val result = runCatching { api.fetch() }
try { api.fetch() } catch (e: Exception) { log(e) }
// GOOD
try {
api.fetch()
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
_state.update { it.copy(error = UiError.Network) }
}
Catch the narrowest exception that can be handled. Use ensureActive() in long loops.
7. Use supervisorScope when children fail independently
// BAD: one failed widget cancels all the others
suspend fun loadDashboard() = coroutineScope {
val a = async { api.weather() }
val b = async { api.news() }
Dashboard(a.await(), b.await())
}
// GOOD: each child failure is handled at its await
suspend fun loadDashboard() = supervisorScope {
val a = async { api.weather() }
val b = async { api.news() }
Dashboard(weather = a.awaitOrNull(), news = b.awaitOrNull())
}
awaitOrNull is a local helper that catches the expected exception types and rethrows cancellation.
Use coroutineScope when the result is all-or-nothing.
8. Mutable state is private; updates are atomic
// BAD: public mutable state and a read-modify-write race
val state = MutableStateFlow(UiState())
fun select(id: ItemId) { state.value = state.value.copy(selected = id) }
// GOOD
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()
fun select(id: ItemId) = _state.update { it.copy(selected = id) }
9. Cold flows become state once, and UI collects with the lifecycle
// BAD: new upstream per collector, keeps running in the background
val items: Flow<List<Item>> get() = repository.observeItems()
val items by viewModel.items.collectAsState(initial = emptyList())
// GOOD
val items: StateFlow<List<Item>> = repository.observeItems()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val items by viewModel.items.collectAsStateWithLifecycle()
WhileSubscribed(5_000)keeps the upstream alive across configuration changes, stops it in background.- Views and Fragments collect inside
repeatOnLifecycle(Lifecycle.State.STARTED). - Details on
flowOn,catch,combineand SharedFlow: Coroutines and Flow.
10. Composables are stateless; state is hoisted
// BAD: ViewModel passed deep, no modifier, not previewable
@Composable
fun DeviceRow(vm: DevicesViewModel, id: DeviceId) { /* reads vm.state, calls vm.connect */ }
// GOOD
@Composable
fun DeviceRow(
device: DeviceUi,
onConnect: (DeviceId) -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier.clickable { onConnect(device.id) }) { Text(device.name) }
}
- Only the screen-level composable touches the ViewModel. Below it: state in, lambdas out.
modifier: Modifier = Modifieris the first optional parameter and is applied to the root only.rememberSaveablefor user input that must survive rotation and process death.
11. Parameters are stable so composables can skip
// BAD: List is an interface the compiler treats as unstable; a new list instance
// every emission forces recomposition of every row
data class FeedUi(val posts: List<PostUi>)
// GOOD
@Immutable
data class FeedUi(val posts: ImmutableList<PostUi>)
// built with posts.map(::toUi).toImmutableList()
- Strong skipping is the default since Kotlin 2.0.20: unstable parameters are compared by instance,
stable ones by
equals. Recreating objects on every emission still defeats skipping. - Do not sprinkle
@Stableto silence reports. Annotate only types that keep the contract.
12. Effects are keyed by what they read
// BAD: keeps the first deviceId forever
LaunchedEffect(Unit) { viewModel.connect(deviceId) }
// GOOD: restarts when deviceId changes
LaunchedEffect(deviceId) { viewModel.connect(deviceId) }
// GOOD: long-lived effect that must call the latest lambda without restarting
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) { delay(30_000); currentOnTimeout() }
- Listeners and observers registered in composition go in
DisposableEffectwithonDispose. - No side effects in the composable body. More in Compose.
13. Dependencies come through the constructor
// BAD: Activity context in a singleton, hidden dependency
object Prefs { lateinit var context: Context }
// GOOD
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settings: SettingsRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel()
@Singleton
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context,
)
- Bind interfaces with
@Bindsin an abstract module;@Providesonly for third-party types. - Scope annotations match lifetime. Details: Hilt and Room.
14. Room DAOs are suspend or Flow, and schemas migrate
// BAD: blocking DAO, and user data wiped on the next schema change
@Query("SELECT * FROM device") fun all(): List<DeviceEntity>
Room.databaseBuilder(ctx, AppDb::class.java, "app.db").fallbackToDestructiveMigration()
// GOOD
@Query("SELECT * FROM device ORDER BY name") fun observeAll(): Flow<List<DeviceEntity>>
@Upsert suspend fun upsert(device: DeviceEntity)
@Database(entities = [DeviceEntity::class], version = 3, exportSchema = true)
abstract class AppDb : RoomDatabase()
- Every version bump ships a
MigrationorAutoMigrationand a migration test.
15. KSP, version catalog, and the Compose compiler plugin
// BAD: kapt and hardcoded versions
plugins { kotlin("kapt") }
dependencies { kapt("com.google.dagger:hilt-compiler:2.51") }
// GOOD
plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.compose)
}
dependencies { ksp(libs.hilt.compiler) }
libs.plugins.kotlin.composemaps toorg.jetbrains.kotlin.plugin.compose, versioned with Kotlin.- Release builds enable R8 and add keep rules for reflection. Details: Build and tooling.
16. Code is ready for the current targetSdk
// BAD: content under the status bar when targeting SDK 35+, and onBackPressed is deprecated
// and no longer called when targeting SDK 36 with predictive back
setContent { LazyColumn { items(rows, key = { it.id }) { RowItem(it) } } }
override fun onBackPressed() { if (editing) cancelEdit() else super.onBackPressed() }
// GOOD
enableEdgeToEdge()
setContent {
Scaffold { innerPadding ->
LazyColumn(contentPadding = innerPadding) { items(rows, key = { it.id }) { RowItem(it) } }
}
BackHandler(enabled = editing) { cancelEdit() }
}
Foreground service types, large-screen orientation rules and more: Platform changes.
17. New logic ships with deterministic tests
// BAD: real dispatcher and sleep, slow and flaky
@Test fun loads() { vm.load(); Thread.sleep(1000); assertEquals(3, vm.state.value.items.size) }
// GOOD: virtual time, Turbine for the flow
@Test fun loads() = runTest {
coEvery { repo.items() } returns listOf(a, b, c)
val vm = ItemsViewModel(repo)
vm.state.test {
assertEquals(ItemsUiState.Loading, awaitItem())
assertEquals(3, (awaitItem() as ItemsUiState.Content).items.size)
}
}
Main dispatcher rule, fakes vs mocks and Compose tests: Testing.
Anti-patterns
!!"because it cannot be null here". If it truly cannot, the type should say so.GlobalScope,runBlockingon the main thread, or aCoroutineScope(...)field nobody cancels.runCatchingorcatch (e: Exception)around suspend calls without rethrowing cancellation.MutableStateFloworMutableListexposed from a ViewModel.- One-off events kept in state that the UI never acknowledges, so the last navigation or toast replays after
rotation. Clear the event once handled (see
mobile-mvvm). - ViewModels importing
android.view,Context(non-application), or Compose UI types. - Passing a ViewModel into leaf composables, or
hiltViewModel()called below the screen level. LaunchedEffect(Unit)reading a parameter that changes.derivedStateOfwrapping a value that changes as often as its input. It adds cost, not savings.- Lazy lists without
key, or keyed by index. - New kapt processors, versions hardcoded in module files,
kotlinOptions {}instead ofcompilerOptions {}. fallbackToDestructiveMigration()in release builds.- Secrets, tokens or API keys in
BuildConfig,strings.xmlor source. Escalate tomobile-security. - Logging device identifiers, emails or tokens with
Log.d. They are personal data. - Silently bumping SDK levels or adding manifest permissions without calling it out.
Checklist
- Project versions, SDK levels and conventions read before writing code
- No
!!; nullable inputs handled or failed withrequireNotNulland a message - UI state is an immutable data class or sealed interface;
whenis exhaustive withoutelse - Typed IDs are value classes where mix-ups are possible
- Every coroutine has an owner scope; no
GlobalScope - Dispatchers injected; blocking work wrapped in
withContextat the source -
CancellationExceptionrethrown in every catch around suspend code - Mutable flows private; updates use
update {} - Cold flows converted with
stateIn(..., WhileSubscribed(5_000), ...); UI usescollectAsStateWithLifecycle - Composables stateless below the screen;
modifierparameter first optional and applied to root - Composable parameters stable; immutable collections where lists are passed
- Effects keyed correctly;
rememberUpdatedStatefor long-lived lambdas; listeners inDisposableEffect - Lazy lists have stable
keyandcontentTypefor mixed rows - Hilt constructor injection;
@ApplicationContextonly; scopes match lifetimes - Room DAOs suspend or Flow; migrations and
exportSchema = true - KSP instead of kapt; new dependencies in
libs.versions.toml - R8 keep rules for reflection or serialization added and release build verified
- Edge-to-edge insets and predictive back handled for the current
targetSdk - Unit tests for new domain and data logic; tests and linters pass
Related skills
mobile-mvvm,mobile-mvi-- where ViewModels, state and events fit in the architecturemobile-design-patterns-- repository, use case, adapter and factory patterns in Kotlinmobile-code-review-- reviewing Kotlin changes against these rulesmobile-security-- storage, network, crypto and logging rules for the same codemobile-ci-cd-- running detekt, ktlint, tests and release builds in CImobile-java-- legacy Java code and Java-Kotlin interopmobile-kmp-- sharing Kotlin code with iOSmobile-ble-- Android Bluetooth Low Energy rulesmobile-testing-- cross-platform test strategy and screenshot testsmobile-performance-- Baseline Profiles, Macrobenchmark and Android vitals
