Kotlin and Coroutines: The Senior Android Engineer's Working Vocabulary
In short
Kotlin idioms and kotlinx.coroutines are the spine of every modern Android app. This deep-skill covers the language features senior engineers use weekly (sealed classes, data classes, scope functions, extension functions), the structured concurrency model that prevents leaks, the Flow / StateFlow / SharedFlow trichotomy that drives reactive UI, exception handling with supervisorScope, Dispatcher selection, and the kotlinx-coroutines-test machinery that makes asynchronous code deterministic. Read this and you will recognize the patterns in any well-written Android codebase from 2023 onward.
Key takeaways
- Sealed classes + exhaustive `when` are how senior Android engineers model state honestly.
- Structured concurrency means every coroutine runs inside a scope; cancel the scope and the children die with it.
- Use `coroutineScope` for atomic work, `supervisorScope` when child failures must be isolated.
- Never catch `CancellationException` without rethrowing — you are breaking the cancellation contract.
- StateFlow holds the current UI state; SharedFlow delivers one-shot events; cold Flow is for caller-owned streams.
- Collect flows with `repeatOnLifecycle(STARTED)` to avoid background work and event-replay bugs.
- Inject dispatchers, test with `runTest` + `TestScope`, and step time with `advanceUntilIdle` and `advanceTimeBy`.
- Turbine makes Flow assertions readable; let `runTest` fail your test on coroutine leaks.
Kotlin idioms every senior Android writes weekly
The Kotlin idioms that separate a junior implementation from a senior one are rarely about clever syntax — they are about modeling state honestly. **Sealed classes** are the single most important tool in this category. They let you enumerate every legal state of a screen, network call, or domain event so the compiler refuses to let you forget a case. Pair a sealed class with a `when` expression (not a `when` statement) and the compiler enforces exhaustiveness — adding a new state forces every consumer to handle it. ```kotlin sealed interface UiState { data object Loading : UiState data class Success(val items: ListStructured concurrency: CoroutineScope, Job, supervisorScope
Structured concurrency is the rule that every coroutine must run inside a scope, every scope owns a `Job`, and a scope cannot finish until all of its children finish. Cancel the scope and every child is cancelled; let a child throw an unhandled exception and the scope (by default) cancels its siblings. This eliminates the leak class that plagued Android for a decade — orphan background work outliving the screen that started it. On Android, you almost never construct a `CoroutineScope` by hand. You use `viewModelScope` (cancelled when the ViewModel clears), `lifecycleScope` (cancelled with the LifecycleOwner), or `repeatOnLifecycle(STARTED) { ... }` for collecting flows only while the UI is visible. These scopes already carry sensible defaults: `Dispatchers.Main.immediate` plus a `SupervisorJob`, so a child failure does not nuke the ViewModel. ```kotlin class JobsViewModel(private val repo: JobsRepository) : ViewModel() { fun load() = viewModelScope.launch { // supervisorScope so a profile failure doesn't cancel jobs supervisorScope { val jobs = async { repo.fetchJobs() } val profile = async { runCatching { repo.fetchProfile() }.getOrNull() } _state.value = UiState.Success(jobs.await(), profile.await()) } } } ``` The distinction between `coroutineScope` and `supervisorScope` is the single most-tested concept in coroutines interviews. `coroutineScope` propagates child failures upward and cancels siblings. `supervisorScope` isolates each child — a failure in one `async` does not cancel its siblings, so partial results are recoverable. Use `supervisorScope` (or a `SupervisorJob`) when concurrent work items are independent (parallel API calls feeding one screen). Use `coroutineScope` when the work items form a single logical unit (a transaction that must succeed or fail atomically). Two more rules separate juniors from seniors. First, **never swallow `CancellationException`**. A blanket `try { ... } catch (e: Exception)` will trap the very signal that structured concurrency relies on. Always rethrow it: `catch (e: Exception) { if (e is CancellationException) throw e; ... }` or use `runCatching` (which preserves cancellation). Second, **install a `CoroutineExceptionHandler`** on top-level scopes only. It only catches uncaught exceptions in `launch` (not `async`, where the exception is deferred to `await()`), and it only applies to the root coroutine. Internalizing these two rules will save you from the majority of production-only crashes that escape unit tests. **Dispatcher selection** is the third axis of structured concurrency. `Dispatchers.Main` runs on Android's main thread and must own all UI work; `Dispatchers.Main.immediate` short-circuits when you are already on Main (avoids an extra post). `Dispatchers.IO` is a 64-thread pool tuned for blocking I/O (disk, network, Room), and it shares a backing pool with `Dispatchers.Default`, which is sized to CPU cores for compute-bound work (JSON parsing, image processing). The right pattern for a repository is to switch dispatchers at the boundary: `withContext(Dispatchers.IO) { ... }` around the actual blocking call, and let the caller stay on Main. Avoid creating bespoke dispatchers (`newSingleThreadContext`) without a clear reason — they leak threads if you forget to close them, and the built-in pools are already optimized for the cases that matter.Flow vs StateFlow vs SharedFlow — when each wins
`Flow` is a cold asynchronous stream — nothing happens until somebody calls `collect`. Each collector triggers a fresh execution of the upstream `flow { }` builder. Use cold `Flow` for one-shot work the caller owns: a database query (Room returns `Flow`), a network call mapped to a stream, a paginated list. Operators (`map`, `filter`, `flatMapLatest`, `combine`, `debounce`, `retry`) are pure and execute on the collector's coroutine context unless you interpose `flowOn(Dispatchers.IO)`. Backpressure is handled by suspension (the producer literally can't outrun the collector) plus the explicit `buffer`, `conflate`, and `collectLatest` operators. `StateFlow` is hot and conflated — it always has a current value, and new values overwrite stale ones. It is the canonical UI-state holder for a ViewModel. Late subscribers immediately receive the current state. `SharedFlow` is hot and configurable — it is the canonical event stream for fire-once side effects like 'navigate to detail' or 'show snackbar.' ```kotlin class JobsViewModel : ViewModel() { // StateFlow: always has a value, perfect for UI rendering private val _ui = MutableStateFlowTesting coroutines: TestScope, runTest, advanceUntilIdle
Testing coroutines without virtual time is misery. The `kotlinx-coroutines-test` library replaces the dispatcher with a virtual scheduler so that `delay(10_000)` completes in microseconds, work is deterministic, and you can step time forward by hand. The entry point is `runTest { }`, which constructs a `TestScope` backed by a `StandardTestDispatcher` (or `UnconfinedTestDispatcher` for eager execution). ```kotlin @Test fun `loads jobs and emits Success`() = runTest { val repo = FakeJobsRepository(jobs = listOf(Job("1", "Android"))) val vm = JobsViewModel(repo) val states = mutableListOfFrequently asked questions
- Common pitfall
- Using `GlobalScope.launch` in production code — it has no parent and leaks indefinitely.
- Common pitfall
- Catching `Exception` (or worse, `Throwable`) without rethrowing `CancellationException`.
- Common pitfall
- Emitting one-shot events from a `StateFlow` and watching them replay on rotation.
- Common pitfall
- Forgetting `flowOn(Dispatchers.IO)` and blocking the main thread inside a `flow { }` builder.
- Common pitfall
- Hardcoding `Dispatchers.IO` / `Dispatchers.Main` in classes you later need to unit test.
- Common pitfall
- Calling `.collect` directly in `lifecycleScope.launch` instead of inside `repeatOnLifecycle(STARTED)`.
- Common pitfall
- Treating `async` exceptions as if they propagate immediately — they surface only at `.await()`.
Sources
About the author. Blake Crosley founded ResumeGeni and writes about Android engineering, hiring technology, and ATS optimization. More writing at blakecrosley.com.