---
title: 'Architecture Components and the Data Layer'
source: 'https://academia.sh/en/courses/architecture-components-and-data-layer'
language: en
updated: '2026-10-21T05:02:36+00:00'
license: 'CC BY-SA 4.0'
lessons: 14
outcomes:
    - 'Builds a layered application architecture'
    - 'Manages asynchronous data flow with coroutines and flows'
    - 'Combines local and remote data sources in the repository layer'
    - "Distinguishes whether a read's answer comes from memory, disk or the network, or falls back to a default value"
---

# Architecture Components and the Data Layer

An app's screen asks the same question of several places, and the answer does not always come from the same one: the profile name comes from the view model's memory, the theme setting from the key–value store on the device, the stock count from the network; when none of them answers, the first value the code wrote shows up. The course measures every mechanism with a single question — where does the answer to this read come from? The layer model, the view model, state holders, observable state, unidirectional data flow, dependency injection, the repository pattern, the local database, key–value storage, file storage, the network client, caching, paged loading and background work all pass through the same frame.

## Application Architecture

UI, domain and data layers, a view model that survives configuration changes, state holders, flow-based observable state, unidirectional data flow and dependency injection.

### The Layer Model

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/layer-model

The layer model splits an app into UI, domain and data layers, and dependencies point only inward. The lesson measures whether the answer to a read comes from memory, disk, the network or a default.

The previous course measured which direction data travels in a UI written with Compose. That course's measuring axis does not continue here. Data now moves between the layers of the app, not through the UI tree, and we ask a single question for every read: **where does the answer to this read come from?** The answer has four origins: memory, disk, network and default.

#### The Origin of a Read

In the data layer, a read tries the tiers in order for a key: memory first, then disk, then the network. If none of them knows the key, the read uses its own default. Each link of the chain is called a tier. The core below classifies which tier gave the answer to a read; an empty string is also an answer, so even if a tier knows the key with an empty string, the answer comes from that tier.

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

`Tier` is one tier and accepts only three names. `Read` walks the chain from start to end and takes the first answer. `countOrigins` counts the four origins. The rule looks only at the order: it does not see whether a value is fresh or stale.

The chain is ordered because of cost, not correctness. Memory is the fastest and shortest-lived tier; disk survives process death; the network is the farthest away. A read moves from the cheap tier to the expensive one and stops at the first answer. This order buys speed, but if a cheap tier has gone stale, the stale value becomes the answer. We will measure this twice in this lesson.

#### Three Layers

The layer model splits an app by responsibility into at least two parts, and in this lesson into three. The UI layer draws the screen and receives the user's events. The domain layer is optional and sits between the other two; it carries the business rules, and converting raw data into the form the screen wants happens here. In an app without a domain layer, the UI connects directly to the data layer. The data layer knows where data is read from and where it is written: memory, disk and the network are visible only in this layer.

Dependencies point only inward: the UI depends on the domain, and the domain depends on data; the data layer does not know the UI. On the platform this skeleton looks like this.

```kotlin
// Platform code: CartScreen
import androidx.lifecycle.ViewModel

class CartViewModel(private val getCart: GetCartUseCase) : ViewModel() {
    val items = getCart()
}

class GetCartUseCase(private val repository: CartRepository) {
    operator fun invoke() = repository.items
}

class CartRepository(private val local: CartLocalSource, private val remote: CartRemoteSource) {
    val items = local.items
}
```

The UI side knows only `CartViewModel`; it does not see `CartRemoteSource`. This piece is platform code, and here we make no claim about output, timing or file paths. The model builds this rule of the platform; the model is not the platform itself. In the model, layers are not classes but the tiers a read passes through.

The inward direction has a practical consequence: if disk or the network changes in the data layer, the domain and UI layers stay the same, because they ask only for the result of the read. The reverse also holds: if the screen changes, the data layer is unaware of it. Each layer answers its own question. The UI answers "what will be shown", the domain "by which rule", and data "where will it come from". This course measures the third question.

#### Eight Reads, Four Origins

A shopping app is opening. Raw data from the network is spread by two functions: `syncTo` copies network values to disk, and `promote` moves values from disk or the network into memory. Both update the map they receive and return it; the tiers we measure are these return values.

```kotlin
// Main.kt
fun syncTo(disk: MutableMap<String, String>, network: Map<String, String>, keys: List<String>): Map<String, String> {
    for (key in keys) network[key]?.let { disk[key] = it }
    return disk
}

fun promote(memory: MutableMap<String, String>, source: Map<String, String>, keys: List<String>): Map<String, String> {
    for (key in keys) source[key]?.let { memory[key] = it }
    return memory
}

fun main() {
    val network = mapOf(
        "profile.name" to "Deniz",
        "product.price" to "120",
        "product.stock" to "7",
        "cart.count" to "3",
    )
    val disk = syncTo(mutableMapOf("settings.theme" to "dark", "settings.language" to "en"), network, listOf("cart.count", "profile.name"))
    val memory = promote(mutableMapOf("product.price" to "100"), disk, listOf("profile.name"))
    println("disk keys: ${disk.keys.sorted()}")
    println("memory keys: ${memory.keys.sorted()}")

    val chain = listOf(Tier("memory", memory), Tier("disk", disk), Tier("network", network))
    val reads = listOf(
        Read("profile-name", "profile.name", chain, "guest"),
        Read("product-price", "product.price", chain, "0"),
        Read("theme", "settings.theme", chain, "light"),
        Read("language", "settings.language", chain, "en"),
        Read("cart-count", "cart.count", chain, "0"),
        Read("stock", "product.stock", chain, "0"),
        Read("campaign", "campaign.code", chain, "none"),
        Read("notification", "notification.enabled", chain, "off"),
    )
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
disk keys: [cart.count, profile.name, settings.language, settings.theme]
memory keys: [product.price, profile.name]
profile-name: key=profile.name | value=Deniz -> memory
product-price: key=product.price | value=100 -> memory
theme: key=settings.theme | value=dark -> disk
language: key=settings.language | value=en -> disk
cart-count: key=cart.count | value=3 -> disk
stock: key=product.stock | value=7 -> network
campaign: key=campaign.code | value=none -> default
notification: key=notification.enabled | value=off -> default
memory: 2
disk: 3
network: 1
default: 2
```

Eight reads produced four origins: memory 2, disk 3, network 1, default 2. Two details show the rule. `product-price` finds 100 in memory; although 120 sits on the network, the read returns to memory, because the rule sees the order, not the freshness. `stock` comes from the network: `syncTo` copied only `cart.count` and `profile.name`, so `product.stock` never reached disk. `campaign` and `notification` are in no tier; the reads' own defaults became the answer.

#### The Domain Layer Does Not Change the Origin

The domain layer converts the raw value from the network into the form the UI wants. The network gives the price in cents; the screen wants dollars. The conversion function carries a new name and hands the data layer's chain only the converted map.

```kotlin
// Domain.kt — domain layer: converts the cents value from the network into dollars, the display format
fun toDisplay(raw: Map<String, String>): Map<String, String> =
    raw.mapValues { (key, value) -> if (key.endsWith(".price")) (value.toInt() / 100).toString() else value }
```

The UI sees only the chain: it does not know whether a value is raw or converted, and it learns which tier it came from through the read's origin. Memory is built with the return value of `promote`: `promote` only asks to move the campaign code into memory, but the network response has no such key, so memory stays empty. The network tier carries the converted map.

```kotlin
// Main.kt — the domain layer converts the value, the origin is still network
fun promote(memory: MutableMap<String, String>, source: Map<String, String>, keys: List<String>): Map<String, String> {
    for (key in keys) source[key]?.let { memory[key] = it }
    return memory
}

fun main() {
    val raw = mapOf("product.price" to "12000", "product.stock" to "7")
    val network = toDisplay(raw)
    println("raw network data: product.price=${raw["product.price"]}")
    println("display format: product.price=${network["product.price"]}")

    val memory = promote(mutableMapOf(), network, listOf("campaign.code"))
    val chain = listOf(Tier("memory", memory), Tier("disk", emptyMap()), Tier("network", network))
    val reads = listOf(
        Read("product-price", "product.price", chain, "0"),
        Read("stock", "product.stock", chain, "0"),
        Read("campaign", "campaign.code", chain, "none"),
    )
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
raw network data: product.price=12000
display format: product.price=120
product-price: key=product.price | value=120 -> network
stock: key=product.stock | value=7 -> network
campaign: key=campaign.code | value=none -> default
memory: 0
disk: 0
network: 2
default: 1
```

The raw value is 12000, the display format 120. Two product reads came from the network: memory was empty, and so was disk. The domain layer converted the value but did not change the tier that gave the answer.

**AA1.** The model applies the conversion to the input of the network tier. On the platform, the conversion can also be done after the read, in the domain layer; that does not change the origin either, but the model does not set up this distinction.

#### The Bounding Measurement

We move the same key into memory and then change the value on the network. For the first read `promote` only asks to move the stock; the network has no stock, so memory stays empty and the read comes from the network. Once `promote` moves the price into memory, the second read returns to memory and reads the old value, even though the price on the network is 150.

```kotlin
// Main.kt — if the same key is moved into memory, the same read becomes memory
fun promote(memory: MutableMap<String, String>, source: Map<String, String>, keys: List<String>): Map<String, String> {
    for (key in keys) source[key]?.let { memory[key] = it }
    return memory
}

fun main() {
    val before = toDisplay(mapOf("product.price" to "12000"))
    val emptyMemory = promote(mutableMapOf(), before, listOf("product.stock"))
    val first = Read("price-first", "product.price", listOf(Tier("memory", emptyMemory), Tier("network", before)), "0")
    first.show()

    val memory = promote(mutableMapOf(), before, listOf("product.price"))
    val after = toDisplay(mapOf("product.price" to "15000"))
    val second = Read("price-after", "product.price", listOf(Tier("memory", memory), Tier("network", after)), "0")
    second.show()
    println("network now: ${after["product.price"]}")
    countOrigins(listOf(first, second))
}
```

```text
price-first: key=product.price | value=120 -> network
price-after: key=product.price | value=120 -> memory
network now: 150
memory: 1
disk: 0
network: 1
default: 0
```

The two reads have the same key and the same value, but different origins. The first read comes from the network; once `promote` puts the value into memory, the second read comes from memory. The network now says 150 but the read finds 120. The rule still sees only the order: because memory is the first tier, the stale value becomes the answer.

**AA2.** The model does not know when a value goes stale; the memory tier does not empty itself. On the platform there may be an expiry time or invalidation; in the model there is none.

#### Summary

- This lesson's origin: network. The domain layer converted the price from cents to dollars, but the tier that gave the answer was the network: memory and disk were empty.
- The lesson's measure is which tier gave the answer to a read: memory, disk, network or default. Over eight reads the counts came out as memory 2, disk 3, network 1, default 2.
- Among the UI, domain and data layers, dependencies point inward; the UI sees not the tiers but only the chain's answer.
- The rule sees the order, not the freshness: 100 in memory was read while 120 sat on the network.
- When the same key was moved into memory, the same read went from the network to memory; the new value on the network was not read.

#### Next Step

In this lesson, memory was a map filled by hand with `promote`. On the platform there is an owner that fills memory and keeps it when the screen rotates. The next lesson, The View Model, measures how that owner changes the origin of a read after the screen rotates.

### The View Model

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/view-model

A view model holds screen data so that it survives a configuration change, but not process death. The lesson measures how the origin of a read changes when the screen rotates and when the process dies.

In the previous lesson we built the chain that classifies the origin of a read and filled memory by hand. On the platform there is an owner that fills memory and keeps it when the screen rotates: the view model (`ViewModel`). In this lesson we look at the first tier of the chain, and the question stays the same: **where does the answer to this read come from?**

The measuring tool is the previous lesson's core; we repeat it unchanged.

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### Memory That Survives Rotation

When the screen rotates, or the language or dark theme changes, the system rebuilds the screen's UI from scratch. This is called a configuration change. The UI objects disappear; the view model does not. The system gives the same view model to the new UI. Because the view model holds the value read from the network in a field, the new UI reads the same value again without going to the network.

```kotlin
// Platform code: CartViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class CartViewModel(private val repository: CartRepository) : ViewModel() {
    var quantity = 1
        private set

    fun load() {
        viewModelScope.launch { quantity = repository.quantity() }
    }
}
```

`quantity` is a field of the view model. Work inside `viewModelScope` continues until the view model is cleared; rotating the screen does not cancel it. This piece is platform code, and here we make no claim about output, timing or file paths. The model builds this rule of the platform; the model is not the platform itself.

In earlier courses, the lifetime axes (M10/K02, M11/K02, M13/K03) measured how long something lives. This lesson also talks about lifetime, but that is not the measure. Memory is not a lifetime but a place in the chain: the measure is whether the memory tier gave the answer to the read.

The model needs three functions. `fillMemory` puts the values from the network into an owner's memory. `surviveRotation` returns whose memory remains after rotation. `afterTermination` keeps only the saved keys in memory once the process has died. All three transform their input.

```kotlin
// Lifecycle.kt — which memory remains after a screen rotation and after process death
fun fillMemory(store: MutableMap<String, String>, network: Map<String, String>, keys: List<String>): Map<String, String> {
    for (key in keys) network[key]?.let { store[key] = it }
    return store
}

fun surviveRotation(stores: Map<String, Map<String, String>>, keepers: Set<String>): Map<String, String> =
    stores.filterKeys { it in keepers }.values.fold(mapOf()) { all, store -> all + store }

fun afterTermination(memory: Map<String, String>, saved: Set<String>): Map<String, String> =
    memory.filterKeys { it in saved }
```

`surviveRotation` merges the memories of the owners in the set of keepers; the memory of an owner that is not kept does not reach the new UI.

Suppose the cart screen has loaded the count and the price into the view model's memory. The screen rotated. The new UI reads five values.

```kotlin
// Main.kt — the screen rotated, the view model's memory was kept
fun main() {
    val network = mapOf("cart.count" to "3", "product.price" to "120", "product.stock" to "7")
    val disk = mapOf("settings.theme" to "dark")
    val stores = mapOf("view-model" to fillMemory(mutableMapOf(), network, listOf("cart.count", "product.price")))
    val memory = surviveRotation(stores, setOf("view-model"))
    println("remaining memory keys: ${memory.keys.sorted()}")

    val chain = listOf(Tier("memory", memory), Tier("disk", disk), Tier("network", network))
    val reads = listOf(
        Read("cart-count", "cart.count", chain, "0"),
        Read("product-price", "product.price", chain, "0"),
        Read("theme", "settings.theme", chain, "light"),
        Read("stock", "product.stock", chain, "0"),
        Read("notification", "notification.enabled", chain, "off"),
    )
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
remaining memory keys: [cart.count, product.price]
cart-count: key=cart.count | value=3 -> memory
product-price: key=product.price | value=120 -> memory
theme: key=settings.theme | value=dark -> disk
stock: key=product.stock | value=7 -> network
notification: key=notification.enabled | value=off -> default
memory: 2
disk: 1
network: 1
default: 1
```

Two reads came from memory: the count and the price. The network was not consulted. `stock` came from the network because it was never loaded, `theme` from disk, and `notification` from the default.

**AA1.** The model counts rotation as a single event and loses no field of the view model. On the platform, if written wrongly, the view model's own fields can also be leaked by holding on to a UI object; the model has no such case.

#### Whose Memory

What would happen if a UI object held the memory? The same two values would have been loaded on the same screen from the same network, but the owner would disappear on rotation. Now we put the two owners side by side. The same load is done for each owner, the same rotation is applied, and the same three reads run again.

```kotlin
// Main.kt — the same load, two different owners
fun main() {
    val network = mapOf("cart.count" to "3", "product.price" to "120")
    val disk = mapOf("settings.theme" to "dark")
    for (owner in listOf("view-model", "ui")) {
        val stores = mapOf(owner to fillMemory(mutableMapOf(), network, listOf("cart.count", "product.price")))
        val memory = surviveRotation(stores, setOf("view-model"))
        val chain = listOf(Tier("memory", memory), Tier("disk", disk), Tier("network", network))
        println("owner: $owner")
        countOrigins(listOf(
            Read("cart-count", "cart.count", chain, "0"),
            Read("product-price", "product.price", chain, "0"),
            Read("theme", "settings.theme", chain, "light"),
        ))
    }
}
```

```text
owner: view-model
memory: 2
disk: 1
network: 0
default: 0
owner: ui
memory: 0
disk: 1
network: 2
default: 0
```

When the owner is the view model, two reads come from memory. When the owner is the UI, memory stays empty and the same two reads go to the network; `theme` comes from disk in both cases. The values are the same and the load is the same; the only difference is whether the memory survived the rotation.

**AA2.** The model takes the kept owners as a set. On the platform the system decides which owner is kept; choosing the set by hand is possible in the model but not on the platform.

#### The Bounding Measurement

A view model survives a configuration change but not process death. Under memory pressure the system can end the app's background process; every object in memory, the view model included, goes away. Only saved state returns. In this lesson we saved no keys; saved state is the subject of the next lesson. We pass the same memory through the two events.

```kotlin
// Main.kt — rotation and process death carry the same reads to different origins
fun main() {
    val network = mapOf("cart.count" to "3", "product.price" to "120")
    val disk = mapOf("settings.theme" to "dark")
    val viewModelMemory = fillMemory(mutableMapOf("screen.tab" to "summary"), network, listOf("cart.count", "product.price"))
    val events = listOf(
        "rotation" to surviveRotation(mapOf("view-model" to viewModelMemory), setOf("view-model")),
        "process-death" to afterTermination(viewModelMemory, emptySet()),
    )
    for ((event, memory) in events) {
        println("event: $event")
        val chain = listOf(Tier("memory", memory), Tier("disk", disk), Tier("network", network))
        val reads = listOf(
            Read("cart-count", "cart.count", chain, "0"),
            Read("product-price", "product.price", chain, "0"),
            Read("theme", "settings.theme", chain, "light"),
            Read("screen-tab", "screen.tab", chain, "first"),
        )
        reads.forEach { it.show() }
        countOrigins(reads)
    }
}
```

```text
event: rotation
cart-count: key=cart.count | value=3 -> memory
product-price: key=product.price | value=120 -> memory
theme: key=settings.theme | value=dark -> disk
screen-tab: key=screen.tab | value=summary -> memory
memory: 3
disk: 1
network: 0
default: 0
event: process-death
cart-count: key=cart.count | value=3 -> network
product-price: key=product.price | value=120 -> network
theme: key=settings.theme | value=dark -> disk
screen-tab: key=screen.tab | value=first -> default
memory: 0
disk: 1
network: 2
default: 1
```

On rotation, three of the four reads came from memory. On process death, the result of the same four reads changed: the count and the price were read from the network, `theme` stayed on disk, and `screen-tab` fell to the default because it was found in no tier. The tab selection in memory counts as lost, because it was not saved.

**AA3.** The model assumes the network stays reachable on process death. On the platform, with no connection, the count and the price would fall to the default as well; the model does not set up this case.

#### Summary

- This lesson's origin: memory. When the screen rotates, the view model's memory is the first tier of the chain, and the count and the price are read from it.
- The lesson's measure is whether the memory tier gave the answer to a read. After rotation, five reads came out as memory 2, disk 1, network 1, default 1.
- For the same load, when the memory owner is the view model the result is memory 2, network 0; when the owner is the UI, memory 0, network 2.
- On process death memory empties: over four reads, memory dropped from 3 to 0, the network rose from 0 to 2, and the default rose from 0 to 1.
- The unsaved tab selection fell to the default; saved state is the subject of the next lesson.

#### Next Step

In this lesson the set of saved keys was empty and the tab selection was lost. The next lesson, State Holders, measures how the screen's state is gathered in one place and how the saved fields are restored from disk.

### State Holders

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/state-holders

A state holder gathers a screen's state in one place; saved state selects the fields that return even after the process dies. The lesson measures which origin each field of a screen returns from and with which saved fields.

In the previous lesson, memory was kept when the screen rotated, emptied when the process died, and the unsaved tab selection fell to the default. In this lesson we gather the screen's state in one place and choose which of its fields return after the process dies. The question is the same: **where does the answer to this read come from?**

The measuring tool is the core of the previous lessons; we repeat it unchanged.

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### The Single Source of Truth

A screen's state consists of a few fields: the count in the cart, the text in the search box, the open tab, the sort order. If the fields are scattered across UI objects, the same information has several copies, and the copies can drift apart. The solution is for each field to have exactly one writable owner; this is called the single source of truth. The UI reads this owner and does not keep the field in its own copy.

The class that carries the state and the logic that changes it is called a state holder. The view model of the previous lesson is a state holder, but being a state holder does not require being a view model. A plain class can also be a state holder; the difference is who determines its lifetime.

```kotlin
// Platform code: CartStateHolder
data class CartState(val quantity: Int = 1, val filter: String = "")

class CartStateHolder {
    var state = CartState()
        private set

    fun setQuantity(value: Int) {
        state = state.copy(quantity = value)
    }
}
```

`CartStateHolder` keeps the state in the `state` field and is the only one that writes it. This piece is platform code, and here we make no claim about output, timing or file paths. The model builds this rule of the platform; the model is not the platform itself.

#### Saved Fields

When the process dies, the state holder in memory goes away. For the fields that must return, the platform offers saved state: the state holder chooses for itself which fields it saves.

```kotlin
// Platform code: CartViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel

class CartViewModel(private val handle: SavedStateHandle) : ViewModel() {
    val filter: String = handle["filter"] ?: ""

    fun setFilter(value: String) {
        handle["filter"] = value
    }
}
```

The `filter` key is written to saved state and is handed back to the new view model after the process dies. A field that is not saved stays only in memory.

In the model, the state holder carries the fields and the set of keys to save. `memory` returns the live fields; `snapshot` filters only the saved ones. `screenReads` builds the screen's four reads.

```kotlin
// Holder.kt — state holder: screen fields in one place, those to be saved marked separately
class Holder(private val fields: Map<String, String>, private val saved: Set<String>) {
    fun memory(): Map<String, String> = fields.toMap()
    fun snapshot(): Map<String, String> = fields.filterKeys { it in saved }
}

fun screenReads(chain: List<Tier>): List<Read> = listOf(
    Read("cart-count", "cart.count", chain, "0"),
    Read("filter-text", "filter.text", chain, "none"),
    Read("screen-tab", "screen.tab", chain, "first"),
    Read("sorting", "sort.order", chain, "name"),
)
```

First, the state before the process dies. All four fields of the state holder are in memory.

```kotlin
// Main.kt — the state holder keeps the screen fields in one place
fun main() {
    val holder = Holder(
        mapOf("cart.count" to "3", "filter.text" to "headphones", "screen.tab" to "summary", "sort.order" to "price"),
        setOf("filter.text", "screen.tab"),
    )
    println("keys to save: ${holder.snapshot().keys.sorted()}")
    val reads = screenReads(listOf(Tier("memory", holder.memory())))
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
keys to save: [filter.text, screen.tab]
cart-count: key=cart.count | value=3 -> memory
filter-text: key=filter.text | value=headphones -> memory
screen-tab: key=screen.tab | value=summary -> memory
sorting: key=sort.order | value=price -> memory
memory: 4
disk: 0
network: 0
default: 0
```

All four reads came from memory. Two keys are marked to be saved: the search text and the open tab. The cart count is not marked because it already sits on the network, and the sort order is not marked because it is only a UI preference.

#### When the Process Dies

The process died. Memory is empty; the new state holder receives the saved state. In the model we build saved state as the disk tier: the place that remains even when the process dies. The network holds only the cart count.

```kotlin
// Main.kt — the process died, saved state returns from disk
fun main() {
    val network = mapOf("cart.count" to "3")
    val holder = Holder(
        mapOf("cart.count" to "3", "filter.text" to "headphones", "screen.tab" to "summary", "sort.order" to "price"),
        setOf("filter.text", "screen.tab"),
    )
    val chain = listOf(Tier("memory", emptyMap()), Tier("disk", holder.snapshot()), Tier("network", network))
    val reads = screenReads(chain)
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
cart-count: key=cart.count | value=3 -> network
filter-text: key=filter.text | value=headphones -> disk
screen-tab: key=screen.tab | value=summary -> disk
sorting: key=sort.order | value=name -> default
memory: 0
disk: 2
network: 1
default: 1
```

The search text and the tab returned from disk. The count was not saved, so it was read from the network. The sort order was in no tier, so it fell to the default. On the same screen at the same time there are three origins: disk 2, network 1, default 1.

**AA1.** The model builds saved state as the disk tier. The model does not say where this bundle is stored on the platform; the only thing the model distinguishes is that the bundle survives process death.

**AA2.** The model hands the set of keys to save to the state holder by hand. On the platform the saved value must be small and serializable; the model has no such constraint.

#### The Bounding Measurement

A field that is not saved falls to the default or the network. So how do the origins change when the saved set changes? We try three sets with the same fields: none, two fields, all.

```kotlin
// Main.kt — the set of saved fields changes the distribution of origins
fun main() {
    val network = mapOf("cart.count" to "3")
    val fields = mapOf("cart.count" to "3", "filter.text" to "headphones", "screen.tab" to "summary", "sort.order" to "price")
    val savedSets = listOf(
        "none" to emptySet<String>(),
        "two-fields" to setOf("filter.text", "screen.tab"),
        "all" to fields.keys,
    )
    for ((name, saved) in savedSets) {
        val snapshot = Holder(fields, saved).snapshot()
        val chain = listOf(Tier("memory", emptyMap()), Tier("disk", snapshot), Tier("network", network))
        println("saved: $name")
        countOrigins(screenReads(chain))
    }
}
```

```text
saved: none
memory: 0
disk: 0
network: 1
default: 3
saved: two-fields
memory: 0
disk: 2
network: 1
default: 1
saved: all
memory: 0
disk: 4
network: 0
default: 0
```

When no field is saved, disk is zero: the count comes from the network and three fields fall to the default. When two fields are saved, the distribution of the previous measurement appears. When all are saved, four of the four reads come from disk and the network is not consulted. The system that rebuilds the screen is the same; the only thing that changes is which fields the state holder marks.

**AA3.** The model marks saving by the field's key. On the platform saving happens on every write; in the model the saved set is fixed and is not updated instantly.

#### Summary

- This lesson's origin: disk. After the process died, the saved search text and tab selection returned from saved state, which the model builds as the disk tier.
- The lesson's measure is which origin a field returns from after the process dies. On the same screen four reads gave disk 2, network 1, default 1.
- The state holder gathers the fields in one place; the set of saved keys determines which ones survive the process.
- With an empty saved set the result was disk 0, network 1, default 3; with all fields saved it was disk 4.
- A field that is not saved goes away together with memory; it is rebuilt from the network or the default.

#### Next Step

In this lesson the fields of the state holder returned after the process died, but the holder does not yet know whether loading has finished. The next lesson, Observable State, measures the difference between the first value of a state flow and the loaded value.

### Observable State

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/observable-state

Observable state is a state flow that publishes a new value to collectors as its value changes; a flow always has an initial value. The lesson measures the origin of the value a collector reads before loading finishes and after it finishes.

In the previous lesson the fields of the state holder returned after the process died, but the holder did not say whether loading had finished. How does the UI find out the value? It uses a structure that reports as the value changes: observable state. The question is the same: **where does the answer to this read come from?**

The measuring tool is the core of the previous lessons; we repeat it unchanged.

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### The Initial Value of a Flow

On the platform, observable state is usually a state flow (`StateFlow`). A flow always carries a value: the initial value given when it is built. The side that changes the state writes the new value to the flow; the side that reads the value, the collector, collects the flow and receives each new value. Collecting runs in a coroutine; if a flow is derived from another source, it is turned into a state flow with `stateIn` and is usually built with an initial value.

```kotlin
// Platform code: CartViewModel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class CartViewModel : ViewModel() {
    private val _state = MutableStateFlow(CartUiState())
    val state: StateFlow<CartUiState> = _state

    fun onLoaded(items: List<CartItem>) {
        _state.value = CartUiState(items)
    }
}
```

The UI collects the flow and is redrawn on every value.

```kotlin
// Platform code: CartScreen
import androidx.compose.runtime.getValue

@Composable
fun CartScreen(viewModel: CartViewModel) {
    val state by viewModel.state.collectAsState()
    Text("Items: ${state.items.size}")
}
```

`CartUiState()` is the initial value of the flow: the cart has not been loaded yet and the list is empty. These two pieces are platform code, and here we make no claim about output, timing or file paths. The model builds this rule of the platform; the model is not the platform itself.

In the model we build the flow as a plain list: the initial value is the first element of the list, and each emission adds a new value written over the previous last one. `latest` returns the last value, `fastCollector` returns every emission, and `slowCollector` returns only the last emission. `collectReads` builds a collector's four reads; in the collector's chain there is no tier other than the flow.

```kotlin
// Feed.kt — observable state: there is an initial value, each emission is added to the list
class Feed(initial: Map<String, String>) {
    private val emitted = mutableListOf(initial)

    fun emit(update: Map<String, String>) {
        emitted.add(emitted.last() + update)
    }

    fun latest(): Map<String, String> = emitted.last()
    fun fastCollector(): List<Map<String, String>> = emitted.toList()
    fun slowCollector(): List<Map<String, String>> = listOf(emitted.last())
}

fun collectReads(value: Map<String, String>): List<Read> {
    val chain = listOf(Tier("memory", value))
    return listOf(
        Read("title", "screen.title", chain, "empty"),
        Read("product-price", "product.price", chain, "0"),
        Read("stock", "product.stock", chain, "0"),
        Read("campaign", "campaign.code", chain, "none"),
    )
}
```

Before loading starts, the collector collects the flow. In the flow's initial value there is only the screen title.

```kotlin
// Main.kt — the collector's first read happens before loading finishes
fun main() {
    val feed = Feed(mapOf("screen.title" to "Cart"))
    val reads = collectReads(feed.latest())
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
title: key=screen.title | value=Cart -> memory
product-price: key=product.price | value=0 -> default
stock: key=product.stock | value=0 -> default
campaign: key=campaign.code | value=none -> default
memory: 1
disk: 0
network: 0
default: 3
```

The title came from memory because it was in the flow's initial value. The price, stock and campaign are not in the flow because they have not been loaded: three reads fell to the default. The network response is on its way; a tier that has not answered is not in the collector's chain.

**AA1.** The model builds the flow as a plain list and puts only the flow in the collector's chain. On the platform, loading can also start from disk or the network at the same time; the collector still looks only at the flow.

#### The Slow Collector

Let the flow make two emissions: first an old price, then the new price. Call the collector that sees every emission fast, and the one that sees only the last slow. Because a state flow conflates values, the slow collector never sees the intermediate value.

```kotlin
// Main.kt — the slow collector does not see the intermediate value
fun main() {
    val feed = Feed(mapOf("screen.title" to "Cart"))
    feed.emit(mapOf("product.price" to "100"))
    feed.emit(mapOf("product.price" to "120"))
    println("fast collector:")
    for (value in feed.fastCollector()) {
        println("  price=${value["product.price"] ?: "none"}")
    }
    println("slow collector:")
    for (value in feed.slowCollector()) {
        println("  price=${value["product.price"] ?: "none"}")
    }
    countOrigins(collectReads(feed.latest()))
}
```

```text
fast collector:
  price=none
  price=100
  price=120
slow collector:
  price=120
memory: 2
disk: 0
network: 0
default: 2
```

The fast collector saw three values: no price, 100, 120. The slow collector saw only 120. Both collectors ended up reading the same value, and the origin of that value is the same: every value inside the flow is memory. In the four reads made with the last value, the result was two memory and two default.

**AA2.** The model builds slowness with two fixed collector types. On the platform a collector's slowness depends on timing, and the model has no time.

#### The Bounding Measurement

We read the same flow at two moments: while loading runs and after it finishes. When loading finishes, the price and stock are written to the flow; that is, the keys enter memory.

```kotlin
// Main.kt — the same flow gives two origins at two moments
fun main() {
    val feed = Feed(mapOf("screen.title" to "Cart"))
    println("loading: in progress")
    countOrigins(collectReads(feed.latest()))

    feed.emit(mapOf("product.price" to "120", "product.stock" to "7"))
    println("loading: finished")
    val after = collectReads(feed.latest())
    after.forEach { it.show() }
    countOrigins(after)
}
```

```text
loading: in progress
memory: 1
disk: 0
network: 0
default: 3
loading: finished
title: key=screen.title | value=Cart -> memory
product-price: key=product.price | value=120 -> memory
stock: key=product.stock | value=7 -> memory
campaign: key=campaign.code | value=none -> default
memory: 3
disk: 0
network: 0
default: 1
```

While loading ran, one read gave memory and three reads gave default. When loading finished, the price and stock entered the flow: memory rose to three and default dropped to one. Same flow, same collector, same four reads; the only thing that changed is the moment at which the flow was read. `campaign` stays default because it did not enter the flow even after loading finished.

**AA3.** The model marks the end of loading with a single emission. On the platform loading finishes piece by piece and each piece can be a separate emission; the model has no such distinction.

#### Summary

- This lesson's origin: default. When the collector read before loading finished, the price, stock and campaign were not in the flow, so the reads' own defaults became the answer.
- The lesson's measure is which moment of the flow the value the collector read came from. While loading ran, memory was 1 and default 3; after it finished, memory was 3 and default 1.
- The initial value of a flow is an answer: the screen title came from memory because it was in the initial value.
- The slow collector did not see the intermediate value 100, only 120; the origin of every value in the flow is memory.
- In the flow's chain there is no tier that has not answered; the collector sees only the flow.

#### Next Step

In this lesson the flow took loading from outside and published it. How an event changes the flow is a separate question. The next lesson, Unidirectional Data Flow, measures how an event rebuilds the state in memory and that the value goes down from the top rather than up from the UI.

### Unidirectional Data Flow

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/unidirectional-data-flow

In unidirectional data flow, events go up, state comes down, and the UI does not write state directly. The lesson measures which tier gives the answer when the new state built by a reducer that handles an event is read.

In the previous lesson the UI collected a changing value from a flow. How the value enters the flow is a separate question: who writes it? In unidirectional data flow the UI does not write state. The user's tap becomes an event, the event goes to the state holder, the holder builds the new state, and the UI reads the new state. The question is again the same: **where does the answer to this read come from?**

The measuring tool is the core of the previous lessons; we repeat it unchanged.

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### The Cycle: Events Go Up, State Comes Down

On the platform, an event is usually written as a sealed type, and the view model receives it through a single entry function. The entry function produces the new state from the current state and writes it to the flow. The UI does only two things: it draws the state and sends the event.

```kotlin
// Platform code: CartViewModel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update

sealed interface CartEvent {
    data class QuantityChanged(val quantity: Int) : CartEvent
    object Cleared : CartEvent
}

class CartViewModel : ViewModel() {
    private val _state = MutableStateFlow(CartUiState())
    val state: StateFlow<CartUiState> = _state

    fun onEvent(event: CartEvent) {
        _state.update { current ->
            when (event) {
                is CartEvent.QuantityChanged -> current.copy(quantity = event.quantity)
                CartEvent.Cleared -> CartUiState()
            }
        }
    }
}
```

```kotlin
// Platform code: CartScreen
@Composable
fun CartScreen(viewModel: CartViewModel) {
    val state by viewModel.state.collectAsState()
    Button(onClick = { viewModel.onEvent(CartEvent.QuantityChanged(state.quantity + 1)) }) {
        Text("Count: ${state.quantity}")
    }
}
```

These two pieces are platform code, and here we make no claim about output, timing or file paths. The model builds this rule of the platform; the model is not the platform itself.

In M31/K03 we built the UI as a tree and measured which direction data travels in that tree. Here the question is not the direction of the tree: we ask which tier gives the answer when the state coming down is read. Direction is not a category in this course; the origin of the read is what gets measured.

In the model, a reducer is a function that takes two inputs: the current state and the event. Its return value is the new state, overwritten with the event's values. `applyAll` handles the events in order. `stateReads` puts the new state in the memory tier and builds four reads.

```kotlin
// Reducer.kt — the event produces the new state from the current state
class Event(val name: String, val values: Map<String, String>)

fun reduce(state: Map<String, String>, event: Event): Map<String, String> =
    state + event.values

fun applyAll(initial: Map<String, String>, events: List<Event>): Map<String, String> =
    events.fold(initial) { current, event -> reduce(current, event) }

fun stateReads(state: Map<String, String>): List<Read> {
    val chain = listOf(Tier("memory", state))
    return listOf(
        Read("count", "cart.count", chain, "0"),
        Read("total", "cart.total", chain, "0"),
        Read("discount", "cart.discount", chain, "none"),
        Read("note", "cart.note", chain, "empty"),
    )
}
```

#### The Event Is Handled, the State Is Built in Memory

Let the cart start with a count of one. The user raises the count to two, and then the total is computed. Two events pass through the reducer and the new state is read.

```kotlin
// Main.kt — two events are handled, the new state is built in memory
fun main() {
    val initial = mapOf("cart.count" to "1")
    val events = listOf(
        Event("set-count", mapOf("cart.count" to "2")),
        Event("compute-total", mapOf("cart.total" to "240")),
    )
    val state = applyAll(initial, events)
    val reads = stateReads(state)
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
count: key=cart.count | value=2 -> memory
total: key=cart.total | value=240 -> memory
discount: key=cart.discount | value=none -> default
note: key=cart.note | value=empty -> default
memory: 2
disk: 0
network: 0
default: 2
```

The count and the total were read from the state the reducer built: two reads are memory. The discount and the note were not written by any event; because the chain's only tier is memory, they fell to the default. That is, a freshly built state makes the read memory, but a field that was not built is still the default.

**AA1.** The model builds the state as a plain map and reduces the UI sending an event to a list element. On the platform events arrive over time and the flow redraws the UI on every change; the model has no drawing.

#### The Event Order Changes, the Origin Does Not

What happens if the same two events arrive in reverse order? Let the count be set first to five and then to two; then let the same events be handled in reverse order. Because the reducer overwrites, the last event wins.

```kotlin
// Main.kt — the event order changes the value, the origin stays the same
fun main() {
    val first = Event("count-five", mapOf("cart.count" to "5"))
    val second = Event("count-two", mapOf("cart.count" to "2"))

    val inOrder = stateReads(applyAll(mapOf(), listOf(first, second)))
    val reversed = stateReads(applyAll(mapOf(), listOf(second, first)))

    println("order: five, two")
    inOrder[0].show()
    println("order: two, five")
    reversed[0].show()
}
```

```text
order: five, two
count: key=cart.count | value=2 -> memory
order: two, five
count: key=cart.count | value=5 -> memory
```

The same read gave two values: 2 and 5. Both came from memory. The rule sees the tier, not the value: when the order changed, the value changed and the origin did not.

**AA2.** The model builds the "last event wins" rule with overwriting. On the platform, two events that write the same field can be merged, ordered or dropped; this decision belongs to the app and is absent from the model.

#### The Bounding Measurement

The limit is the moment the event has not yet been handled. A field read while the state is empty is compared with the same field read after the event is handled.

```kotlin
// Main.kt — a read before the event is handled gives the default
fun main() {
    val before = stateReads(mapOf())
    println("before events:")
    countOrigins(before)

    val after = stateReads(
        applyAll(
            mapOf(),
            listOf(
                Event("set-count", mapOf("cart.count" to "2")),
                Event("add-discount", mapOf("cart.discount" to "code-10")),
            ),
        )
    )
    println("after events:")
    after.forEach { it.show() }
    countOrigins(after)
}
```

```text
before events:
memory: 0
disk: 0
network: 0
default: 4
after events:
count: key=cart.count | value=2 -> memory
total: key=cart.total | value=0 -> default
discount: key=cart.discount | value=code-10 -> memory
note: key=cart.note | value=empty -> default
memory: 2
disk: 0
network: 0
default: 2
```

Before the events were handled, four of the four reads gave the default. After two events, the count and the discount came from memory; the total and the note are still the default. It is the event that writes the state; as long as no event arrives, what the UI will read is not in the chain but is the default that the code wrote.

**AA3.** The model builds the empty state with an empty map. On the platform the initial state is usually an initial value that comes before loading, as we saw in the Observable State lesson.

#### Summary

- This lesson's origin: memory. The reducer that handled the event built the new state in memory, and after two events the count and the total were read from the memory tier.
- The lesson's measure is whether the read came from the newly built state or from a field that was never written. Before the events were handled, memory was 0 and default 4; after two events, memory was 2 and default 2.
- When the event order changed, the same read gave 2 and 5, but the origin of both is memory: the rule sees the tier, not the value.
- The UI does not write state; the event writes it. A field for which no event arrives stays the default.
- Direction is not a category in this course; what is measured is which tier gives the answer.

#### Next Step

So far we have built the tiers of the chain ourselves. In a real app, who builds the chain? The next lesson, Dependency Injection, measures how the tiers an object needs are handed in from outside and where the answer comes from in the production chain.

### Dependency Injection

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/dependency-injection

In dependency injection, a class does not build the objects it needs itself but receives them from outside. The lesson measures which tier answers a read in the production chain built by a container, and how the answer changes when the chain changes in a test.

In the previous lessons we built the tiers of the chain directly. In a real app a structure takes over this job: dependency injection. A class does not create the objects it needs; it receives them from outside, in its constructor. The structure that builds and hands out the objects is called a container. The question is again the same: **where does the answer to this read come from?**

The measuring tool is the core of the previous lessons; we repeat it unchanged.

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### The Chain Handed In Through the Constructor

In constructor injection, a class asks for its dependency as a parameter. A module describes which object is built and how; a scope determines how long that object is shared. Injection libraries process this description at build time or at run time; we show only the constructor parameter and the result of the description.

```kotlin
// Platform code: CartRepository and module
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Inject
import javax.inject.Singleton

class CartRepository @Inject constructor(
    private val remote: CartApi,
    private val local: CartDao,
)

@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    @Provides
    @Singleton
    fun provideCartApi(): CartApi = CartApi.create()
}
```

The consumer does not build the `CartApi` and `CartDao` objects itself; the module decides which one arrives. This piece is platform code, and here we make no claim about output, timing or file paths. The model builds this rule of the platform; the model is not the platform itself.

Two questions from earlier courses need to be kept apart here. M12/K02 asks where a change reaches, and M12/K03 asks who can change a result. This lesson describes who builds which object, but that is not what we measure: the category is not the owner that supplies the object but the tier that answers the read. Even though the container built the chain, what we look at is not the builder; it is which tier the answer came from.

In the model the container is a chain. `Container.read` hands the chain to a read. `preload` loads the requested keys from the network response into memory ahead of time. `productionContainer` builds the production chain: it builds memory from the return value of `preload`, leaves disk empty and fills the network. `testContainer` puts fake data in the memory tier. `CartScreenModel` is the consumer and receives the container in its constructor; it does not know which chain arrived.

```kotlin
// Container.kt — the container builds the chain, the consumer receives it in its constructor
class Container(private val chain: List<Tier>) {
    fun read(label: String, key: String, fallback: String): Read =
        Read(label, key, chain, fallback)
}

fun preload(network: Map<String, String>, keys: List<String>): Map<String, String> =
    keys.filter { it in network }.associateWith { network.getValue(it) }

fun productionContainer(network: Map<String, String>): Container =
    Container(listOf(Tier("memory", preload(network, listOf("campaign.code"))), Tier("disk", mapOf()), Tier("network", network)))

fun testContainer(fake: Map<String, String>, network: Map<String, String>): Container =
    Container(listOf(Tier("memory", fake), Tier("disk", mapOf()), Tier("network", network)))

class CartScreenModel(private val container: Container) {
    fun readAll(): List<Read> = listOf(
        container.read("title", "screen.title", "empty"),
        container.read("product-price", "product.price", "0"),
        container.read("stock", "product.stock", "0"),
        container.read("campaign", "campaign.code", "none"),
    )
}
```

#### The Production Chain

In production the container asks to preload only the campaign code into memory and leaves disk empty. The network response carries the title, the price and the stock; there is no campaign code, so `preload` returns an empty map and memory stays empty as well.

```kotlin
// Main.kt — in the production chain the answer comes from the network
fun main() {
    val network = mapOf(
        "screen.title" to "Cart",
        "product.price" to "120",
        "product.stock" to "7",
    )
    val model = CartScreenModel(productionContainer(network))
    val reads = model.readAll()
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
title: key=screen.title | value=Cart -> network
product-price: key=product.price | value=120 -> network
stock: key=product.stock | value=7 -> network
campaign: key=campaign.code | value=none -> default
memory: 0
disk: 0
network: 3
default: 1
```

Three reads came from the network; the campaign fell to the default because it was in no tier. Memory and disk were tiers that did not answer in this chain: because they were empty, the read passed them and reached the network.

**AA1.** The model builds the container as a plain chain and imitates the scope by rebuilding the chain on every call. On the platform the container builds the object graph and keeps singleton objects alive; the chain is only one slice of that graph.

#### The Scope Changes, the Origin Does Not

Let the same consumer run in the scope of two users. Each scope is built with its own network response; the consumer class is the same in both.

```kotlin
// Main.kt — two scopes: same consumer, same origin, different value
fun main() {
    val scopes = mapOf(
        "user-a" to mapOf("product.price" to "120"),
        "user-b" to mapOf("product.price" to "95"),
    )
    for ((name, network) in scopes) {
        val model = CartScreenModel(productionContainer(network))
        println("scope: $name")
        model.readAll()[1].show()
    }
}
```

```text
scope: user-a
product-price: key=product.price | value=120 -> network
scope: user-b
product-price: key=product.price | value=95 -> network
```

In the two scopes the price came out as 120 and 95; the origin of both is the network. The consumer stayed the same, the value changed, the origin did not. The scope determines the value; the rule looks only at who gave the answer.

**AA2.** The model builds the scope as a network response map. On the platform a scope is the lifetime boundary of an object, and two consumers in the same scope share the same instance; the model has no object sharing.

#### The Bounding Measurement

Testing is the real gain of injection: the chain changes while the consumer does not. In the test, fake price and stock are put in the memory tier. The network response stays the same as in production.

```kotlin
// Main.kt — in the test, fake data is injected into the memory tier
fun main() {
    val network = mapOf(
        "screen.title" to "Cart",
        "product.price" to "120",
        "product.stock" to "7",
    )
    val fake = mapOf("product.price" to "1", "product.stock" to "1")
    val model = CartScreenModel(testContainer(fake, network))
    val reads = model.readAll()
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
title: key=screen.title | value=Cart -> network
product-price: key=product.price | value=1 -> memory
stock: key=product.stock | value=1 -> memory
campaign: key=campaign.code | value=none -> default
memory: 2
disk: 0
network: 1
default: 1
```

Same consumer, same network response; but the price and stock now came from memory and their values became 1. The title is still from the network and the campaign still from the default. Once fake data entered the memory tier, the answer to the read was given from there. The rule classifies by who gave the answer, not by who built the tier.

**AA3.** The model builds fake data as a memory tier. On the platform a fake is a test implementation of an interface; the behavior of the fake is not measured in this lesson.

#### Summary

- This lesson's origin: network. In the production chain built by the container, memory and disk were empty and the network was full; the title, price and stock came from the network.
- The lesson's measure is that the network answered after the tiers that did not answer. In production the result was network 3, default 1, memory 0, disk 0.
- In two scopes the same consumer read 120 and 95; both are network. The scope determines the value, not the origin.
- When fake data entered the memory tier in the test, the price and stock came from memory: memory 2, network 1, default 1.
- The category is not the owner that builds the object but the tier that gives the answer.

#### Next Step

We have seen the tiers of the chain and the structure that builds them. The next topic of the course, The Repository Pattern, gathers this chain behind a single interface: the screen asks without knowing which tier it reads from, the repository manages the order, and the data layer is read with the same measure.

## The Data Layer

The repository pattern, the local database, key–value storage, file and media storage, the network client, caching and offline support, paged data loading and background work.

### The Repository Pattern

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/repository-pattern

The repository pattern hides where data comes from by ordering local and remote data sources behind a single interface. The lesson measures the origin of an answer in an ordered lookup chain and shows how the origin changes when the order changes.

In the previous lesson we saw that a class receives its dependency from outside instead of building it itself. The dependency the domain layer receives is often a single object, and the data layer provides it. That object is called a repository. In The Layer Model lesson we measured that the answer to a read can come from one of four origins: memory, disk, network, or default. This lesson points that measurement at a new place: who builds the chain that provides the answer, and does the caller know about it? The question of the lesson is the same as in the earlier lessons: **where does the answer to this read come from?**

The core below stays the same in every lesson of the course. A read walks an ordered lookup chain of tiers and takes the origin of the first tier that holds the key. If no tier holds the key, the answer is the default.

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### Data Sources and the Repository

The data of an application lives in more than one place. The record on disk is the local data source, and the record on the server is the remote data source. Each speaks its own language: one speaks rows and columns, the other requests and responses. If the calling code knows these languages, the caller must change every time a source changes. The repository pattern cuts this link: the repository orders the sources inside itself and offers a single read operation to the outside.

On the platform, this is written as an interface and a class that uses it. The class looks at the local source, goes to the remote source if it finds nothing, and writes the incoming answer to the local source.

```kotlin
// Platform code: ProductRepository
interface ProductLocalDataSource {
    suspend fun price(id: String): String?
    suspend fun save(id: String, price: String)
}

interface ProductRemoteDataSource {
    suspend fun fetchPrice(id: String): String
}

class ProductRepository(
    private val local: ProductLocalDataSource,
    private val remote: ProductRemoteDataSource,
) {
    suspend fun price(id: String): String =
        local.price(id) ?: remote.fetchPrice(id).also { local.save(id, it) }
}
```

For the caller, `price` is a single suspending function. Whether the price came from the local or the remote source is a detail the caller does not see. This snippet is platform code, and here we make no claim about output, duration, or number of sources. The model builds the rule of the platform; the model is not the platform itself.

In the model, a data source is a `Tier`, and the repository is a small class that holds a list of `Tier` objects. The calling code knows only the repository.

```kotlin
// Repository.kt — the repository orders the tiers and offers a single read
class Repository(private val chain: List<Tier>) {
    fun read(label: String, key: String, fallback: String): Read = Read(label, key, chain, fallback)
}
```

We do not write the memory tier by itself either. The content of memory is a map of keys pulled from another source, and a function does that pulling.

```kotlin
// Sources.kt — extracts the map of keys provided by a data source
fun loadFrom(source: Tier, keys: List<String>): Map<String, String> =
    keys.mapNotNull { key -> source.lookup(key)?.let { key to it } }.toMap()
```

The calling code also sits in a single place. It asks the repository for the five values a screen reads, and it does not know which source they came from.

```kotlin
// Caller.kt — the caller knows the repository, not the data sources
fun readProfileScreen(repository: Repository): List<Read> = listOf(
    repository.read("profile-name", "profile.name", "unknown"),
    repository.read("product-price", "product.price", "unknown"),
    repository.read("theme", "settings.theme", "light"),
    repository.read("stock", "product.stock", "none"),
    repository.read("campaign", "campaign.code", "none"),
)
```

#### A Chain That Checks Memory First

We build the chain first. Disk holds three saved values. The server knows four values, and its price differs from the one on disk because it was updated on the server. The memory tier was filled by pulling `profile.name` and `product.price` from disk. The repository orders the tiers as memory, disk, network.

```kotlin
// Main.kt — the repository tries the read in the order memory, disk, network
fun main() {
    val disk = Tier("disk", mapOf("profile.name" to "Ayse", "product.price" to "120", "settings.theme" to "dark"))
    val remote = Tier("network", mapOf("profile.name" to "Ayse", "product.price" to "125", "product.stock" to "8"))
    val memory = Tier("memory", loadFrom(disk, listOf("profile.name", "product.price")))
    val repository = Repository(listOf(memory, disk, remote))
    val reads = readProfileScreen(repository)
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
profile-name: key=profile.name | value=Ayse -> memory
product-price: key=product.price | value=120 -> memory
theme: key=settings.theme | value=dark -> disk
stock: key=product.stock | value=8 -> network
campaign: key=campaign.code | value=none -> default
memory: 2
disk: 1
network: 1
default: 1
```

The five answers to the five reads spread over four origins. `profile.name` and `product.price` were found in the first tier because they were loaded into memory. `settings.theme` is not in memory, but it is on disk. `product.stock` exists only on the server. `campaign.code` is in no tier, and the fallback value supplied by the caller comes back. This lesson's origin is memory: the first read that enters the repository is answered from the tier at the head of the chain.

The value to notice is `product.price`: 120. The server holds 125, but the read never reached the server. The rule sees the order, not the freshness. Even if the value in memory is stale, the chain stops at the first match as long as the key is there.

**DL1.** The model assumes that memory is filled once from disk and does not change afterward. The size limit of a memory cache on the platform, its eviction rule, and the updating of the cache when a value is written are absent from the model; in the model, memory is only a map.

#### The Caller Does Not Know the Chain

The value of the repository pattern is that the caller stays unaware of the chain. The same `readProfileScreen` function can be called with two different repositories. The first is a three-tier chain, and the second is a chain made only of the network: like a first version that was not prepared for offline use and has no local source.

```kotlin
// Main.kt — the same caller, two different repositories
fun main() {
    val disk = Tier("disk", mapOf("profile.name" to "Ayse", "product.price" to "120", "settings.theme" to "dark"))
    val remote = Tier("network", mapOf("profile.name" to "Ayse", "product.price" to "125", "product.stock" to "8"))
    val memory = Tier("memory", loadFrom(disk, listOf("profile.name", "product.price")))
    val chains = listOf(
        "full" to listOf(memory, disk, remote),
        "network-only" to listOf(remote),
    )
    for ((name, chain) in chains) {
        println("chain: $name")
        countOrigins(readProfileScreen(Repository(chain)))
    }
}
```

```text
chain: full
memory: 2
disk: 1
network: 1
default: 1
chain: network-only
memory: 0
disk: 0
network: 3
default: 2
```

The calling function worked with both repositories without changing a single character. In the second chain, memory and disk are zero. `settings.theme` fell to the default because it is not on the server, and `campaign.code` again fell back to the fallback. The calling code did not change to add or remove a source; the only thing that changed was the origin distribution.

#### The Bounding Measurement

What happens if the repository changes the order? In a chain that checks the network first, the same keys come from the server. We read the same three keys in two orders: first memory, disk, network; then network, memory, disk.

```kotlin
// Main.kt — when the order changes, the origin of the same key changes
fun main() {
    val disk = Tier("disk", mapOf("profile.name" to "Ayse", "product.price" to "120", "settings.theme" to "dark"))
    val remote = Tier("network", mapOf("profile.name" to "Ayse", "product.price" to "125", "product.stock" to "8"))
    val memory = Tier("memory", loadFrom(disk, listOf("profile.name", "product.price")))
    val orders = listOf(
        "memory-first" to listOf(memory, disk, remote),
        "network-first" to listOf(remote, memory, disk),
    )
    for ((name, chain) in orders) {
        println("order: $name")
        val repository = Repository(chain)
        listOf("profile.name", "product.price", "settings.theme")
            .map { repository.read(it.replace('.', '-'), it, "none") }
            .forEach { it.show() }
    }
}
```

```text
order: memory-first
profile-name: key=profile.name | value=Ayse -> memory
product-price: key=product.price | value=120 -> memory
settings-theme: key=settings.theme | value=dark -> disk
order: network-first
profile-name: key=profile.name | value=Ayse -> network
product-price: key=product.price | value=125 -> network
settings-theme: key=settings.theme | value=dark -> disk
```

`profile.name` gave the value `Ayse` in both orders, but the origin moved from memory to network. The value was the same; the rule does not see the value, it sees the first tier that holds the key. `product.price` changed both its origin and its value: 125 instead of 120. `settings.theme` came from disk in both orders because it is not on the network. The order decides which tier has the say; it does not decide which one is right.

What the measurement does not say is which order is better. A chain that checks memory first answers without going to the network, and a chain that checks the network first gets the current value. The rule does not weigh these two benefits; it only says which tier the answer came from.

**DL2.** The model assumes that a tier either holds the key or does not. A failure of the remote source, a timeout, or a partial response on the platform is absent from the model; in the model, the network tier either always answers or holds nothing.

#### Summary

- This lesson's origin: memory. The first read that enters the repository is answered from the tier at the head of the chain.
- The repository pattern orders local and remote data sources and offers the caller a single read.
- The same calling code runs unchanged with two different repositories; the only thing that changes is the origin distribution: 2, 1, 1, 1 versus 0, 0, 3, 2.
- When the order changes, the origin of the same key changes. `profile.name` kept its value, and `product.price` changed its value too: 125 instead of 120.
- The rule sees the order, not the freshness; a stale value in memory prevents the server from being consulted.

#### Next Step

In this lesson, the disk tier was a ready-made map. On the platform, disk is a database with rows and columns, and its schema changes over time. The next lesson, Local Database, measures which tier old rows are read from when the schema changes and what a new column falls to in old rows.

### Local Database

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/local-database

A local database keeps the data of an application on the device disk through entities and data access objects, and a migration carries old rows to the new schema when the schema changes. The lesson measures the origin of a read after a migration and what a new column falls to in old rows.

In the previous lesson the repository ordered the sources, and the disk tier was a ready-made map. On the platform, a local database stands behind this tier, and its schema grows together with the application. In this lesson we look at how the disk tier gets filled and what happens to old rows when the schema changes. The question of the lesson is the same as in the earlier lessons: **where does the answer to this read come from?**

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### The Entity, the Data Access Object, and the Database

The local database library consists of three parts. The entity is the class that describes a row of a table; each of its fields is a column. The data access object (DAO) is the interface of the queries and writes made against that table. The database class brings the entities and the schema version together and exposes the data access objects. From here on we will say only DAO.

```kotlin
// Platform code: AppDatabase
@Entity(tableName = "cart_item")
data class CartItemEntity(
    @PrimaryKey val productId: String,
    val quantity: Int,
    val stock: Int = 0,
)

@Dao
interface CartItemDao {
    @Query("SELECT * FROM cart_item WHERE productId = :id")
    suspend fun find(id: String): CartItemEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun save(item: CartItemEntity)
}

@Database(entities = [CartItemEntity::class], version = 2)
abstract class AppDatabase : RoomDatabase() {
    abstract fun cartItemDao(): CartItemDao
}
```

The version number is the identity of the schema. When the new version of the application adds a column, the number goes from 1 to 2, and the rows written by the old version stay on disk. This snippet is platform code, and here we make no claim about output, duration, or file path. The model builds the rule of the platform; the model is not the platform itself.

In the model, the database is a disk tier, and the rows are key and value pairs. A schema change affects the keys of these pairs.

#### A Migration Carries Rows

In version 1, the quantity of the cart was in the `cart.quantity` column. In version 2, this column became `cart.count`, and a new column, `product.stock`, was added. A migration is the rule that carries the rows of the old version to the schema of the new version. On the platform, a class writes it, and schema commands sit inside it.

```kotlin
// Platform code: Migration1To2
val migration1To2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE cart_item RENAME COLUMN quantity TO count")
        db.execSQL("ALTER TABLE cart_item ADD COLUMN stock INTEGER NOT NULL DEFAULT 0")
    }
}
```

```sql
ALTER TABLE cart_item RENAME COLUMN quantity TO count;
ALTER TABLE cart_item ADD COLUMN stock INTEGER NOT NULL DEFAULT 0;
```

On the platform, the `DEFAULT 0` expression writes zero into old rows. In the model, we will separate this behavior into two distinct choices. The migration function renames the keys and, if asked, writes the value of the new column into the old rows.

```kotlin
// Migrate.kt — carries old rows to the new schema, fills the new column if asked
fun migrate(
    rows: Map<String, String>,
    renames: Map<String, String>,
    backfill: Map<String, String>,
): Map<String, String> =
    rows.map { (key, value) -> (renames[key] ?: key) to value }.toMap() + backfill
```

The screen reads four values according to the schema of version 2.

```kotlin
// Screen.kt — four values read according to schema version 2
fun readCartScreen(disk: Tier): List<Read> {
    val chain = listOf(disk)
    return listOf(
        Read("profile-name", "profile.name", chain, "unknown"),
        Read("product-price", "product.price", chain, "unknown"),
        Read("cart-count", "cart.count", chain, "0"),
        Read("product-stock", "product.stock", chain, "0"),
    )
}
```

We first measure the state before the migration. Disk holds three rows written with version 1, and nobody has transformed them yet.

```kotlin
// Main.kt — without a migration, the query of version 2 looks at the rows of version 1
fun main() {
    val v1Rows = mapOf("profile.name" to "Ayse", "product.price" to "120", "cart.quantity" to "2")
    val reads = readCartScreen(Tier("disk", v1Rows))
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
profile-name: key=profile.name | value=Ayse -> disk
product-price: key=product.price | value=120 -> disk
cart-count: key=cart.count | value=0 -> default
product-stock: key=product.stock | value=0 -> default
memory: 0
disk: 2
network: 0
default: 2
```

`profile.name` and `product.price` are columns whose names did not change, and they were read from disk. The key under which the quantity sat on disk was `cart.quantity`; because the query looked for `cart.count`, the row was not found even though it exists, and the answer fell to the default. The data was not lost; the new schema does not see it under its old name.

Now we apply the migration. There is only a rename, and the new column is not filled.

```kotlin
// Main.kt — the migration carries the renamed column
fun main() {
    val v1Rows = mapOf("profile.name" to "Ayse", "product.price" to "120", "cart.quantity" to "2")
    val migrated = migrate(v1Rows, mapOf("cart.quantity" to "cart.count"), emptyMap())
    val reads = readCartScreen(Tier("disk", migrated))
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
profile-name: key=profile.name | value=Ayse -> disk
product-price: key=product.price | value=120 -> disk
cart-count: key=cart.count | value=2 -> disk
product-stock: key=product.stock | value=0 -> default
memory: 0
disk: 3
network: 0
default: 1
```

The quantity is now read from disk, and its value is 2. This lesson's origin is disk: the answer for a migrated row belongs to disk. `product.stock`, on the other hand, is not on disk because it was never written in version 1.

**DL1.** The model builds the migration as a one-step mapping: it renames a key and, if asked, adds a value. The migration passing through intermediate versions, the table being recreated, and the transaction boundaries that protect the consistency of the data during a migration are absent from the model.

#### The Bounding Measurement

What happens to a new column in old rows? If the migration does not fill it, it does not exist in the old row, and as we saw in the measurement above, that field falls to the default. The default value of a column is the fourth parameter of `Read`. If the migration fills the column with a rule such as `DEFAULT 0`, the situation changes.

```kotlin
// Main.kt — the same value, two migrations: an unfilled column is default, a filled column is disk
fun main() {
    val v1Rows = mapOf("profile.name" to "Ayse", "product.price" to "120", "cart.quantity" to "2")
    val renames = mapOf("cart.quantity" to "cart.count")
    val plans = listOf(
        "without-backfill" to emptyMap<String, String>(),
        "with-backfill" to mapOf("product.stock" to "0"),
    )
    for ((name, backfill) in plans) {
        println("migration: $name")
        val migrated = migrate(v1Rows, renames, backfill)
        readCartScreen(Tier("disk", migrated)).filter { it.key == "product.stock" }.forEach { it.show() }
    }
}
```

```text
migration: without-backfill
product-stock: key=product.stock | value=0 -> default
migration: with-backfill
product-stock: key=product.stock | value=0 -> disk
```

In both migrations the value of `product.stock` is 0. The origin, however, differs: in one it is the fallback value of the reader, and in the other it is a row written on disk. The rule does not see the value; it sees the first tier that holds the key. The benefit of this is that one can tell apart the rows in which a column was written from the rows in which it only appears to be. The person building the model sees that the backfill did not change the value, but it changed the owner of the answer.

What this measurement does not say is which migration is right. Writing a value into old rows grows the database and lengthens the migration; not writing it requires a fallback value everywhere the field is read. The rule does not weigh this cost.

**DL2.** The model treats a migration as either applied or not applied. A migration being interrupted halfway, a migration definition being incomplete, or the database being rolled back to an old version are absent from the model; in these cases the platform raises an error, and the model does not build that error.

#### Summary

- This lesson's origin: disk. The read of a migrated row is answered from the disk tier.
- The entity carries the row, the data access object carries the query, and the database class carries the schema version.
- A migration carries old rows to the new schema; a row that was not carried is not found when it is searched under the new name and falls to the default: disk 2, default 2.
- With the rename migration, the quantity is read from disk with the value 2: disk 3, default 1.
- A new column that is not filled falls to the default, and a filled one becomes disk; in both cases the value is 0 and the origin differs.

#### Next Step

In this lesson, disk was a database with rows and columns. Not every piece of data is structured enough to enter a table: small preferences such as theme, language, and notification are written under a single key. The next lesson, Key–Value Storage, measures the origin from which a preference that was never written is read and how the origin changes when the user writes it.

### Key–Value Storage

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/key-value-storage

A key–value store keeps small preferences under a single key; reads and writes are asynchronous, and the value arrives as a flow. The lesson measures the origin of a read of a preference that was never written and how that origin changes when the user writes it.

In the previous lesson a migration carried the rows of a table to the new schema. Not every piece of data is structured enough to enter a table: preferences such as theme, language, and whether notifications are on are each a single value written under a single key. In this lesson we look at that small data, and the moment we care about most is the one when the user has not yet written anything. The question of the lesson is the same as in the earlier lessons: **where does the answer to this read come from?**

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### Preference Data

A key–value store binds values to string keys instead of to a table. There is no schema; a key either exists or it does not. A preference is a small setting written into this store. The preference store of the platform has two properties. The first is that reading and writing are asynchronous: both are suspending functions, and they do not block the main thread. The second is that a read arrives as a flow rather than as a one-time value: every time the preference changes, a new value comes out of the flow.

```kotlin
// Platform code: ThemePreferences
class ThemePreferences(private val dataStore: DataStore<Preferences>) {
    private val themeKey = stringPreferencesKey("settings.theme")
    val theme: Flow<String> = dataStore.data.map { it[themeKey] ?: "light" }
    suspend fun setTheme(value: String) {
        dataStore.edit { it[themeKey] = value }
    }
}
```

The `theme` flow produces the value `"light"` if the key was not written. This line is the subject of the lesson itself: the preference store gives no value for an unwritten key, and the code that reads it picks a fallback with `?:`. This snippet is platform code, and here we make no claim about output, duration, or file path. The model builds the rule of the platform; the model is not the platform itself.

In the model, the preference store is a disk tier. A write is a function that returns a new map without changing the old one.

```kotlin
// Preferences.kt — a write returns a new map without changing the old one
fun write(store: Map<String, String>, key: String, value: String): Map<String, String> =
    store + (key to value)
```

The settings screen reads three preferences. Each has its own fallback value: light for theme, English for language, yes for notification.

```kotlin
// Settings.kt — the three preferences the settings screen reads
fun readSettings(disk: Tier): List<Read> {
    val chain = listOf(disk)
    return listOf(
        Read("theme", "settings.theme", chain, "light"),
        Read("language", "settings.language", chain, "en"),
        Read("notification", "notification.enabled", chain, "yes"),
    )
}
```

#### A Preference That Was Never Written

The user opened the application for the first time. In the preference store there is only the name of the profile; none of the settings keys were written.

```kotlin
// Main.kt — a preference that was never written is read from the fallback value
fun main() {
    val store = write(emptyMap(), "profile.name", "Ayse")
    val reads = readSettings(Tier("disk", store))
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
theme: key=settings.theme | value=light -> default
language: key=settings.language | value=en -> default
notification: key=notification.enabled | value=yes -> default
memory: 0
disk: 0
network: 0
default: 3
```

All three of the three reads fell to the default. The disk tier gave zero matches because none of the settings keys were written. This lesson's origin is default: the preference store is a tier that does not answer unless something is written.

A distinction is needed here. In the Android Platform and Tools course (M31/K02), the case in which the platform could not find the requested source and gave another one was classified as a substitute. The default in this lesson is not that substitute: there the platform picks another value, and the chosen value could differ from the requested one; here no tier holds the key, and the code that reads brings the fallback value itself.

**DL1.** The model assumes that the preference store holds only written keys. The deletion of a key, a preference being written with a different type, and the corruption of the store file are absent from the model.

#### When the User Writes

The user sets the theme to dark. The write returns a new map without changing the old one, and the theme key is now on disk.

```kotlin
// Main.kt — when the user writes the theme, the same read comes from disk
fun main() {
    val store = write(write(emptyMap(), "profile.name", "Ayse"), "settings.theme", "dark")
    val reads = readSettings(Tier("disk", store))
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
theme: key=settings.theme | value=dark -> disk
language: key=settings.language | value=en -> default
notification: key=notification.enabled | value=yes -> default
memory: 0
disk: 1
network: 0
default: 2
```

The theme is read from disk, and its value is dark. Language and notification did not change; they are still default. The reading code stayed the same; only the content of the store changed.

#### The Bounding Measurement

What happens if the written value is the same as the fallback value? Or if an empty string is written? We try three cases at the same time. The theme is written as `light`, the same value as its fallback. The language is written as an empty string. The notification is not written.

```kotlin
// Main.kt — a value equal to the fallback and an empty string are also written preferences
fun main() {
    val written = write(write(emptyMap(), "settings.theme", "light"), "settings.language", "")
    val reads = readSettings(Tier("disk", written))
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
theme: key=settings.theme | value=light -> disk
language: key=settings.language | value= -> disk
notification: key=notification.enabled | value=yes -> default
memory: 0
disk: 2
network: 0
default: 1
```

The theme is still `light`, and this time it is from disk. The fallback value and the written value were the same; the origin changed because the key is now written. The value of the language is an empty string, and its origin is disk as well. An empty string is an answer, not a default: `Tier.lookup` does not count an empty string as `null`; it says that the tier holds the key. The read did not fall to the fallback value `en`.

The benefit of this measurement is that it makes the distinction between "the preference is empty" and "the preference was never written" visible. If the user left the language empty on purpose, the fallback value must not override that decision. What the rule does not say is whether an empty string is meaningful for the application; that is told not by the lesson but by the code that uses that preference.

**DL2.** The model stores a preference as a string and counts an empty string as a valid value. The definition of a preference as a number, a boolean, or a string, and the need for a different key object depending on the type, are absent from the model.

#### Summary

- This lesson's origin: default. No tier holds a preference that was never written, and the code that reads it supplies the fallback value.
- A key–value store keeps small preferences under a single key; on the platform, a read is a flow and a write is a suspending function.
- In an empty store all three of the three reads are default: disk 0, default 3.
- When the user writes a preference, the same read becomes disk: disk 1, default 2. If a value equal to the fallback is written, the value stays the same and the origin becomes disk.
- An empty string is a written preference; its read comes from disk and does not fall to the fallback value.

#### Next Step

In this lesson, the disk tier was a small store with single keys. Sometimes an application stores a file such as an image or a document, and a file can sit in a directory that belongs only to the application or in a shared place that other applications also see. The next lesson, File and Media Storage, measures the origin from which a file in the application's own directory is read and why the file of another application does not enter the chain.

### File and Media Storage

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/file-and-media-storage

An application reaches the files in its own directory directly and the media of others through the media store and permission rules. The lesson measures the origin of a read of a file in the app-specific directory and what a file that does not enter the chain falls to.

In the previous lesson the disk tier was a small store of preferences. An application sometimes keeps a photo, a document, or an invoice, and these files do not fit in a row or in a single string. Where the file sits matters as well: in the directory of the application itself, or in a shared place that other applications also see? The question of the lesson is the same as in the earlier lessons: **where does the answer to this read come from?**

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### The App-Specific Directory

Every application has a directory given by the platform that belongs only to itself. This directory is called the app-specific directory. The application asks for no special permission to write files there or to read files from there; other applications cannot see that directory. The directory is also deleted when the application is deleted.

```kotlin
// Platform code: PrivateFiles
class PrivateFiles(private val context: Context) {
    fun save(name: String, bytes: ByteArray) {
        File(context.filesDir, name).writeBytes(bytes)
    }

    fun read(name: String): ByteArray? {
        val file = File(context.filesDir, name)
        return if (file.exists()) file.readBytes() else null
    }
}
```

`read` returns `null` if the file does not exist; that is, this class, like the stores in the earlier lessons, gives no answer for something that was not written. This snippet is platform code, and here we make no claim about output, file path, or size. The model builds the rule of the platform; the model is not the platform itself.

In the model, the directory is a function that extracts a map from file names. Each file is bound to a key with the `file.` prefix; the value is the path of the file inside the directory. No real file is read or written.

```kotlin
// Files.kt — extracts a map of keys and paths from the file names in a directory
fun indexDirectory(directory: String, files: List<String>): Map<String, String> =
    files.associate { name -> "file.${name.substringBeforeLast('.')}" to "$directory/$name" }
```

A screen asks for three files: a profile photo, an invoice, and an image chosen from the gallery.

```kotlin
// Gallery.kt — the three files the file screen asks for
fun readFileScreen(chain: List<Tier>): List<Read> = listOf(
    Read("profile", "file.profile", chain, "none"),
    Read("invoice", "file.invoice", chain, "none"),
    Read("gallery", "file.gallery", chain, "none"),
)
```

#### A File in the Directory Comes from Disk

There are two files in the directory of the application: `profile.jpg` and `invoice.pdf`. The gallery image was not copied into the application.

```kotlin
// Main.kt — a file in the application's own directory is read from disk
fun main() {
    val privateDir = Tier("disk", indexDirectory("private", listOf("profile.jpg", "invoice.pdf")))
    val reads = readFileScreen(listOf(privateDir))
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
profile: key=file.profile | value=private/profile.jpg -> disk
invoice: key=file.invoice | value=private/invoice.pdf -> disk
gallery: key=file.gallery | value=none -> default
memory: 0
disk: 2
network: 0
default: 1
```

Two files came from disk, and the gallery image came from the fallback value. This lesson's origin is disk: a file in the directory of the application is answered by the disk tier.

**DL1.** The model derives the directory from a list of names and does not carry the content of the file; the value is only the path. The size of the file, a write being interrupted halfway, and a failure caused by a full disk are absent from the model.

#### The Media Store and Scoped Storage

The gallery image is not in the directory of the application but in the shared media of the user. The platform limits access to this media with the rule of scoped storage: an application cannot open the files of other applications directly by their path. It reaches shared media through a media store query, with the permission the user gave or with the file the user chose.

```kotlin
// Platform code: GalleryQuery
fun queryImageNames(context: Context): List<String> {
    val names = mutableListOf<String>()
    val projection = arrayOf(MediaStore.Images.Media.DISPLAY_NAME)
    context.contentResolver.query(
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, null,
    )?.use { cursor ->
        while (cursor.moveToNext()) names.add(cursor.getString(0))
    }
    return names
}
```

The query returns the images that the application is allowed to see. Without permission, the list comes back empty or the query raises an error; there is no way to open by its path a file that the application was not given permission for. This snippet is also platform code, and we make no claim about output. The model builds the rule of the platform; the model is not the platform itself.

In the model, the permission rule is not a tier. A file in the media store can sit as a `Tier` that was not added to the chain, and a tier that is not in the chain does not answer the read.

#### The Bounding Measurement

We will measure two things. The first is the thumbnail: if the profile photo was shrunk and loaded into memory, the read is answered without descending to disk. The second is the gallery image: the file exists in the media store, but it is not in the lookup chain.

The function that keeps the thumbnail in memory produces a memory value from the path of a file on disk.

```kotlin
// Thumbnails.kt — loads the thumbnail of a file read from disk into memory
fun decodeThumbnails(source: Tier, keys: List<String>): Map<String, String> =
    keys.mapNotNull { key -> source.lookup(key)?.let { key to "thumb:$it" } }.toMap()
```

```kotlin
// Main.kt — the thumbnail is in memory, the gallery image is outside the chain
fun main() {
    val privateDir = Tier("disk", indexDirectory("private", listOf("profile.jpg", "invoice.pdf")))
    val mediaStore = Tier("disk", indexDirectory("media", listOf("gallery.png")))
    val memory = Tier("memory", decodeThumbnails(privateDir, listOf("file.profile")))
    val chain = listOf(memory, privateDir)
    println("in the media store: ${mediaStore.lookup("file.gallery")}")
    val reads = readFileScreen(chain)
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
in the media store: media/gallery.png
profile: key=file.profile | value=thumb:private/profile.jpg -> memory
invoice: key=file.invoice | value=private/invoice.pdf -> disk
gallery: key=file.gallery | value=none -> default
memory: 1
disk: 1
network: 0
default: 1
```

Because the profile image was loaded into memory, it now has the memory origin, and its value carries the name of the thumbnail. The invoice still comes from disk. The gallery image is found in the media store, so the file really exists; but because it is not in the lookup chain, the answer is default. The rule looks not at whether the file exists but at the content of the chain.

If the gallery image is copied into the directory of the application, the situation changes. The file now belongs to the application and sits in the disk tier of the chain.

```kotlin
// Main.kt — when the gallery image is copied into the application's directory, it enters the chain
fun main() {
    val files = listOf("profile.jpg", "invoice.pdf", "gallery.png")
    val privateDir = Tier("disk", indexDirectory("private", files))
    val reads = readFileScreen(listOf(privateDir))
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
profile: key=file.profile | value=private/profile.jpg -> disk
invoice: key=file.invoice | value=private/invoice.pdf -> disk
gallery: key=file.gallery | value=private/gallery.png -> disk
memory: 0
disk: 3
network: 0
default: 0
```

All three of the three reads came from disk. The same file gave no answer in the media store, and it gives an answer in the directory of the application; the difference is not in the content of the file but in its place in the chain.

The counterpart on the platform is this: an application cannot read the file of another application directly even if it knows its path. The permission rule decides access to the file, and if permission was not given, the read stays unanswered even when the file sits on disk. In the model this is shown by the file never being added to the chain. What this measurement does not say is why the permission was not given or why the user denied it.

**DL2.** The model builds permission not as a rule but as membership of the chain. A permission request on the platform, the user revoking the permission later, and temporary access being granted for a single file the user chose are absent from the model.

#### Summary

- This lesson's origin: disk. The read of a file in the application's own directory is answered from the disk tier.
- The app-specific directory asks for no permission and belongs only to the application; shared media is reached through a media store query and permission.
- With two files in the directory and the gallery image not copied into the application, the reads came out as disk 2, default 1.
- When the thumbnail is loaded into memory, the profile read becomes memory: memory 1, disk 1, default 1.
- A file that is not in the chain falls to the default even if it exists in the media store; the rule sees the content of the chain, not the existence of the file.
- When the gallery image is copied into the directory of the application, the same read becomes disk: disk 3.

#### Next Step

In this lesson, the file was on the disk of the same device. Most data is on another machine and is reached with a request. The next lesson, The Network Client, measures how the response to a request is decoded to build the network tier and what a field that cannot be decoded falls to.

### The Network Client

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/network-client

A network client defines a request, passes it through interceptors, and decodes the incoming response with serialization; the decoded fields build the network tier of the read. The lesson measures the origin of a read of a decoded response and what a cached response and a field that cannot be decoded fall to.

In the previous lesson the file was on the disk of the same device. Most data, however, is on another machine and is reached with a request. In this lesson we look at how the response to that request enters the chain: the response arrives as text, it is decoded, and it becomes an answer for the read only as far as it was decoded. The question of the lesson is the same as in the earlier lessons: **where does the answer to this read come from?**

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### Request, Interceptor, Serialization

A network client does three jobs. The request definition writes, as an interface, which address is called with which parameters. The interceptor steps in between the request and the response and does things such as adding a header, writing a log, or storing the response. Serialization turns the text in the response body into the classes of the application.

```kotlin
// Platform code: ProductApi
interface ProductApi {
    @GET("product")
    suspend fun product(@Query("id") id: String, @Query("lang") lang: String): ProductDto
}

@Serializable
data class ProductDto(
    val price: String,
    val stock: Int = 0,
    val campaign: String? = null,
)
```

The request definition is written in the interface; `@GET` gives the address and `@Query` gives the parameters. The response body is decoded into `ProductDto`, and a field that is not in the body falls to the default value in the class. The interceptor is also a separate class.

```kotlin
// Platform code: CacheHeaderInterceptor
class CacheHeaderInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val response = chain.proceed(chain.request())
        return response.newBuilder()
            .header("Cache-Control", "max-age=60")
            .build()
    }
}
```

These two pieces are platform code, and here we make no claim about output, duration, or response code. The model builds the rules of the platform; the model is not the platform itself.

In the model, the request is a single line built from an address and parameters. The response is a body in which `key=value` pairs are separated by semicolons; the decoder extracts a map from this body. A piece that carries no `=` sign cannot be decoded and does not enter the map.

```kotlin
// Wire.kt — builds the request as one line, decodes the response body into a map
fun requestLine(path: String, query: Map<String, String>): String =
    "GET $path?" + query.toSortedMap().entries.joinToString("&") { "${it.key}=${it.value}" }

fun decodeResponse(body: String): Map<String, String> =
    body.split(";")
        .filter { "=" in it }
        .associate { part -> part.substringBefore("=") to part.substringAfter("=") }
```

The screen reads three values. Each has a fallback value.

```kotlin
// Product.kt — the three values the product screen reads
fun readProduct(chain: List<Tier>): List<Read> = listOf(
    Read("price", "product.price", chain, "unknown"),
    Read("stock", "product.stock", chain, "0"),
    Read("campaign", "campaign.code", chain, "none"),
)
```

#### A Decoded Response Is Network

The server returns a body with three fields. The decoder turns the body into a map, and this map is the network tier.

```kotlin
// Main.kt — the decoded response builds the map of the network
fun main() {
    println(requestLine("/product", mapOf("id" to "7", "lang" to "en")))
    val body = "product.price=125;product.stock=8;campaign.code=SUMMER10"
    val network = Tier("network", decodeResponse(body))
    val reads = readProduct(listOf(network))
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
GET /product?id=7&lang=en
price: key=product.price | value=125 -> network
stock: key=product.stock | value=8 -> network
campaign: key=campaign.code | value=SUMMER10 -> network
memory: 0
disk: 0
network: 3
default: 0
```

All three of the three fields came from the network. This lesson's origin is network: the fields of the decoded response build the network tier. The network tier is a map that exists after the request has gone and the response has been decoded; text that has not been decoded cannot answer a read.

**DL1.** The model assumes that the request always succeeds and that the whole response body arrives. A connection being dropped, a timeout, and the server returning an error code are absent from the model; in the model, the network tier is either a map or is never built.

#### The Bounding Measurement

Two things break the network origin. The first is the interceptor storing the response: if the response to an earlier request remains in a cache on disk, the next read looks at that cache. The second is a field that cannot be decoded: a piece that is in the body but that the decoder could not put into the map does not answer the read.

First the cache. The cache is a disk tier decoded from the body of an earlier request, and it comes before the network in the chain. The body in the cache is stale: the price is 120 and there is no campaign.

```kotlin
// Main.kt — a cached response comes from disk, a field not in the cache comes from the network
fun main() {
    val cachedBody = "product.price=120;product.stock=8"
    val freshBody = "product.price=125;product.stock=8;campaign.code=SUMMER10"
    val cache = Tier("disk", decodeResponse(cachedBody))
    val network = Tier("network", decodeResponse(freshBody))
    val reads = readProduct(listOf(cache, network))
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
price: key=product.price | value=120 -> disk
stock: key=product.stock | value=8 -> disk
campaign: key=campaign.code | value=SUMMER10 -> network
memory: 0
disk: 2
network: 1
default: 0
```

The price and the stock were read from the cache, that is, from disk. The price is 120; even though the current value on the server is 125, the read did not reach it. The campaign was not in the cache and came from the network. The rule sees the order, not the freshness.

Now the field that cannot be decoded. The body has a `product.stock` piece, but it has no `=` sign and no value.

```kotlin
// Main.kt — a field that cannot be decoded does not enter the network map and falls to default
fun main() {
    val body = "product.price=125;product.stock;campaign.code=SUMMER10"
    val network = Tier("network", decodeResponse(body))
    val reads = readProduct(listOf(network))
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
price: key=product.price | value=125 -> network
stock: key=product.stock | value=0 -> default
campaign: key=campaign.code | value=SUMMER10 -> network
memory: 0
disk: 0
network: 2
default: 1
```

The server sent the stock field, but the decoder could not put it into the map. For the read, that field counts as absent, and the answer is the fallback value, 0. The default value rule in serialization on the platform covers only one half of this: if a field is not in the body, the default value in the class is used. A value that is in the body but cannot be decoded raises an error in the default configuration; a separately enabled setting is needed for it to fall to the default.

What the measurement does not say is why the body is malformed, and whether 0 is a correct value for the stock. The rule says only which tier the answer came from.

**DL2.** The model builds the decoder only for the `key=value` form. Nested objects, lists, type mismatches, and the ignoring of unknown fields are absent from the model.

#### Summary

- This lesson's origin: network. The fields of the decoded response build the network tier, and the read is answered from that tier.
- A network client defines the request, passes it through interceptors, and decodes the response with serialization.
- When a response with three fields is decoded, three of the three reads are network: network 3.
- If a cached response comes before the network in the chain, the read is answered from disk: disk 2, network 1. The price stays at 120, and the rule does not see the 125 on the server.
- A field that cannot be decoded does not enter the network map; that read falls to default: network 2, default 1.

#### Next Step

In this lesson the cache was only a tier, the decoded form of an old response. Writing the response permanently to disk and making the screen read only the disk is a separate design: the basis of working offline. The next lesson, Caching and Offline Support, measures which origin a refresh that writes from the network to disk brings the read to, and why, when the network goes down, the origin stays the same while the value goes stale.

### Caching and Offline Support

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/caching-and-offline-support

In the single source of truth pattern the UI reads only the local database, and the network only refreshes the database. The lesson measures the origin of a read after a refresh, that the origin stays the same while the value goes stale when the network goes down, and what a key that is not on disk at all falls to.

In the previous lesson the response from the network was decoded and became a tier in the chain; the cache was also the decoded form of an old response. In this lesson we examine not the response sitting in the chain but its being written permanently to disk. If the network response is written to disk, the screen has no need to look at the network at all, and when the network goes down the screen keeps working. The question of the lesson is the same as in the earlier lessons: **where does the answer to this read come from?**

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### The Single Source of Truth

The single source of truth pattern sets two rules. The UI reads only the local database; it never looks directly at the network. The network only refreshes the database; it gives no data directly to the UI. Data flows in one direction: from the network to the database, from the database to the UI. This way the screen is fed from a single place, and that place works even without a network.

```kotlin
// Platform code: ProductRepository
class ProductRepository(private val dao: ProductDao, private val api: ProductApi) {
    fun products(): Flow<List<ProductEntity>> = dao.observeAll()

    suspend fun refresh() {
        try {
            dao.upsertAll(api.products())
        } catch (e: IOException) {
            // when offline, the old records stay
        }
    }
}
```

`products` only observes the database. `refresh` takes from the network and writes to the database, and if there is no network it writes nothing; when the database changes, the flow passes the new list to the UI. This piece is platform code, and here we make no claim about output, duration, or record count. The model builds this rule of the platform; the model is not the platform itself.

In the model, the refresh is a function that adds the keys requested from the network to the old map. If there is no network, the map does not change. The chain of the read has only disk in it; the network enters the chain of no read.

```kotlin
// Refresh.kt — writes the requested keys to disk if the network is reachable, leaves the disk as it is if it is not
fun refreshFrom(disk: Map<String, String>, network: Tier?, keys: List<String>): Map<String, String> {
    if (network == null) return disk
    return disk + keys.mapNotNull { key -> network.lookup(key)?.let { key to it } }
}
```

The screen reads three values and looks only at disk.

```kotlin
// Screen.kt — the UI reads only the disk
fun readProductScreen(disk: Map<String, String>): List<Read> {
    val chain = listOf(Tier("disk", disk))
    return listOf(
        Read("price", "product.price", chain, "unknown"),
        Read("stock", "product.stock", chain, "0"),
        Read("campaign", "campaign.code", chain, "none"),
    )
}
```

#### A Refresh Writes to Disk

The disk holds the price and the stock left over from the previous session. The server has updated the price. The reads of the screen before and after the refresh, side by side.

```kotlin
// Main.kt — the refresh writes the network value to disk, the read still comes from disk
fun main() {
    val disk = mapOf("product.price" to "120", "product.stock" to "9")
    val network = Tier("network", mapOf("product.price" to "125", "product.stock" to "8"))
    println("before refresh")
    readProductScreen(disk).forEach { it.show() }
    val refreshed = refreshFrom(disk, network, listOf("product.price", "product.stock", "campaign.code"))
    println("after refresh")
    val reads = readProductScreen(refreshed)
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
before refresh
price: key=product.price | value=120 -> disk
stock: key=product.stock | value=9 -> disk
campaign: key=campaign.code | value=none -> default
after refresh
price: key=product.price | value=125 -> disk
stock: key=product.stock | value=8 -> disk
campaign: key=campaign.code | value=none -> default
memory: 0
disk: 2
network: 0
default: 1
```

After the refresh the price became 125 and the stock 8, and the origin did not change: it is still disk. The network did not become the origin of a read; the network only changed the content of the disk. This lesson's origin is disk: the screen reads from disk in every case, and the effect of the network shows only in the value on the disk. Because the server does not know the campaign code, that key was not written to disk and the read stayed at default.

**DL1.** The model assumes the refresh is a single step and entirely successful: the requested keys are either all written together or not written at all. A refresh being cut halfway, two refreshes overlapping, and a database transaction failing during the write are absent from the model.

#### When the Network Goes Down

If there is no network, `refresh` writes nothing. The old records stay, and the screen keeps showing them.

```kotlin
// Main.kt — when the network goes down the read still comes from disk but the value is stale
fun main() {
    val disk = mapOf("product.price" to "120", "product.stock" to "9")
    val refreshed = refreshFrom(disk, null, listOf("product.price", "product.stock", "campaign.code"))
    val reads = readProductScreen(refreshed)
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
price: key=product.price | value=120 -> disk
stock: key=product.stock | value=9 -> disk
campaign: key=campaign.code | value=none -> default
memory: 0
disk: 2
network: 0
default: 1
```

The origin of the read did not change: the price and the stock are disk, and the counts are the same as in the measurement before the refresh. What changes when there is no network is the value: the price stayed at 120, while on the server it is 125. The screen works offline, and the value it shows is stale.

This measurement differs from the staleness measure in M30/K04. In that course, the deviation of the value a screen showed from the value of the model was judged: the screen was either current or stale. In this lesson freshness is not judged; the rule says only which tier the read came from. In both measurements the price is 120 and the origin is disk; it is not the rule but the server value we put side by side that says the value is old.

#### The Bounding Measurement

What happens to a key that is not on disk at all? The application is running for the first time, the disk is empty, and there is no network. This is the most extreme form of the measurement in which the network goes down: there is not even an old value the screen could show.

```kotlin
// Main.kt — with an empty disk and no network every read falls to default, and when the network arrives it is written to disk
fun main() {
    val network = Tier("network", mapOf("product.price" to "125", "product.stock" to "8", "campaign.code" to "SUMMER10"))
    val keys = listOf("product.price", "product.stock", "campaign.code")
    val states = listOf("no-network" to null, "with-network" to network)
    for ((name, source) in states) {
        println("first launch: $name")
        val reads = readProductScreen(refreshFrom(emptyMap(), source, keys))
        reads.forEach { it.show() }
        countOrigins(reads)
    }
}
```

```text
first launch: no-network
price: key=product.price | value=unknown -> default
stock: key=product.stock | value=0 -> default
campaign: key=campaign.code | value=none -> default
memory: 0
disk: 0
network: 0
default: 3
first launch: with-network
price: key=product.price | value=125 -> disk
stock: key=product.stock | value=8 -> disk
campaign: key=campaign.code | value=SUMMER10 -> disk
memory: 0
disk: 3
network: 0
default: 0
```

On the first launch without a network, three of the three reads are default. For a key that is not on disk, the network going down produces no value; the screen opens with the fallback values. When the network arrives, the refresh writes all three keys to disk, and the same reads this time come from disk. In the single source of truth, the network is not the origin of a read; it only makes the disk fill.

What the measurement does not say is which value is correct, and how the screen should look on a first launch without a network. The second is a design decision, and the rule does not give it.

**DL2.** The model assumes that the network is either never reachable or gives all the requested keys. A partial response, a slow connection, and when a refresh waiting for the network to return is retried are absent from the model.

#### Summary

- This lesson's origin: disk. In the single source of truth the UI reads only the database, and the network does not enter the chain of the read.
- The refresh writes from the network to disk; even though after the refresh the price is 125 and the stock 8, the origin stays disk: disk 2, default 1.
- When the network goes down the refresh writes nothing; the read is still disk, but the price stays at 120 and is stale.
- Freshness is not judged here; the rule says not whether the value is current but which tier it came from.
- A key that is not on disk at all is default on a first launch without a network: default 3; when the network arrives it becomes disk 3.

#### Next Step

In this lesson the screen read a single product, and the disk could hold it in one go. When the list grows, bringing all of it at once fits neither the network nor memory; the data is loaded piece by piece. The next lesson, Paged Data Loading, measures which origin an item on a page that has not yet been loaded is read from, and how the origin changes when the page is loaded.

### Paged Data Loading

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/paged-data-loading

A long list is loaded not all at once but page by page; each page is requested with a page key. The lesson measures the origin of a read of an item on a page that has not yet been loaded, how the origin changes when the page is loaded, and the same item coming from the network or from disk.

In the previous lesson the screen read a single product, and the disk could hold it in one go. A product list can be thousands of rows, and asking for all of it at once is too much for both the network and memory. For this reason the list is loaded page by page: as the user scrolls down, the next page is requested. In this lesson we look at that moment, that is, at the part of the list that has not yet been loaded. The question of the lesson is the same as in the earlier lessons: **where does the answer to this read come from?**

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### Loading Page by Page

Paged loading has two roles. The paging source, given a page key, fetches the items of that page and says the key of the next page. The pager calls the paging source as the user moves through the list and passes the arriving pages to the UI as a flow. The page key can be a page number, a cursor, or the identity of the last item.

```kotlin
// Platform code: ProductPagingSource
class ProductPagingSource(private val api: ProductApi) : PagingSource<Int, ProductDto>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ProductDto> {
        val page = params.key ?: 0
        val items = api.products(page = page, size = params.loadSize)
        return LoadResult.Page(
            data = items,
            prevKey = if (page == 0) null else page - 1,
            nextKey = if (items.isEmpty()) null else page + 1,
        )
    }
}
```

`load` asks for the page whose key is given; if the list comes back empty, the next key is `null` and loading ends. This piece is platform code, and here we make no claim about output, page size, or duration. The model builds this rule of the platform; the model is not the platform itself.

```kotlin
// Platform code: ProductPager
val products: Flow<PagingData<ProductDto>> = Pager(
    config = PagingConfig(pageSize = 20),
    pagingSourceFactory = { ProductPagingSource(api) },
).flow
```

The pager builds the paging source together with a configuration and gives the result as a flow; the UI collects this flow. This piece is also platform code, and we make no claim about output. The model builds this rule of the platform; the model is not the platform itself.

In the model, a page is a map of consecutive keys taken from the source. Given page `n` and size `s`, the items between `n*s+1` and `(n+1)*s` are loaded. An item that is not in the source does not enter the map.

```kotlin
// Pages.kt — takes the items of a page from the source and turns them into a map
fun loadPage(source: Tier, page: Int, size: Int): Map<String, String> =
    ((page * size + 1)..((page + 1) * size)).mapNotNull { n ->
        source.lookup("product.$n")?.let { "product.$n" to it }
    }.toMap()
```

The screen reads sequential items. The fallback value of each item is the same placeholder.

```kotlin
// List.kt — the first n items the list screen reads
fun readList(chain: List<Tier>, count: Int): List<Read> =
    (1..count).map { n -> Read("item$n", "product.$n", chain, "loading") }
```

#### An Unloaded Page Is Default

The server has nine products and the page size is three. Only the first page was loaded from the network; the screen draws a list of six rows.

```kotlin
// Main.kt — an item on an unloaded page is read from the placeholder
fun main() {
    val names = listOf("Pencil", "Notebook", "Eraser", "Ruler", "Scissors", "Tape", "Stapler", "Candle", "Clip")
    val server = Tier("network", names.withIndex().associate { (i, name) -> "product.${i + 1}" to name })
    val loaded = Tier("network", loadPage(server, 0, 3))
    val reads = readList(listOf(loaded), 6)
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
item1: key=product.1 | value=Pencil -> network
item2: key=product.2 | value=Notebook -> network
item3: key=product.3 | value=Eraser -> network
item4: key=product.4 | value=loading -> default
item5: key=product.5 | value=loading -> default
item6: key=product.6 | value=loading -> default
memory: 0
disk: 0
network: 3
default: 3
```

The three items on the loaded page came from the network, and the three items on the page that was not loaded came from the placeholder. This lesson's origin is default: even if `product.4` exists on the server, no tier carries an item whose page has not been loaded. The rule looks not at whether the item exists on the server but at the content of the chain.

**DL1.** The model counts a page as loaded in one step and entirely: the three items of a page either all arrive together or do not arrive at all. A page returning an error while loading, being retried, an insertion in the middle of the list, and how placeholders are drawn in the UI are absent from the model.

#### As the User Scrolls

The user scrolled down the list and the second page was also loaded. The network tier in the chain now carries the items of two pages.

```kotlin
// Main.kt — when the second page is loaded, the placeholders become network
fun main() {
    val names = listOf("Pencil", "Notebook", "Eraser", "Ruler", "Scissors", "Tape", "Stapler", "Candle", "Clip")
    val server = Tier("network", names.withIndex().associate { (i, name) -> "product.${i + 1}" to name })
    val loaded = Tier("network", loadPage(server, 0, 3) + loadPage(server, 1, 3))
    val reads = readList(listOf(loaded), 8)
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
item1: key=product.1 | value=Pencil -> network
item2: key=product.2 | value=Notebook -> network
item3: key=product.3 | value=Eraser -> network
item4: key=product.4 | value=Ruler -> network
item5: key=product.5 | value=Scissors -> network
item6: key=product.6 | value=Tape -> network
item7: key=product.7 | value=loading -> default
item8: key=product.8 | value=loading -> default
memory: 0
disk: 0
network: 6
default: 2
```

The fourth, fifth, and sixth items now come from the network. The page of the seventh and eighth items has still not been loaded and remains as a placeholder. As pages are loaded, the default count falls and the network count rises.

#### The Bounding Measurement

Two tiers can carry the same item. The first page was written to disk from the previous session, and as the user scrolls both pages are loaded from the network. Which tier comes first determines the origin of the same item.

```kotlin
// Main.kt — if the same item is on both disk and network, the order in the chain determines the origin
fun main() {
    val names = listOf("Pencil", "Notebook", "Eraser", "Ruler", "Scissors", "Tape", "Stapler", "Candle", "Clip")
    val server = Tier("network", names.withIndex().associate { (i, name) -> "product.${i + 1}" to name })
    val cached = Tier("disk", loadPage(server, 0, 3))
    val fetched = Tier("network", loadPage(server, 0, 3) + loadPage(server, 1, 3))
    val chains = listOf("disk-first" to listOf(cached, fetched), "network-first" to listOf(fetched, cached))
    for ((name, chain) in chains) {
        println("chain: $name")
        val reads = readList(chain, 6)
        reads.forEach { it.show() }
        countOrigins(reads)
    }
}
```

```text
chain: disk-first
item1: key=product.1 | value=Pencil -> disk
item2: key=product.2 | value=Notebook -> disk
item3: key=product.3 | value=Eraser -> disk
item4: key=product.4 | value=Ruler -> network
item5: key=product.5 | value=Scissors -> network
item6: key=product.6 | value=Tape -> network
memory: 0
disk: 3
network: 3
default: 0
chain: network-first
item1: key=product.1 | value=Pencil -> network
item2: key=product.2 | value=Notebook -> network
item3: key=product.3 | value=Eraser -> network
item4: key=product.4 | value=Ruler -> network
item5: key=product.5 | value=Scissors -> network
item6: key=product.6 | value=Tape -> network
memory: 0
disk: 0
network: 6
default: 0
```

The values are the same in both chains; only the origin changes. When disk comes first, the three items of the first page are read from disk and the three items of the second page from the network. When the network comes first, six of the six items are from the network. After the page is loaded, the origin of the same item depends on which tier the page was loaded from and on the order of the chain.

What the measurement does not say is which of the two chains is correct, and whether the one on disk is older than the one on the network. The rule sees the order, not the freshness.

**DL2.** The model assumes that the two tiers use the same page boundaries. The page sizes on the network and on disk being different, the paging source combining a remote and a local source, and a refresh loading the list from the start are absent from the model.

#### Summary

- This lesson's origin: default. No tier carries an item on a page that has not yet been loaded, and the screen shows the placeholder.
- The paging source fetches a page with a page key; the pager calls the source as the user scrolls and passes the pages on as a flow.
- With only the first page loaded, a list of six rows gave network 3, default 3.
- When the second page is loaded, the fourth through sixth items become network: in eight rows, network 6, default 2.
- If the same item is on both disk and network, the order of the chain determines its origin: if disk comes first, disk 3, network 3; if the network comes first, network 6.

#### Next Step

In this lesson the pages were loaded as the user scrolled, while the application was open. Some data, however, must be refreshed even when the user is not looking at the screen: at a moment when the network is available, during charging, or while the application is closed. The next lesson, Background Work, measures under which constraint these jobs run and which origin a job that writes to disk brings the read to when it runs.

### Background Work

Source: https://academia.sh/en/courses/architecture-components-and-data-layer/background-work

A deferrable job, once its constraints are satisfied, pulls from the network and writes to disk, and the next read of the screen comes from disk. The lesson measures the origin of the read once the constraints are satisfied, what the read falls to when they are not, and the distribution of origins across the fourteen lessons of the course.

In the previous lesson the pages were loaded as the user scrolled, while the application was open. Some data, however, must be refreshed even when the user is not looking at the screen: at a moment when the network is available, during charging, or while the application is closed. In this lesson we assume that the refresh is done not by the screen but by a deferrable job. The question of the lesson is the same as in the earlier lessons: **where does the answer to this read come from?**

```kotlin
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
    init {
        require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
    }

    fun lookup(key: String): String? = entries[key]
}

class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
    private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
    val value: String = hit?.lookup(key) ?: fallback
    val origin: String = hit?.origin ?: "default"

    fun show() {
        println("$label: key=$key | value=$value -> $origin")
    }
}

fun countOrigins(reads: List<Read>) {
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${reads.count { it.origin == name }}")
    }
}
```

#### Deferrable Work and Constraints

Background work is a job that the user is not waiting for on a screen and that does not need to finish immediately. Because such a job is deferrable, the system can leave it to a suitable moment. The constraints say when the job may run: being connected to the network, being on charge. The job does not run until all the constraints are satisfied; once they are satisfied, the system decides when to run the job.

```kotlin
// Platform code: RefreshWorker and the requests
class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result = try {
        repository.refresh()
        Result.success()
    } catch (e: IOException) {
        Result.retry()
    }
}

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresCharging(true)
    .build()
val once = OneTimeWorkRequestBuilder<RefreshWorker>().setConstraints(constraints).build()
val periodic = PeriodicWorkRequestBuilder<RefreshWorker>(1, TimeUnit.DAYS).setConstraints(constraints).build()
```

This piece is platform code, and here we make no claim about output, duration, or run count. `doWork` calls the `refresh` function from the earlier lessons; if there is no network, it asks for the job to be retried later. `Constraints` imposes the network connection and charging as conditions. The request can be built as one-time or periodic. The model builds this rule of the platform; the model is not the platform itself.

#### The Job in the Model

In the model, the job is a function. If all the required constraints are satisfied, it takes the requested keys from the network and writes them to disk; if they are not, it leaves the disk as it is. The screen reads only the disk, as in the previous lesson.

```kotlin
// Work.kt — writes the requested keys to disk if all constraints are satisfied, leaves the disk if they are not
fun runWork(
    required: Set<String>,
    satisfied: Set<String>,
    disk: Map<String, String>,
    network: Tier,
    keys: List<String>,
): Map<String, String> {
    if (!satisfied.containsAll(required)) return disk
    return disk + keys.mapNotNull { key -> network.lookup(key)?.let { key to it } }
}
```

```kotlin
// Screen.kt — the home screen reads only the disk
fun readHomeScreen(disk: Map<String, String>): List<Read> {
    val chain = listOf(Tier("disk", disk))
    return listOf(
        Read("price", "product.price", chain, "unknown"),
        Read("stock", "product.stock", chain, "0"),
        Read("campaign", "campaign.code", chain, "none"),
    )
}
```

#### Once the Constraints Are Satisfied, It Writes to Disk

The disk is empty. The job asks for two constraints: `network-connected` and `charging`. Both are satisfied, and the server knows all three keys.

```kotlin
// Main.kt — once the constraints are satisfied the job writes the network values to disk, the next read comes from disk
fun main() {
    val required = setOf("network-connected", "charging")
    val network = Tier("network", mapOf("product.price" to "125", "product.stock" to "8", "campaign.code" to "SUMMER10"))
    val keys = listOf("product.price", "product.stock", "campaign.code")
    println("before the work runs")
    readHomeScreen(emptyMap()).forEach { it.show() }
    val disk = runWork(required, required, emptyMap(), network, keys)
    println("after the work runs")
    val reads = readHomeScreen(disk)
    reads.forEach { it.show() }
    countOrigins(reads)
}
```

```text
before the work runs
price: key=product.price | value=unknown -> default
stock: key=product.stock | value=0 -> default
campaign: key=campaign.code | value=none -> default
after the work runs
price: key=product.price | value=125 -> disk
stock: key=product.stock | value=8 -> disk
campaign: key=campaign.code | value=SUMMER10 -> disk
memory: 0
disk: 3
network: 0
default: 0
```

Before the job ran, three of the three reads were default. After the job ran, the same three reads came from disk. The network did not enter the chain of the read: the network only filled the disk, and the screen read from disk. This lesson's origin is disk. The refresh was done not when the screen opened but by a job before the screen opened.

**DL3.** The model takes the moment the constraints are satisfied as a given set and runs the job at that moment in a single step. When the system starts the job, the job being cut halfway, and the same job being queued twice are absent from the model.

#### One-Time and Periodic Work

One-time work runs once. Periodic work runs again in every period. Their effect on the read of the screen separates when the server value changes. The disk starts with 120 and 9; the server first says 125 and 8, then 130 and 6.

```kotlin
// Main.kt — one-time work refreshes the disk once, periodic work refreshes it every period
fun main() {
    val required = setOf("network-connected")
    val start = mapOf("product.price" to "120", "product.stock" to "9")
    val first = Tier("network", mapOf("product.price" to "125", "product.stock" to "8"))
    val second = Tier("network", mapOf("product.price" to "130", "product.stock" to "6"))
    val keys = listOf("product.price", "product.stock")
    val afterFirst = runWork(required, required, start, first, keys)
    println("one-time: after the first run")
    readHomeScreen(afterFirst).forEach { it.show() }
    println("one-time: server changed, work did not run again")
    readHomeScreen(afterFirst).forEach { it.show() }
    val afterSecond = runWork(required, required, afterFirst, second, keys)
    println("periodic: after the second run")
    readHomeScreen(afterSecond).forEach { it.show() }
}
```

```text
one-time: after the first run
price: key=product.price | value=125 -> disk
stock: key=product.stock | value=8 -> disk
campaign: key=campaign.code | value=none -> default
one-time: server changed, work did not run again
price: key=product.price | value=125 -> disk
stock: key=product.stock | value=8 -> disk
campaign: key=campaign.code | value=none -> default
periodic: after the second run
price: key=product.price | value=130 -> disk
stock: key=product.stock | value=6 -> disk
campaign: key=campaign.code | value=none -> default
```

The one-time job made the price 125, and even though the server says 130, the screen showed 125: because the job did not run again, the disk did not change. The periodic job made the disk 130 and 6 on its second run. In all three stages the origin is the same: disk. What changes is the value of the disk. The rule does not say whether the value is current; we see the old one because we put the server value side by side.

#### The Bounding Measurement

What happens if a constraint is not satisfied? We try two cases. In the first, the disk is empty and there is no charging. In the second, the disk carries values left from an earlier run and there is no network. In both, the job cannot find the required constraints.

```kotlin
// Main.kt — the job does not run unless the constraints are satisfied, the read stays default or old disk
fun main() {
    val required = setOf("network-connected", "charging")
    val network = Tier("network", mapOf("product.price" to "125", "product.stock" to "8", "campaign.code" to "SUMMER10"))
    val keys = listOf("product.price", "product.stock", "campaign.code")
    val old = mapOf("product.price" to "120", "product.stock" to "9")
    val states = listOf(
        Triple("empty disk, no charging", emptyMap<String, String>(), setOf("network-connected")),
        Triple("old disk, no network", old, setOf("charging")),
    )
    for ((name, disk, satisfied) in states) {
        println(name)
        val reads = readHomeScreen(runWork(required, satisfied, disk, network, keys))
        reads.forEach { it.show() }
        countOrigins(reads)
    }
}
```

```text
empty disk, no charging
price: key=product.price | value=unknown -> default
stock: key=product.stock | value=0 -> default
campaign: key=campaign.code | value=none -> default
memory: 0
disk: 0
network: 0
default: 3
old disk, no network
price: key=product.price | value=120 -> disk
stock: key=product.stock | value=9 -> disk
campaign: key=campaign.code | value=none -> default
memory: 0
disk: 2
network: 0
default: 1
```

On the empty disk the job did not run, so three of the three reads stayed default. On the old disk two reads came from disk with old values, and the campaign stayed default. The server knew the new values, but the job did not carry them to disk. The rule does not see this: the chain has only disk in it, and the answer is given by the disk or by the fallback value.

What the measurement does not say is how long it will take for the constraints to be satisfied and what the screen should show until then. The first is a decision of the platform, the second a decision of design.

**DL4.** The model takes a constraint as either satisfied or not satisfied. A constraint being partly satisfied, being satisfied and then broken again, and the way the job waits until a constraint is satisfied are absent from the model.

#### Closing Table

The fourteen lessons of the course asked the same question. The read each lesson measured, the origin of the lesson, and its bounding measurement are in the table. The origin column is the one written in the `Summary` section of each lesson.

| Lesson | Measured read | Origin | Bounding measurement |
|---|---|---|---|
| The Layer Model | The price passing through the domain layer | network | The old value in memory answers even though the network says a new one |
| The View Model | Count and price on rotation | memory | On process death the reads fall to the network and to default |
| State Holders | The saved search text and tab | disk | A field that is not saved falls to default or to the network |
| Observable State | The price read before loading finishes | default | When loading finishes, the price and the stock come from memory |
| Unidirectional Data Flow | The count the event set | memory | Before the event arrives, four reads are default |
| Dependency Injection | The header in the chain the container built | network | The fake answers from the memory tier |
| The Repository Pattern | The first read to enter the repository | memory | When the chain order changes, the origin moves to the network |
| Local Database | The row that went through the migration | disk | A field that was not written is default, and once it is filled it is disk |
| Key–Value Storage | A preference that was never written | default | A written preference and an empty string come from disk |
| File and Media Storage | A file in the application's directory | disk | A file in the media store gets no answer until it is copied |
| The Network Client | The fields of the decoded response | network | A field that cannot be decoded falls to default |
| Caching and Offline Support | The read after a refresh | disk | When the network goes down it stays disk, and the value goes stale |
| Paged Data Loading | An item on an unloaded page | default | On a loaded page the item comes from the network or from disk |
| Background Work | The read after a job that writes to disk once the constraints are satisfied | disk | If the constraints are not satisfied, default or old disk |

The block that counts the origins in the table counts a written list; it is not a measurement of the rule. We put the distribution of the eight reads of the core into the same block as well.

```kotlin
// Main.kt — counts the origins of the fourteen lessons and of the core
fun main() {
    val lessons = listOf(
        "layer-model" to "network", "view-model" to "memory", "state-holders" to "disk",
        "observable-state" to "default", "unidirectional-data-flow" to "memory",
        "dependency-injection" to "network", "repository-pattern" to "memory", "local-database" to "disk",
        "key-value-storage" to "default", "file-and-media-storage" to "disk",
        "network-client" to "network", "caching" to "disk", "paged-data-loading" to "default",
        "background-work" to "disk",
    )
    val core = mapOf("memory" to 2, "disk" to 3, "network" to 1, "default" to 2)
    println("lessons: ${lessons.size}")
    for (name in listOf("memory", "disk", "network", "default")) {
        println("$name: ${lessons.count { it.second == name }}")
    }
    println("core reads: ${core.values.sum()}")
    for ((name, n) in core) {
        println("$name: $n")
    }
}
```

```text
lessons: 14
memory: 3
disk: 5
network: 3
default: 3
core reads: 8
memory: 2
disk: 3
network: 1
default: 2
```

Of the fourteen lessons, three are memory, five are disk, three are network, and three are default. The distribution of the eight reads of the core, on the other hand, is 2, 3, 1, 2. The two are not the same thing: the core counts eight reads, the table counts fourteen lessons. The numbers looking alike is not a rule; one is not expected to predict the other.

#### Summary

- This lesson's origin: disk. Once the constraints are satisfied, the job pulls from the network and writes to disk, and the next read of the screen comes from disk.
- The difference between the job and the screen is that the job does the refresh before the screen opens; the network does not enter the chain of the read.
- One-time work refreshes the disk once, periodic work every period; as long as the job does not run, the disk stays old.
- Unless the constraints are satisfied the job does not run: the read becomes default on an empty disk, and disk with the old value on an old disk.
- The closing table puts the origins of the fourteen lessons side by side: memory 3, disk 5, network 3, default 3.

#### Course Wrap-Up

This course measured, in fourteen lessons, which tier the answer to a read in an Android application comes from: from the architecture layers to the view model, from state holders to dependency injection, from the repository to the local database, from preference and file storage to the network client, from the cache to paged loading and background work. The measure was always the same and the rule always saw only the order; freshness, correctness, and design decisions stayed outside the measure. The next course, Platform Services and Integrations, takes as its subject the relationship the application builds with platform services and external integrations.

