UI, domain and data layers
Arjun trades onions at Vashi. He opens Mandi Bhav, taps the "Onion" chip, and reads three cards: Lasalgaon ₹2,450, Pune ₹2,200, Nashik ₹2,300. What he wants is one line, "Lasalgaon pays most today", and it is a fair thing to ask: the app has the numbers. The interesting question is not how to work it out, which is one maxBy, but where that code should live, because the farmer on the detail screen wants the same answer. This lesson sorts Mandi Bhav's existing code into layers, works out whether the app has earned a domain layer, and adds its first piece of business logic in the place a second screen can reach it.
Three layers, and the direction of the arrows
Google's guide to app architecture recommends at least two layers, and an optional third between them:
| Layer | Its job | In Mandi Bhav | Knows about |
|---|---|---|---|
| UI | Show data on screen and turn taps into events | Composables, routes, ViewModels, navigation |
Domain and data |
| Domain (optional) | Business rules that more than one screen needs, or that are too complex for one ViewModel |
Nothing yet; this lesson adds the first rule | Data |
| Data | Own the app's data: where it comes from, how it changes | SamplePrices, PriceSource |
Nothing above it |
The last column is the rule that makes layers worth having: dependencies point one way, from the UI towards the data. The data layer never imports a composable or a ViewModel; the domain layer never imports the UI. That one-way rule is what will let module 8 put a database under the data layer without touching a screen, and what lets a rule in the domain layer run in a unit test with no Android at all.
The model classes, CommodityPrice and DayPrice, sit outside all three. They are plain data that every layer passes around, so they live in their own model package, which depends on nothing.
A layer here is a package, not a Gradle module. Large apps split layers into separate Gradle modules so the compiler enforces the arrows and builds run in parallel; Google's Now in Android sample does. For an app with one developer and a dozen files, that is a cost without a payoff. Packages and the discipline in this lesson are enough, and you will see below how to check the discipline in one command.
Sorting what is already there
Most of Mandi Bhav's code already sits in the right place, because module 5 moved logic out of composables and module 6 put data behind PriceSource. Going through it file by file is the quickest way to learn the boundaries:
| Code | Layer | Why |
|---|---|---|
PricesScreen, PriceCard, FilterControls |
UI | They draw |
PricesViewModel, PriceDetailViewModel |
UI | They hold one screen's state; module 5's state holders |
formatRupees, spokenRupees, priceCardDescription |
UI | How a number looks or sounds on this app's screens |
filterPrices, commoditiesIn |
UI | What this screen shows for this search; no other screen filters by a typed query |
priceWeek, describeChange |
UI | Arithmetic for one screen's display; only the detail screen needs it |
TopLevelNavigation, the navigation keys |
UI | Where the user is |
PriceSource, SamplePrices |
Data | Where prices come from |
CommodityPrice, DayPrice |
Model | Plain data every layer uses |
Two of those deserve a second look. filterPrices contains real logic, with rules about case and spaces, and it is tested. It still belongs to the UI, because it answers a question only the prices screen asks. priceWeek works out the change since yesterday, which sounds like business logic, but today only one screen shows it. Logic does not move to the domain layer because it is clever or important. It moves when more than one consumer needs the same rule, or when it is complex enough to deserve its own class and tests away from any ViewModel. Google's domain-layer guide gives the same two reasons, and calls the layer optional for exactly that reason.
The rule that earns a domain layer
"Which market pays the most for onion today" is wanted by two screens: the prices screen, once a commodity chip is chosen, and the detail screen, where a farmer looking at Pune onion wants to know whether to send the truck to Lasalgaon instead. That is a business rule shared by two ViewModels, which is the textbook case for the domain layer. Write it in one place and both screens give the same answer. Write it twice and, the first time someone fixes a bug in one copy, they disagree.
The rule needs pinning down before it is code, because "pays the most" has three details a trader would care about:
- Compare modal prices, the most common price traded, not the maximum. A maximum is often one exceptional lot.
- On a tie, prefer the higher minimum. It is the better floor for a farmer whose produce is not the best in the yard.
- Only the latest day counts. A market that has not reported today cannot win on last week's price.
Create domain/BestMarket.kt:
package com.riztechacademy.mandibhav.domain
import com.riztechacademy.mandibhav.model.CommodityPrice
/**
* The market paying the most for [commodity]: the highest modal price on the
* latest day that commodity was reported, and on a tie the higher minimum
* price, the better floor for a farmer. Null if no market reported it.
*
* Only the latest day counts, so a market that has not reported today
* cannot win on an older, higher price.
*/
fun bestMarketFor(prices: List<CommodityPrice>, commodity: String): CommodityPrice? {
val reported = prices.filter { it.commodity == commodity }
val latestDay = reported.maxOfOrNull { it.date } ?: return null
return reported
.filter { it.date == latestDay }
.maxWithOrNull(compareBy<CommodityPrice> { it.modalPrice }.thenBy { it.minPrice })
}
It is a top-level function, not a class. It needs nothing but the list it is given, so there is nothing to construct, inject or replace in a test. When it needs the repository in the next lesson, it gets a class around it; until then a class would be ceremony. compareBy { … }.thenBy { … } builds a comparator that compares modal prices and, only when they are equal, minimums; maxWithOrNull returns the largest by that comparator, or null for an empty list.
The trap: last week's price wins
My first version was the obvious one:
return prices
.filter { it.commodity == commodity }
.maxWithOrNull(compareBy<CommodityPrice> { it.modalPrice }.thenBy { it.minPrice })
It passed every check against the sample data, because all nine sample prices are from the same day. The test that caught it, in app/src/test/java/com/riztechacademy/mandibhav/domain/BestMarketTest.kt, is the case the sample data does not contain:
@Test
fun anOlderHigherPriceDoesNotWin() {
// Vashi reported onion at 2,900, but last week. It is not on offer today.
val lastWeekAtVashi = SamplePrices.lasalgaonOnion.copy(
id = "vashi-onion-2026-10-31",
market = "Vashi APMC, Navi Mumbai",
date = SamplePrices.lasalgaonOnion.date.minusDays(7),
modalPrice = 2_900,
)
val prices = SamplePrices.all + lastWeekAtVashi
assertEquals(SamplePrices.lasalgaonOnion, bestMarketFor(prices, "Onion"))
}
Against the first version, the test report in app/build/reports/tests/testDebugUnitTest/index.html says:
java.lang.AssertionError: expected:<CommodityPrice(id=lasalgaon-onion-2026-11-07, commodity=Onion, market=Lasalgaon APMC, date=2026-11-07, minPrice=1200, maxPrice=2851, modalPrice=2450)> but was:<CommodityPrice(id=vashi-onion-2026-10-31, commodity=Onion, market=Vashi APMC, Navi Mumbai, date=2026-10-31, minPrice=1200, maxPrice=2851, modalPrice=2900)>
On a real phone that is a farmer sending a truck to Vashi for a price that ended a week ago. Real APMC data has exactly this shape: small markets report irregularly, and the newest price from one market can be days older than another's. Module 9 brings real data, and this rule will already be right. A rule that money depends on gets tests for the cases your sample data does not have.
The same file has three more tests: the highest modal price wins, a tie goes to the higher minimum (Nashik's modal raised to Lasalgaon's ₹2,450 and placed first in the list, and Lasalgaon's minimum of ₹1,200 still beats Nashik's ₹1,100), and a commodity nobody reported, garlic, gives null. Each one is a sentence of the rule above, written so a machine can check it.
Both screens ask the same rule
In PricesViewModel, the best market follows the selected chip. It is a fourth flow beside the three from module 5:
/** The best market for the chosen commodity, or null when "All" is selected. */
val bestMarket: StateFlow<CommodityPrice?> =
selectedCommodity.map { commodity ->
commodity?.let { bestMarketFor(prices, it) }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = selectedCommodity.value?.let { bestMarketFor(prices, it) },
)
with the imports com.riztechacademy.mandibhav.domain.bestMarketFor and kotlinx.coroutines.flow.map. In PriceDetailViewModel, which reads its data once, it is one line:
/** The best market today for this price's commodity, which may be this one. */
val bestMarket: CommodityPrice? = detail?.let { bestMarketFor(source.todaysPrices(), it.price.commodity) }
The words on screen are UI work, so they go in ui/BestMarketText.kt, with a plain function the tests can check and a composable that uses it:
/**
* The best market for a commodity, in words: "Best today: Lasalgaon APMC,
* ₹2,450". If [here] is the best market itself, says so instead.
*/
fun describeBestMarket(best: CommodityPrice, here: CommodityPrice? = null): String =
if (here != null && here.id == best.id) {
"The best price for ${best.commodity.lowercase()} today"
} else {
"Best today: ${best.market}, ${formatRupees(best.modalPrice)}"
}
/** [describeBestMarket] as a line of text, read with spoken rupees by TalkBack. */
@Composable
fun BestMarketText(best: CommodityPrice, modifier: Modifier = Modifier, here: CommodityPrice? = null) {
val spoken = if (here != null && here.id == best.id) {
describeBestMarket(best, here)
} else {
"Best today: ${best.market}, ${spokenRupees(best.modalPrice)}"
}
Text(
text = describeBestMarket(best, here),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
modifier = modifier.semantics { contentDescription = spoken },
)
}
PricesScreen gains a bestMarket: CommodityPrice? parameter after selectedCommodity, and a full-width item above the cards when it is not null:
if (bestMarket != null) {
item(key = "best-market", contentType = "message", span = { GridItemSpan(maxLineSpan) }) {
BestMarketText(best = bestMarket)
}
}
PricesRoute collects the new flow and passes it on. PriceDetailScreen gains the same parameter and shows BestMarketText(best = best, here = price) under the change since yesterday, so on Lasalgaon onion it reads "The best price for onion today", and on Pune onion "Best today: Lasalgaon APMC, ₹2,450". The previews pass bestMarket = null, or a sample price for the filtered one. BestMarketTextTest has two tests, one for each sentence, and ./gradlew testDebugUnitTest runs 43.
Notice what that did to PricesScreen: eleven parameters now, and a route that collects four flows one by one. It works, and it is getting unwieldy. Lesson 4 replaces the lot with one object.
Checking the arrows
A layer rule nobody checks is a suggestion. One command lists everything the domain package imports:
grep -rh "^import" app/src/main/java/com/riztechacademy/mandibhav/domain
import com.riztechacademy.mandibhav.model.CommodityPrice
One line: the domain knows about the model and nothing else. No android., no androidx.compose, nothing from ui. If a later change adds import com.riztechacademy.mandibhav.ui.formatRupees to a domain file, this is how a reviewer spots it. The rupee formatting belongs to the screens; a domain rule that formats money has started doing the UI's job.
Check your work
Which way dependencies point. From the UI towards the data. The data layer knows nothing about the layers above it.
Why filterPrices stays in the UI. Only the prices screen asks that question, about a query the user typed.
When logic earns a place in the domain layer. When more than one consumer needs the same rule, or it is complex enough to need its own class and tests.
Why the best-market rule qualifies. Two screens, prices and detail, need the same answer, and it must be the same answer.
Why bestMarketFor is a function, not a class. It needs nothing but its arguments; a class would add construction with nothing to construct.
What the first version got wrong. It compared every price ever reported, so last week's ₹2,900 at Vashi beat today's ₹2,450 at Lasalgaon.
Why the sample data did not catch it. All nine sample prices are from the same day.
Practice 4. The tie test fails: expected:<CommodityPrice(id=lasalgaon-onion-2026-11-07, …, minPrice=1200, …)> but was:<CommodityPrice(id=nashik-onion-2026-11-07, …, minPrice=1100, …)>. With no tie-breaker, maxWithOrNull keeps the first of two equal elements, and the test deliberately puts Nashik first, so the farmer is sent to the market with the worse floor. Swap the two prices in the test's list and it passes again without the tie-breaker, which is the point: the answer would depend on the order of a list nobody chose. The tie-breaker makes it a decision, and putting the loser first in the test is what proves the decision is being made.
Practice
- Add
domain/BestMarket.kt,BestMarketTestwith all four tests,BestMarketTextand its test, and the twobestMarketvalues. Run the 43 tests, then the app: choose "Onion", then open Pune onion. - Write the naive version first and watch
anOlderHigherPriceDoesNotWinfail with the message above. Then fix it. - Run the
grepfrom the last section. Then add an import offormatRupeestoBestMarket.kt, run it again, and remove the import. - Break it on purpose: delete
.thenBy { it.minPrice }, predict which test fails and how, then run it. - Decide which layer each of these belongs in, and why: a function that converts quintals to kilograms for display; a rule that a price alert fires when the modal price crosses a threshold; the list of markets the farmer has marked as favourites; the colour of a price that went up.
- Harder: the detail screen will one day show "₹75 more at Lasalgaon than here". Decide whether that difference belongs in
bestMarketFor, in a new domain function, or in the UI, and argue it from the rule this lesson used. Then write it and its tests where you decided.
Official documentation
- UI layer — What belongs in the UI layer, including state holders and UI logic.
- Domain layer — When the layer is worth adding, use-case naming, and the warning against use cases that only forward calls.
- Data layer — Repositories and data sources, the layer the next lesson builds.
Next: one repository, and a single source of truth.
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