RizTech Academy logo
RizTech Academy
ArchitectureLesson 3 of 635 min

Repositories, and a single source of truth

At 4 pm the markets post their closing prices. Lasalgaon onion closes at ₹2,525, up ₹75 on the morning; Nashik closes at ₹2,560 and overtakes it. Mandi Bhav is about to get a "Refresh" button that brings those prices in, and the moment it does, lesson 1's seven copies of the data stop agreeing with each other. This lesson builds the refresh the wrong way first so you can see the bug on the emulator, then replaces PriceSource with a repository: one object, shared by every screen, that is the only place prices come from and the only place they change. Along the way the Markets tab gets the ViewModels module 6 promised, and the tests get a fake.

What a repository is

A repository is the data layer's front door. Google's data-layer guide lists its jobs: expose data to the rest of the app, centralise changes to it, resolve conflicts between sources, hide where the data comes from, and hold the business logic about the data. In Mandi Bhav that means one class that answers "what are today's prices", "what is this price", "what happened this week", and "fetch the latest", whether the answer comes from memory, a database or a server.

The repository is also the single source of truth: the one owner of the prices. Everything else asks it, and only it can change them. The guide's naming convention is the type of data followed by Repository, so this one is PriceRepository.

Start with the questions, as an interface, in data/PriceRepository.kt:

package com.riztechacademy.mandibhav.data

import com.riztechacademy.mandibhav.model.CommodityPrice
import com.riztechacademy.mandibhav.model.DayPrice

/**
 * The one place the app gets prices from. Screens ask it questions; they
 * never know whether the answer came from memory, a database or the network.
 */
interface PriceRepository {

    /** Today's prices, every commodity at every market. */
    fun todaysPrices(): List<CommodityPrice>

    /** One price by its id, or null if there is no such price (any more). */
    fun price(id: String): CommodityPrice?

    /** Today's prices at one market. */
    fun pricesAt(market: String): List<CommodityPrice>

    /**
     * The last seven days of modal prices for the price with [id], oldest
     * first and ending on that price's date; empty if there is no history.
     */
    fun week(id: String): List<DayPrice>

    /** Fetches the latest prices. Returns how many prices changed. */
    suspend fun refresh(): Int
}

The first four are the questions PriceSource already answered, with the same names, as module 6 promised: the screens keep asking the same questions. refresh is new, and it is suspend because fetching takes time. Lesson 6 changes the first four to return flows; for now they return the answer as it is when asked.

Why an interface, when there is only one implementation? Because there will be three. This module's is sample data in memory, module 8's reads a Room database, and module 9's adds a server behind it. Each replaces the class behind the interface and nothing else. The interface is also what lets a test hand a ViewModel exactly the prices a case needs, which you will use at the end of the lesson. An interface with one implementation and no prospect of a second is ceremony; this one has both reasons.

The implementation, with fake data

The 4 pm prices go in SamplePrices, after all:

/**
 * The 4 pm closing prices: what a refresh brings in. Three prices change
 * under the same ids, and Nashik overtakes Lasalgaon as the best market
 * for onion.
 */
val closingPrices = listOf(
    lasalgaonOnion.copy(maxPrice = 2_900, modalPrice = 2_525),
    nashikOnion.copy(modalPrice = 2_560),
    puneTomato.copy(modalPrice = 1_450),
)

and data/SamplePriceRepository.kt holds the data and answers from it:

/**
 * The prices from [SamplePrices], held in memory. The data is fake; the
 * contract is the real one, so a database (module 8) and the network
 * (module 9) can replace this class without any screen changing.
 *
 * One instance is shared by the whole app: it is the single source of truth,
 * and a refresh is seen by every screen that asks afterwards.
 */
class SamplePriceRepository(
    initialPrices: List<CommodityPrice> = SamplePrices.all,
    private val weekOfModalPrices: Map<String, List<Long>> = SamplePrices.weekOfModalPrices,
    private val closingPrices: List<CommodityPrice> = SamplePrices.closingPrices,
    private val networkDelayMillis: Long = 1_000,
) : PriceRepository {

    private var prices: List<CommodityPrice> = initialPrices

    override fun todaysPrices(): List<CommodityPrice> = prices

    override fun price(id: String): CommodityPrice? = prices.firstOrNull { it.id == id }

    override fun pricesAt(market: String): List<CommodityPrice> = prices.filter { it.market == market }

    override fun week(id: String): List<DayPrice> {
        val price = price(id) ?: return emptyList()
        val history = weekOfModalPrices[id] ?: return emptyList()
        // The history ends on the day of the price, so its last value is
        // today's modal price, whatever a refresh has set it to.
        val modals = history.dropLast(1) + price.modalPrice
        return modals.mapIndexed { index, modal ->
            DayPrice(
                date = price.date.minusDays((modals.size - 1 - index).toLong()),
                modalPrice = modal,
            )
        }
    }

    /**
     * Applies the markets' closing prices, after a pause that stands in for
     * the network until module 9. A second refresh finds nothing new.
     */
    override suspend fun refresh(): Int {
        delay(networkDelayMillis)
        val closing = closingPrices.associateBy { it.id }
        val updated = prices.map { closing[it.id] ?: it }
        val changed = updated.indices.count { updated[it] != prices[it] }
        prices = updated
        return changed
    }
}

The imports are the two model classes and kotlinx.coroutines.delay. A few things are deliberate:

  • The data is fake, and says so. The one-second delay stands in for a network call so the app behaves like it will in module 9: a refresh takes time. Fake data behind a real contract is honest; fake data that pretends to be real is not.
  • prices is private and is a var. Only this class can change it, which is what "single source of truth" means in code. Everything outside sees List<CommodityPrice>, which is read-only.
  • The week ends on today's modal price. Before, the last day of history was typed by hand to match. After a refresh it must follow the new price, or the detail screen would say ₹2,525 at the top and ₹2,450 for today in the week.
  • Constructor parameters with defaults keep the app's version one call, SamplePriceRepository(), and let a test pass other data.

Delete data/PriceSource.kt and its test. PriceSourceTest becomes SamplePriceRepositoryTest with the same five tests and two new ones: a refresh changes three prices and a second changes none, and after a refresh the week ends on ₹2,525 with ₹2,420 the day before. runTest skips the delay, so they run in milliseconds.

The trap: a repository each

The obvious next step is to give each ViewModel a SamplePriceRepository, exactly as each had a PriceSource. In a scratch copy, PricesViewModel takes a repository and gains a refresh:

/** Asks the repository for the latest prices, then reads them again. */
fun refresh() {
    viewModelScope.launch {
        repository.refresh()
        prices.value = repository.todaysPrices()
    }
}

and each route builds its own:

viewModel: PricesViewModel = viewModel {
    // The trap: a repository of its own.
    val repository = SamplePriceRepository()
    PricesViewModel(createSavedStateHandle(), repository, FindBestMarketUseCase(repository))
},

with the same pattern in PriceDetailRoute. (FindBestMarketUseCase arrives below.) It compiles and runs. Choose "Onion", tap "Refresh", wait a second, and the prices tab is right: "Best today: Nashik APMC, ₹2,560", Lasalgaon ₹2,525, Nashik ₹2,560. Now tap the Nashik card. From adb shell uiautomator dump on the detail screen:

'Nashik APMC'
'₹2,300 per quintal'
'₹20 up on yesterday'
'Best today: Lasalgaon APMC, ₹2,450'

One tap apart, the app gives two prices for the same onion at the same market and two answers to "where should I sell". The detail screen's repository never heard about the refresh, because it is a different object. Nothing crashed, no test failed, and the app told a farmer the wrong market. That is what "single source of truth" protects you from, and it is why the rule is about the number of owners, not the number of classes.

One repository, made once

So the app needs exactly one SamplePriceRepository, made before any screen asks and kept for as long as the process lives. Something must create it, and something must hand it to every ViewModel. The object that lives exactly as long as the process is the Application, and Android creates one before anything else. Give Mandi Bhav its own, with a container that holds what the whole app shares, in AppContainer.kt in the root package:

/**
 * The objects the whole app shares, each made once. Every ViewModel gets its
 * repository from here, so there is one repository and one truth.
 */
class AppContainer {
    val priceRepository: PriceRepository = SamplePriceRepository()
    val findBestMarket = FindBestMarketUseCase(priceRepository)
}

/** Lives as long as the process, so the container does too. */
class MandiBhavApplication : Application() {
    val container = AppContainer()
}

/** The app's container, for a route that creates a ViewModel. */
@Composable
fun appContainer(): AppContainer =
    (LocalContext.current.applicationContext as MandiBhavApplication).container

and tell Android to use it, in AndroidManifest.xml, on the <application> element:

android:name=".MandiBhavApplication"

This is dependency injection by hand: the ViewModels do not create what they need, they are given it. Google calls the container pattern manual dependency injection, and it is worth doing once by hand, because lesson 5 replaces it with Hilt and you will know exactly what Hilt is doing for you.

appContainer() reads the application from the composition's Context. The application Context is the one module 5 said a ViewModel may hold, because it lives as long as the process; nothing here holds an activity.

The use case from lesson 2 now needs the repository, so it becomes a class, in domain/FindBestMarketUseCase.kt:

/**
 * The best market today for a commodity, from the repository's prices.
 * Two screens ask this question; the rule lives once, in [bestMarketFor].
 */
class FindBestMarketUseCase(private val repository: PriceRepository) {
    operator fun invoke(commodity: String): CommodityPrice? =
        bestMarketFor(repository.todaysPrices(), commodity)
}

operator fun invoke lets a caller write findBestMarket("Onion"), as if the object were a function. The name follows Google's convention for use cases, a verb, a noun and UseCase. It holds no state of its own, so the container could make a new one per ViewModel; one shared instance is simpler and equally correct.

The ViewModels get the repository

PricesViewModel takes three constructor parameters now:

class PricesViewModel(
    private val savedStateHandle: SavedStateHandle,
    private val repository: PriceRepository,
    private val findBestMarket: FindBestMarketUseCase,
) : ViewModel() {

    /** The repository's prices, read again after every refresh. */
    private val prices = MutableStateFlow(repository.todaysPrices())

visiblePrices combines prices with the query and the chip, so a refresh refilters; bestMarket combines prices and the chip and calls findBestMarket(it); and refresh() is the function shown in the trap. The ViewModel is typed against PriceRepository, the interface, never SamplePriceRepository.

The route builds it with the overload of viewModel that takes an initialiser, from module 6, and createSavedStateHandle(), which gives the initialiser the same SavedStateHandle the default factory used to provide:

viewModel: PricesViewModel = appContainer().let { container ->
    viewModel {
        PricesViewModel(
            savedStateHandle = createSavedStateHandle(),
            repository = container.priceRepository,
            findBestMarket = container.findBestMarket,
        )
    }
},

Its imports are androidx.lifecycle.createSavedStateHandle and com.riztechacademy.mandibhav.appContainer. PricesScreen gains an onRefresh: () -> Unit parameter and a "Refresh" TextButton in the app bar's actions. A button whose label says what it does needs no icon, the same reasoning as module 5's "Back to top".

PriceDetailViewModel takes (priceId, repository, findBestMarket), reads repository.price(priceId) and repository.week(priceId) where it read the source, and asks findBestMarket(it.price.commodity) for its best market. Its route passes container.priceRepository and container.findBestMarket.

The Markets tab gets ViewModels

Module 6's MarketsRoute read data inside a composable, and lesson 1 measured the cost: a new PriceSource on every rotation. Each Markets screen now gets a ViewModel, in ui/MarketsViewModel.kt:

/** Holds the markets list: every market that reported today. */
class MarketsViewModel(repository: PriceRepository) : ViewModel() {
    val markets: List<MarketSummary> = marketSummaries(repository.todaysPrices())
}

/** Holds one market's prices today, for the market named in the navigation key. */
class MarketViewModel(market: String, repository: PriceRepository) : ViewModel() {
    val prices: List<CommodityPrice> = repository.pricesAt(market)
}

and the routes ask for them the same way:

@Composable
fun MarketsRoute(
    onMarketClick: (market: String) -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MarketsViewModel = appContainer().let { container ->
        viewModel { MarketsViewModel(container.priceRepository) }
    },
) {
    MarketsScreen(markets = viewModel.markets, onMarketClick = onMarketClick, modifier = modifier)
}

MarketRoute builds MarketViewModel(market, container.priceRepository) and passes viewModel.prices to MarketScreen. The previews use SamplePriceRepository() directly; they draw sample data and never refresh, so a throwaway instance is right there. The remember import goes.

Run the same steps as the trap: "Onion", "Refresh", then the Nashik card. The detail screen now says ₹2,560, "₹280 up on yesterday" and "The best price for onion today". One repository, one truth.

Testing with a fake

The ViewModel tests need a repository whose prices the test controls. Google's testing guide describes the choices, and recommends fakes: a working implementation, too simple for production, that lives in the test source set. app/src/test/java/com/riztechacademy/mandibhav/data/FakePriceRepository.kt:

/**
 * A [PriceRepository] for tests: it holds whatever prices a test gives it,
 * and a refresh swaps in [pricesAfterRefresh]. No delay, no sample history
 * unless a test asks for it, and it counts its refreshes.
 */
class FakePriceRepository(
    var prices: List<CommodityPrice> = SamplePrices.all,
    var weeks: Map<String, List<DayPrice>> = emptyMap(),
    var pricesAfterRefresh: List<CommodityPrice> = prices,
) : PriceRepository {

    var refreshCount = 0
        private set

    override fun todaysPrices(): List<CommodityPrice> = prices

    override fun price(id: String): CommodityPrice? = prices.firstOrNull { it.id == id }

    override fun pricesAt(market: String): List<CommodityPrice> = prices.filter { it.market == market }

    override fun week(id: String): List<DayPrice> = weeks[id].orEmpty()

    override suspend fun refresh(): Int {
        refreshCount++
        val changed = pricesAfterRefresh.count { it !in prices }
        prices = pricesAfterRefresh
        return changed
    }
}

A test reads like the situation it checks:

@Test
fun readsTodaysValueFromTheRepositoryNotFromWhoeverOpenedIt() {
    // The 4 pm update: the repository now has a corrected price under the
    // same id. A screen that was handed the id shows the new value.
    val corrected = SamplePrices.lasalgaonOnion.copy(modalPrice = 2_525)
    val viewModel = viewModelFor(corrected.id, FakePriceRepository(prices = listOf(corrected)))

    assertEquals(2_525L, viewModel.detail!!.price.modalPrice)
}

where viewModelFor(id, repository) is a two-line helper in the test class that builds PriceDetailViewModel(id, repository, FindBestMarketUseCase(repository)).

Why a fake and not a mock. A mocking library such as Mockito or MockK would let you write "when price("lasalgaon-onion-…") is called, return this", and check afterwards that it was called once. That tests how the ViewModel talks to the repository, so it breaks when you change how, even if the screen shows the same thing. The fake tests what the ViewModel shows for given data, which is the behaviour a farmer sees. It is also plain Kotlin: no library, no special syntax, and when the interface gains a function the compiler tells you to add it to the fake. What it costs: you maintain it, and a fake that drifts from the real class's behaviour gives tests that pass against something the app never does. That is why SamplePriceRepository has its own tests too. A mock earns its place when the thing to check is the interaction and nothing observable follows from it, such as "an analytics event was sent exactly once". Mandi Bhav has no such case yet.

The checkpoint's tests: PricesViewModelTest builds its ViewModel with a fake and gains a refresh test (Nashik at ₹2,560 after a refresh, and the best market follows); PriceDetailViewModelTest has four tests, including the best market; MarketsViewModelTest has two. ./gradlew testDebugUnitTest runs 49.

What one repository does not fix yet

Try this on the emulator: open Markets, Lasalgaon APMC, Lasalgaon onion. Switch to the Prices tab, choose "Onion" and tap "Refresh". Switch back to Markets. The detail screen, still open on that tab:

'₹2,450 per quintal'
'The best price for onion today'

while the Prices tab says Lasalgaon ₹2,525 and Nashik best at ₹2,560. There is one repository now, and it has the new prices, but this screen read them once, when it opened, and nobody told it they changed. A screen that asks again sees the truth; a screen that is already open does not. Lesson 6 fixes that by making the repository's answers flows the screens keep watching.

There is a second gap, and it is the next module's: the repository lives in memory, so when Android kills the process, the 4 pm prices go with it, and a reopened app shows the morning's. Module 8 puts them on disk.

Check your work

What makes a repository the single source of truth. It is the only owner of the data: everything else asks it, and only it can change it.

What the trap's detail screen showed, and why. ₹2,300 and "Best today: Lasalgaon APMC, ₹2,450" while the list said ₹2,560 and Nashik: its repository was a different object that never saw the refresh.

Why the repository lives in the Application. The Application lives exactly as long as the process, so one repository is made once and shared by every screen.

What manual dependency injection means here. The ViewModels are given their repository by the container instead of creating it.

Why PriceRepository is an interface. Three implementations are coming, and tests need their own.

Why a fake rather than a mock. It tests what the ViewModel shows for given data, not how it calls the repository, and needs no library.

What still goes wrong. A screen that is already open keeps the values it read when it opened.

Practice 4. The app crashes as soon as the prices screen asks for its ViewModel: java.lang.ClassCastException: android.app.Application cannot be cast to com.riztechacademy.mandibhav.MandiBhavApplication, thrown from appContainer(). Without android:name, Android creates a plain Application, and the cast fails. The class exists and compiles; only the manifest connects it to the app.

Practice

  1. Add PriceRepository, SamplePriceRepository, the closing prices, AppContainer with MandiBhavApplication, the manifest line, FindBestMarketUseCase, the two Markets ViewModels and the new routes. Delete PriceSource, and change PriceWeekTest's PriceSource() to SamplePriceRepository(). Run the 49 tests.
  2. Build the trap first, with a repository per ViewModel, and reproduce the two answers for Nashik onion. Then fix it with the container.
  3. Run the stale-screen steps from the last section and confirm the open detail screen keeps ₹2,450. Then press Back and open it again, and explain why it now shows ₹2,525.
  4. Break it on purpose: remove android:name from the manifest, predict what happens, and run it.
  5. Write a test that refresh() in PricesViewModel calls the repository exactly once, using the fake's refreshCount. Then decide whether that test is checking behaviour a user sees, or implementation, and whether you would keep it.
  6. Harder: a farmer reports that after a refresh the Markets list still says "2 prices today" for a market that now has three. Using only FakePriceRepository and MarketsViewModel, write a test that reproduces it, and explain why the fix belongs in lesson 6 rather than here.

Official documentation

  • Data layer — Repositories, the single source of truth, naming conventions, and scoping a repository to the application.
  • Manual dependency injection — The container pattern used here, step by step, and where it starts to hurt.
  • Use test doubles in Android — Fakes, mocks and stubs defined, and why fakes are preferred.

Next: one object for everything a screen shows, and messages that are shown exactly once.

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