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

Naming across layers, from table to screen

A price in Mandi Bhav passes through five layers on its way to Kavita's screen: a JSON file on a server, a Retrofit class, a Room table, the app's own model, and a composable. Each layer is written at a different time, often by a different person, and each one is a chance to call the same thing by a new name. When that happens the reader has to translate at every boundary, and the translation is where bugs live. The Kotlin course's naming lesson taught naming inside one file: intent, scope, the noise words; this lesson is about one name surviving five layers, with Mandi Bhav's real names as the evidence, including three that were wrong and are fixed in this module's checkpoint.

One price, five layers

Follow the modal price, the number a farmer actually reads, from the server to the screen:

Layer File The name
JSON from the server api/v1/prices.json "modal_price"
Network class data/network/NetworkPrices.kt @SerialName("modal_price") val modalPrice: Long
Room table data/local/PriceEntity.kt column modalPrice in prices
The app's model model/CommodityPrice.kt val modalPrice: Long
The card ui/PriceCard.kt formatRupees(price.modalPrice)

One concept, one name, and one change of spelling: snake case to camel case, made once, at the edge of the app, by @SerialName. The server's style is the server's business. The moment the value is inside the app it is modalPrice, and a search for modalPrice finds every place it is read, from the parser to the card. Had the network class said modal, the entity price, and the model mostCommonPrice, that search would find a fifth of the truth.

The same holds for minPrice, maxPrice, commodity and market. The rule underneath it: the casing may change at a boundary; the word may not.

The suffixes say which layer you are in

Where one concept has a class in each layer, Mandi Bhav names the class after the concept and lets a prefix or suffix say which layer owns it:

Layer Pattern Mandi Bhav
Network Network… NetworkPrice, NetworkMarket, NetworkHistory
Database …Entity, …Dao PriceEntity, MarketEntity, PriceDao
Model the plain noun CommodityPrice, Market, PriceAlert
Data access …Repository interface, named by technology when implemented PriceRepository, RoomPriceRepository; UserPreferencesRepository, DataStoreUserPreferencesRepository
A rule more than one caller needs …UseCase FindBestMarketUseCase, RefreshPricesUseCase
Screen …UiState, …ViewModel, …Route, …Screen PricesUiState, PricesViewModel, PricesRoute, PricesScreen
Tests Fake… FakePriceRepository, FakeNetworkMonitor
Mapping between layers as…() asModel(), asEntity(), asLatestPrices()
DAO reads observe… returns a Flow; a plain name runs once observeAll(), observeLastFetched(); newestDate()

The value of the table is not the particular words; another team will choose others. It is that a reader who sees PriceEntity in a ViewModel knows, without opening the file, that a database class has escaped its layer. A naming convention turns an architecture rule into something you can see in an import list.

When the name should change: thumbnail and thumbnailUrl

There is one place where Mandi Bhav does change the word, on purpose:

Layer Name Value
JSON and NetworkPrice thumbnail "images/thumb/onion.jpg", a path relative to the API
PriceEntity, CommodityPrice thumbnailUrl "https://raw.githubusercontent.com/…/api/v1/images/thumb/onion.jpg"

NetworkPrice.asModel(baseUrl) resolves the path against the API's address, the only layer that knows it. After that line the value is a different thing, a full URL that Coil can load, and the name says so. Had the model kept thumbnail, someone would one day pass it to AsyncImage from a test with a relative path and wonder why nothing loads.

A name changes when the meaning changes, and only then.

The trap: one word, two meanings

Reading Mandi Bhav for this lesson turned up a word doing two jobs: updated.

  • refresh() returns how many prices changed, and the snackbar turns that into "3 prices updated." So in this code, updated means changed.
  • PriceRepository.lastUpdated(), and PricesUiState.lastUpdated above it, held the time of the last successful fetch, which moves whether anything changed or not.

The repository's own test shows the two meanings pulling apart:

@Test
fun aRefreshThatChangesNoPriceStillCountsNone() = runTest {
    // Every row gets a new fetchedAt; only a different price is a change.
    repository.refresh()
    val later = RoomPriceRepository(
        database.priceDao(), network, Clock.offset(clock, java.time.Duration.ofHours(1)), appScope,
    )

    assertEquals(0, later.refresh())
    assertEquals(fourOFive.plusSeconds(3_600), later.lastUpdated().first())
}

Zero prices updated, and lastUpdated moved by an hour. The code is correct; the name invites a wrong reading. Imagine Arjun asked to add "Onion last changed at 4:05 PM" to the detail screen. lastUpdated() is right there in the repository, it compiles, and it shows the time of the last refresh, including the ones where onion did not move.

Notice where the drift started. The data layer had it right all along: the column is fetchedAt, and the DAO's query is observeLastFetched(). The word changed at the repository, one layer up, and every layer above copied the repository. That is the usual pattern: a name drifts at one boundary and then spreads, because each layer names things after the layer below.

The checkpoint renames it back to the data layer's word:

/**
 * When prices were last fetched successfully; null if never. It moves on
 * every successful refresh, including one that changed no price.
 */
fun lastFetched(): Flow<Instant?>

and PricesUiState.lastFetched, the parameter of updatedLabel and the UpdatedText composable with it. What the farmer reads does not change: the line still says "Updated today at 4:05 PM", because that is the right word for a person. Words on the screen are chosen for the user; names in the code are chosen for the next developer. They do not have to match, and when they differ, the code's name should be the precise one.

A number called price

Everywhere in Mandi Bhav, price means a CommodityPrice: a commodity, a market, a date and three amounts. One class broke that:

data class AlertRow(val alert: PriceAlert, val todaysPrice: Long?)

todaysPrice is not a price in the app's sense; it is one number, the modal price, in rupees per quintal. AlertsUiState had a second todaysPrice: Long? beside PriceDetailUiState's bestMarket: CommodityPrice?, so the same word was a Long on one screen and an object on the next. The type system would catch you passing one for the other, but a reader skimming todaysPrice?.let { … } does not know which they are looking at. Both are now todaysModalPrice, which says which of the three amounts it is.

Two classes called Market

The third finding was not fixed. Mandi Bhav has two classes named Market: the model, model.Market, a market with its prices, and the navigation key, navigation.Market(name). A file that needs both does not compile. A scratch file with both imports:

e: …/ui/MarketLink.kt:3:43 Conflicting import: imported name 'Market' is ambiguous.
e: …/ui/MarketLink.kt:4:48 Conflicting import: imported name 'Market' is ambiguous.

and Kotlin's answer is an alias at the import:

import com.riztechacademy.mandibhav.model.Market
import com.riztechacademy.mandibhav.navigation.Market as MarketKey

which compiled. Why not rename the key? Because the key's name is part of another convention that is working: each key matches its destination's classes, PriceDetail with PriceDetailRoute and PriceDetailViewModel, Market with MarketRoute, MarketScreen and MarketViewModel. Renaming the key alone breaks that pairing; renaming all four is a large diff for a collision that has cost one fully qualified name in one test. That is a judgement, and the opposite one is defensible. What is not defensible is not noticing.

What a rename touches in an Android app

In plain Kotlin, Android Studio's Rename (Shift F6) updates every reference and you are done. Android adds places where a name is stored outside the code, and renaming those is a change of behaviour:

The name lives in Renaming it means Mandi Bhav's rule
A Room entity's field A new column name: a schema version and a migration (module 8) Rename above the database, not the column. fetchedAt stayed.
An @SerialName or a JSON key Old builds, still installed, read the old key Never: api/v1/ is fixed. Rename the Kotlin property only.
A SavedStateHandle key A process restored after the update finds nothing under the new key Harmless for a search query; check before renaming anything users typed
A @Serializable navigation key The saved back stack holds the class's serial name Saved state is discarded on an update (module 5), so this is safe between versions
The Baseline Profile Rules name methods; a renamed method's rule matches nothing until the profile is regenerated RoomPriceRepository.lastUpdated() was in the profile; it now names a method that does not exist, which costs one method's ahead-of-time compilation until the next regeneration
R8 and the mapping file Nothing: R8 renames everything anyway

The checkpoint's rename touched twelve files, built, passed all 206 unit tests and the 12 instrumented tests, and passed lint with only the existing target-SDK warning.

Check your work

What is the one change of spelling the modal price goes through, and where? Snake case to camel case, modal_price to modalPrice, made by @SerialName in the network class. The word stays the same in every layer.

Why is thumbnail becoming thumbnailUrl correct, when lastUpdated drifting from fetchedAt was not? The value changed meaning, from a relative path to a full URL, in the one function that resolves it. lastUpdated held exactly what fetchedAt held.

Which test showed the two meanings of "updated" disagreeing? aRefreshThatChangesNoPriceStillCountsNone: refresh() returned 0 and the timestamp still moved an hour.

Why does the screen still say "Updated today at 4:05 PM"? It is copy for a farmer, for whom "updated" is the natural word. The code name has to be precise for developers; the two need not match.

Why did the checkpoint rename the repository function but not the Room column? Renaming a column is a schema change that needs a version and a migration, for no gain: the column was already named correctly.

What does the rename leave stale, and what does it cost? One Baseline Profile rule naming lastUpdated(); that method loses ahead-of-time compilation until the profile is next regenerated.

Practice

  1. Make your own version of the five-layer table for commodity and for the market's name. Where does the market's name change from name to market, and is that a change of meaning or drift? (Look at MarketEntity and PriceEntity.)
  2. Find every …Repository interface in Mandi Bhav and its implementation. Does each implementation's name say which technology it uses? Write down any that does not.
  3. Search the code for the word price used for a Long. After this module there should be none outside minPrice, maxPrice and modalPrice; if you find one, rename it.
  4. Break it on purpose: in a scratch copy, rename PriceEntity.fetchedAt to lastFetchedAt with Shift F6. Build, then install over a copy that has saved prices. Read what Room says, and explain why the IDE's rename was not enough.
  5. Harder: write the argument for renaming the navigation key Market to MarketDetail, with every file it touches and what happens to a back stack saved before the change. Then decide, and write one line in the README's conventions section saying what you decided and why.

Official documentation

  • Kotlin style guide — Android's own naming rules for Kotlin, including backing properties and test names.
  • Define data using Room entities — How Room turns field names into column names, and @ColumnInfo when they must differ.
  • SerialName — The one annotation that lets a JSON key and a Kotlin name differ.
  • Coding conventions — Kotlin's naming rules, which the Kotlin course's naming lesson covered in depth.

Next: where logic lives: composable, ViewModel, use case or repository.

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