RizTech Academy logo
RizTech Academy
Android Best PracticesLesson 2 of 535 min

Where logic lives: composable, ViewModel or repository

Every piece of logic in an Android app has four places it could go: the composable that draws it, the ViewModel that owns the screen, a use case in the domain layer, or the repository that owns the data. Module 7 drew the layers. This lesson is about the daily decision underneath them, the one you make every time you write an if: which of the four should hold it. Put a rule in the wrong one and it usually still works, which is the problem. It works with the data you tested with and gives a wrong answer with the data a farmer has.

Four places, four jobs

Place Good at Cannot Tested with
Composable How things look; state that belongs to a UI element: scroll position, a snackbar, an animation, a timer for a "slow connection" note See any data it was not handed; outlive its screen; survive process death unless the state is saveable Compose tests, or not at all
ViewModel Turning data and the user's choices into one screen's state; reacting to events; keeping that state across rotation Outlive the screen's back-stack entry; be shared by a worker that has no screen Plain JVM tests with fakes
Use case A rule that more than one caller needs, written once Hold screen state Plain JVM tests
Repository Where data comes from and how it stays consistent: caching, the single source of truth, one refresh at a time Know which screen is asking JVM tests over an in-memory database

Two questions decide most cases. What does this logic need to see? A composable sees what it was handed and nothing else. Who else needs the answer? If a worker, a notification or a second screen needs it, it cannot live in one screen's code.

Mandi Bhav's decisions

Here is where fourteen modules put things, and why:

Logic Where Why there
formatRupees, priceCardDescription Plain functions in ui/ Display only; pure, so tested without Compose (RupeesTest, PriceDescriptionTest)
"Back to top" after three items PricesScreen, with derivedStateOf Depends on scroll position, which only the UI has
The "slow connection" note after 5 s NothingSaved, a saveable flag and an effect A UI element's own timer; the ViewModel need not know
Search and chip filtering filterPrices, called by PricesViewModel Needs every saved price and the saved query; must survive rotation
isFirstDownload, noResults Properties on PricesUiState Derived from state the screen already has; computed once, not in each composable that asks
The best market for a commodity bestMarketFor, behind FindBestMarketUseCase The Prices screen and the detail screen both ask
Refresh, then check the alerts RefreshPricesUseCase The Prices screen and the daily worker both refresh; the worker has no screen
A valid alert level AlertsViewModel.save() The form's rule, needing the form's state; one screen asks
One refresh at a time; retries RoomPriceRepository, retryingTransientFailures About the data and the network, whoever asks
Is a deep link one of ours? parseDeepLink in navigation/ Untrusted input, checked once, before any screen sees it

Notice the first row. "No logic in the UI layer" is not the rule. Formatting rupees with Indian grouping is real logic with five tests, and it lives in ui/ because only the UI needs it. It is a plain function rather than code inside a composable, so a test calls it directly. That is the escape hatch for display logic: write it as a pure function next to the composable, and call it from there.

The trap: the best market, worked out on the screen

The Prices screen shows "Best today: Lasalgaon APMC, ₹2,450" when a commodity chip is selected. The ViewModel gets that line from FindBestMarketUseCase, which reads every price in the repository. A developer adding the line without knowing that might reasonably think: the screen already has the prices, and the best one is a maxByOrNull away. In a scratch copy, inside PricesScreen's grid:

// Trap: the best market worked out here, from what the screen was given.
val best = uiState.selectedCommodity?.let { chosen ->
    uiState.prices.filter { it.commodity == chosen }.maxByOrNull { it.modalPrice }
}
best?.let { best ->
    item(key = "best-market", contentType = "message", span = { GridItemSpan(maxLineSpan) }) {
        BestMarketText(best = best)
    }
}

Tap "Onion" and it shows Lasalgaon, ₹2,450. Correct. Now do what Kavita does in module 5: type "pune" first. This test drives the real PricesRoute and PricesViewModel over the sample prices:

compose.onNode(hasSetTextAction()).performTextInput("pune")
compose.onNodeWithText("Onion").performClick()

compose.onNodeWithText("Best today: Lasalgaon APMC, ₹2,450").assertIsDisplayed()

It passed against the real code. Against the scratch copy:

java.lang.AssertionError: Assert failed: The component with Text + InputText + EditableText contains 'Best today: Lasalgaon APMC, ₹2,450' (ignoreCase: false) is not displayed!

and an assertion for the other market, run to see what the screen did say, passed:

compose.onNodeWithText("Best today: Pune (Gultekdi) APMC, ₹2,200").assertIsDisplayed()

The screen told a farmer searching for Pune that Pune is the best market for onion, ₹250 a quintal below Lasalgaon. On 40 quintals that is ₹10,000. Nothing crashed and nothing looked wrong.

The bug is not the maxByOrNull. It is where it ran. uiState.prices is the list to show: already filtered by the search, already reordered with the home market first. A composable can only work from what it is handed, and what it is handed has been shaped for display. The question "which market pays most?" needs every price, which the ViewModel and the repository have and the screen does not.

There are two more costs. The rule now lives in a composable, so the only way to test it is a Compose test like the one above, which needs Robolectric or a device. And the detail screen asks the same question, so it would need its own copy, which would drift from this one the first time somebody fixed a tie-break in only one of them. Module 7's bestMarketFor already handles a tie (the higher minimum price wins) and a market that has not reported today; the scratch version does neither.

Moving logic down is not free either

The opposite mistake is real too. Here is a decision in Mandi Bhav that could have gone the other way: the line "Updated today at 4:05 PM" is built by updatedLabel(lastFetched, now, zone, locale), a pure function, and UpdatedText calls it with Instant.now() while it draws. So the label is only as fresh as the last recomposition. Leave the app open on the Prices screen from 11.40 pm, untouched, and after midnight it still says "Updated today at 11:35 PM", which is now yesterday; leave it overnight and the "may be out of date" warning, due after 12 hours, never appears until something else redraws the line.

The alternative is a clock in the ViewModel, a flow that ticks every minute and feeds now into the state. It would keep the label exact. It also means a coroutine waking the phone every minute for as long as the screen is visible, a Clock to inject into another class, and tests that advance virtual time. For a label whose job is to say "this morning" or "yesterday", Mandi Bhav chose the composable. Writing the decision down, as the comment above UpdatedText does, is what turns it from an accident into a choice somebody can revisit.

Things that look like logic but must never move into a ViewModel, because they belong to one composition and the ViewModel outlives it: a Context (module 5's trap), a SnackbarHostState, a scope from rememberCoroutineScope(), a LazyGridState. The ViewModel says what to show; the composable owns the objects that show it.

Four questions

When you are about to write an if, ask in this order:

  1. Does it need data the screen was not given? Not in a composable.
  2. Does anything without a screen need the answer: a worker, a notification, a widget? Not in a ViewModel; a use case or the repository.
  3. Is it about where data comes from or how it stays consistent? The repository.
  4. Is it about how something looks? A pure function the composable calls, or the composable itself if it needs UI state such as scroll position.

If two places pass all four, prefer the one that is easier to test, which is almost never the composable.

Check your work

Why did the scratch version say Pune was the best market for onion? It worked from uiState.prices, which had already been filtered by the search "pune", so Lasalgaon was not in the list it searched.

Why does formatRupees live in the UI layer even though it is real logic with tests? Only the UI needs it, and as a pure function it is tested directly without Compose.

Why is the alert check a use case and not part of PricesViewModel? The daily worker refreshes too and has no ViewModel; the use case lets both run the same check.

What does reading Instant.now() in UpdatedText cost, and what would moving it cost? The label is only as fresh as the last recomposition. A ticking clock in the ViewModel would fix that at the price of a coroutine waking every minute, another injected Clock, and tests of virtual time.

Name two things a ViewModel must not hold because they belong to a composition. Any two of: a SnackbarHostState, a rememberCoroutineScope() scope, a LazyGridState, a Context from the composition.

Practice

  1. Put each of these in the four-places table and give the reason: the text of the snackbar after a refresh ("3 prices updated."); the order of the Markets list once the user's location is known; the 12-hour "may be out of date" threshold; whether to show the Privacy dialog.
  2. BestMarketText builds its spoken description itself, repeating the "is this the best market?" check that describeBestMarket already makes. Move the spoken text into a pure function beside describeBestMarket, add a test for it, and decide whether the change was worth its diff.
  3. Write the best-market test from this lesson in your copy and run it against the real code. Then plant the trap and watch it fail with the message above.
  4. Break it on purpose: move the alert-level check from AlertsViewModel.save() into the Save button's onClick in AlertsScreen. Which tests in AlertsViewModelTest still pass, and what does that tell you about the tests now? Put it back.
  5. Harder: the farmer wants "Updated 2 minutes ago" that counts up while the screen is open. Design it twice, once with the clock in the composable and once in the ViewModel. For each, write down what a test needs, what runs while the phone sits on the table with the screen on, and what happens on rotation. Choose one and justify it in two sentences.

Official documentation

  • State holders and UI state — Android's guide to what belongs in a ViewModel and what in a plain state holder or the composable.
  • Domain layer — When a use case earns its place, and the guide's own warning against wrapping every repository call in one.
  • Recommendations for Android architecture — The full list of Android's architecture recommendations, each marked strongly recommended or optional.
  • Where to hoist state — UI element state versus screen state, with the rules for each.

Next: defensive Android: process death, the main thread and configuration changes.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship