Coroutines or Compose? How to Aim an LLM at Kotlin So It Doesn't Leak ViewModels, Flows, or Your Patience

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.

Key distinction Coroutine/ViewModel prompts answer the question "where does the work happen and who owns it?" Compose prompts answer "what does the screen show, and what happens when the state changes?" Mixing them in one request makes the model invent the boundary for you.

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 viewModelScope and repeatOnLifecycle.

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, and withContext. 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) vs shareIn with SharingStarted.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 catch CancellationException; map IO exceptions to a sealed ApiError; 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. Use runTest, a StandardTestDispatcher, and advanceUntilIdle(). No Thread.sleep, no delay in assertions. Show the MainDispatcherRule if 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.

Where people go wrong Asking for "a test for this ViewModel" without naming the dispatcher gets you 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, remember for derived values, derivedStateOf for 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. Compare collectAsState() and collectAsStateWithLifecycle(). 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 @Preview composables for all states of this screen: loading, empty, error with retry, long-content. Use a fake UiState, no ViewModel in the preview, and multiple @Preview annotations with showBackground = true and different device values.

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 FlowRow or a Column fallback; avoid fixed height; use sp for text and dp for 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.colorScheme and MaterialTheme.typography. No Color(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: sealed UiEvent) and this stateless composable, write the stateful wrapper. Collect with collectAsStateWithLifecycle, 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 MutableState created inside a stateless composable? Is the ViewModel referenced in a @Preview? List violations with line references.

Non-obvious insight The seam between ViewModel and Compose is where nearly all LLM-generated Kotlin breaks. Asking the model to review only the boundary catches more real bugs than asking it to review the whole file.

15. Prompt for migration off LiveData

Task: convert without changing behavior.

Prompt:

Convert this LiveData-based ViewModel to StateFlow. Preserve initial values, keep distinctUntilChanged semantics, show the Compose side change. List any behavior that cannot be preserved exactly.

What to check before you paste

  • Does the answer use viewModelScope and not GlobalScope?
  • Is CancellationException rethrown?
  • 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.

← All posts

Ask AI about this article

Ask a question about this article — ASI Biont will answer based on its text.