Lesson 07 / 14
The 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.
Published Updated
In this lesson
- Explains that the repository pattern separates the calling code from the data sources
- Shows that local and remote data sources form an order inside a repository
- Measures that in a repository that checks memory first, the origin of the answer is memory
- Shows that when the order changes, the origin of the same value changes while the value may not
Contents
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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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)
}
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.
// 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)))
}
}
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.
// 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() }
}
}
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.namekept its value, andproduct.pricechanged 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.