Skip to content
academia.sh

Lesson 01 / 14

The 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.

Published Updated

In this lesson

  • Separates the responsibilities of the UI, domain and data layers
  • Explains that dependencies point only inward and that the UI sees the data layer through the lookup chain
  • Classifies the answer to a read into one of the origins memory, disk, network and default
  • Measures that the rule does not see a stale value held in memory
Contents

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.

// 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.

// 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.

// 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)
}
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.

// 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.

// 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)
}
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.

// 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))
}
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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close