Most Kotlin developers I talk to hit the same wall: the LLM writes something that compiles, looks plausible, and then quietly leaks a coroutine scope, collects a Flow in the wrong lifecycle, or rebuilds a ViewModel on every recomposition. The code is not wrong in the way a syntax error is wrong — it is wrong in the way a race condition is wrong. You find out three days later.
The root cause is almost never the model. It is the prompt. A single request like "write a ViewModel with a network call and a list screen" forces the model to guess two entirely different things at once: asynchronous data logic and UI state rendering. Those follow different rules, fail in different ways, and deserve different prompts.
Below is a split-by-task prompt collection: one track for coroutines, Flow and ViewModel, another for Jetpack Compose layout and state. Pick the track by the question you are actually asking. Each prompt comes with a usage example and the failure mode it prevents.
Why one prompt for "everything" produces leaky code
When you ask for a screen end-to-end, the model optimizes for completeness. It will happily put viewModelScope.launch inside a composable, call collectAsState() on a cold Flow that re-subscribes on every recomposition, or create a MutableStateFlow in the Composable itself. None of that is a hallucination — it is a reasonable-sounding answer to an unreasonable question.
The practical rule: if the deliverable is a suspend function, a repository, a state holder, or a Flow operator chain — use the coroutine track. If the deliverable is a @Composable, a preview, a modifier chain, or a screen state mapping — use the Compose track. Then wire them together in a third, small prompt.
Track A — coroutines, Flow, ViewModel
1. The ownership prompt: who cancels what
Task: establish scope ownership before any code exists.
Prompt:
You are a Kotlin Android reviewer. I will describe a feature; do not write the UI. For each async operation, state: which CoroutineScope owns it, what cancels it, what happens on configuration change, and whether it survives process death. Feature: [description]. Output a table then a minimal skeleton with
viewModelScopeandrepeatOnLifecycle.
Example result: the model produces a table naming viewModelScope for the repository call, lifecycleScope + repeatOnLifecycle(STARTED) for collection, and explicitly flags that a GlobalScope in the draft would survive navigation — the exact leak you were about to ship.
2. Structured concurrency audit
Task: find orphan launches in existing code.
Prompt:
Here is a Kotlin file. List every
launch,async, andwithContext. For each, tell me which parent scope it belongs to, whether an exception in it cancels siblings, and whether it can outlive the caller. Do not rewrite yet — audit only.
Paste the file after the prompt. The audit format forces the model to reason about the job hierarchy instead of pattern-matching to a tutorial.
3. Flow operator selection with a trade-off
Task: choose between stateIn, shareIn, conflate, debounce.
Prompt:
I have a cold Flow from Room that emits on every DB change. I need it collected by two screens. Compare
stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), initial)vsshareInwithSharingStarted.Eagerly. Give the trade-off in a table: replay behavior, cancellation, memory, what breaks if the last collector leaves.
Example result: a table plus the non-obvious note that WhileSubscribed(5000) exists precisely to survive a configuration change without re-querying — and that Eagerly keeps the upstream hot even with zero collectors, which is a battery and memory cost you may not want.
4. Error handling that does not swallow CancellationException
Task: generate a runCatching-free error wrapper.
Prompt:
Write a
Result-returning repository function in Kotlin. Rules: never catchCancellationException; map IO exceptions to a sealedApiError; log only in the ViewModel layer, not the repository. Show the sealed class and one usage in a ViewModel.
This is one of the most common LLM mistakes: wrapping everything in try/catch (e: Exception), which eats cancellation and makes coroutines unkillable.
5. Testing coroutines without flakiness
Task: produce a deterministic test.
Prompt:
Write a JUnit test for this ViewModel using
kotlinx-coroutines-test. UserunTest, aStandardTestDispatcher, andadvanceUntilIdle(). NoThread.sleep, nodelayin assertions. Show theMainDispatcherRuleif needed.
Example result: a test class where the dispatcher is injected via a rule, so the assertion runs after virtual time advances — the difference between a green suite and a CI that fails one run in twenty.
runBlocking and real delays. Always pin the test dispatcher in the prompt.
6. State modeling before code
Task: design the UI state as a sealed hierarchy.
Prompt:
Model screen state for [feature] as a single immutable data class with nullable fields, not a sealed class of Loading/Success/Error. Explain why one state object beats multiple booleans for Compose consumption, and list the invariant each field must satisfy.
Multiple booleans (isLoading, isError, data) allow impossible combinations; a single state object makes them unrepresentable. The model will usually agree — but only if you ask.
Track B — Jetpack Compose layout and screen state
7. The recomposition-cost prompt
Task: get a layout that does not recompose the world.
Prompt:
Write a Compose screen with a header, a LazyColumn of items, and a bottom bar. Constraints: stable keys on list items, no lambda allocation inside
items {}that captures changing state,rememberfor derived values,derivedStateOffor a computed scroll flag. Annotate each line that affects recomposition scope.
Example result: items(items, key = { it.id }) plus a comment explaining that a missing key makes Compose reuse slots incorrectly on reorder — a bug you see as "wrong item animates" rather than a crash.
8. State hoisting decision
Task: decide where state lives.
Prompt:
For this composable tree, tell me for each piece of state whether it should be local (
remember), hoisted to the screen, or moved to the ViewModel. Criteria: survives config change, shared across siblings, needs async source. Output a table.
9. The collectAsStateWithLifecycle prompt
Task: correct Flow consumption in UI.
Prompt:
Show the correct way to collect a
StateFlow<UiState>in Compose so collection pauses when the app is in the background. ComparecollectAsState()andcollectAsStateWithLifecycle(). State which dependency each requires.
Example result: the answer names androidx.lifecycle:lifecycle-runtime-compose for the lifecycle-aware variant and explains that plain collectAsState keeps collecting behind a locked screen.
10. Preview data that matches production
Task: build @Preview without touching the network.
Prompt:
Generate
@Previewcomposables for all states of this screen: loading, empty, error with retry, long-content. Use a fake UiState, no ViewModel in the preview, and multiple@Previewannotations withshowBackground = trueand differentdevicevalues.
11. Layout that survives font scaling
Task: accessibility-safe layout.
Prompt:
Rewrite this Row-based card so it does not clip at 200% font scale. Prefer
FlowRowor a Column fallback; avoid fixedheight; usespfor text anddpfor spacing. Explain each change in one line.
12. Theming prompt without hardcoded colors
Task: map a design to MaterialTheme.
Prompt:
Convert this design spec into Compose using only
MaterialTheme.colorSchemeandMaterialTheme.typography. NoColor(0xFF...)inside composables. List which token each visual element maps to, and flag any color the default scheme cannot express.
Wiring the two tracks together
13. The integration prompt
Task: connect ViewModel to screen with an explicit contract.
Prompt:
Given this ViewModel (state:
StateFlow<UiState>, events: sealedUiEvent) and this stateless composable, write the stateful wrapper. Collect withcollectAsStateWithLifecycle, pass events up, keep the inner composable previewable. Show both files.
Example result: two files — a Screen that owns the ViewModel and a ScreenContent(state, onEvent) that is pure and previewable. This split is the single highest-value pattern the model will produce when asked explicitly.
| Track | Deliverable | Failure it prevents |
|---|---|---|
| Coroutines/VM | scope, Flow, sealed errors | leaks, swallowed cancellation |
| Compose | composables, state mapping | recomposition storms, clipped layouts |
| Integration | stateful wrapper | untestable, unpreviewable screens |
14. The review prompt for the seam
Task: audit the boundary, not the code.
Prompt:
Review these two files only for boundary violations: is any suspending call made from a composable? Is any
MutableStatecreated inside a stateless composable? Is the ViewModel referenced in a@Preview? List violations with line references.
15. Prompt for migration off LiveData
Task: convert without changing behavior.
Prompt:
Convert this
LiveData-based ViewModel toStateFlow. Preserve initial values, keepdistinctUntilChangedsemantics, show the Compose side change. List any behavior that cannot be preserved exactly.
What to check before you paste
- Does the answer use
viewModelScopeand notGlobalScope? - Is
CancellationExceptionrethrown? - Are list items keyed?
- Is Flow collected with the lifecycle-aware variant?
- Is the inner composable free of ViewModel references?
If you want to go deeper on how these prompts behave across tooling, the piece on Advances in Vibe Coding covers where model-generated code tends to drift, and the TypeScript workflow roundup shows the same split-by-task principle applied to a different stack.
The takeaway is simple: stop asking one prompt to be an architect, a coroutine expert, and a designer. Split by question, name the constraints, and demand the trade-off. The model will not leak your ViewModel if you never let it guess who owns it.