The data layer contains your app's data and business rules about that data. It decides how data is created, stored and changed, and it hides where the data comes from: a network server, a local database, a file, a sensor or memory. The UI layer never needs to know. It asks a repository for data and receives it.
This lesson covers Data Layer and The Data layer - MAD Skills from the Modern Android App Architecture programme (https://developer.android.com/courses/android-architecture), based on the official page https://developer.android.com/topic/architecture/data-layer and the recommendations at https://developer.android.com/topic/architecture/recommendations.
By the end you will be able to:
- Explain the roles of repositories and data sources.
- Expose immutable data through
Flowandsuspendfunctions. - Design models per layer (network, database, domain) and map between them.
- Handle errors with
Resultor sealed types, and keep threading correct. - Implement a repository that combines Retrofit and Room.
- Add in-memory caching and multiple data sources.
- Write fakes and test repositories properly.
Big idea: a repository is a promise: "ask me for data and I will give you the best I have, without telling you how I got it".
Honesty note: Room 3 package names are used below, as in lesson 34. If your project uses Room 2.x, change
androidx.room3toandroidx.roomand follow the mapping in lesson 34.
- Layers and the dependency rule (lessons 60 and 61).
- Room, DAOs and Flow (lesson 34).
- Retrofit and Kotlin serialization (lesson 29).
- Coroutines, dispatchers and Flow (lesson 33).
- Interfaces and fakes (lesson 60).
ViewModel / Use case
│ asks
▼
Repository ← one per type of data (Students, Payments)
│ │
▼ ▼
Local data Remote data
source source
(Room DAO) (Retrofit service)
| Piece | Responsibility | Never |
|---|---|---|
| Data source | Works with one source (a DAO, a service, a file, DataStore) | Combine other sources or know the UI |
| Repository | Exposes data to the rest of the app, resolves conflicts between sources, holds business rules about data, chooses threads | Expose DAOs, DTOs or Cursors; reference ViewModels |
The official guidance lists what a repository does:
- Exposes data to the rest of the app.
- Centralises changes to the data.
- Resolves conflicts between multiple data sources.
- Abstracts sources from the rest of the app.
- Contains business logic that concerns the data.
One repository per type of data
Create StudentsRepository, PaymentsRepository, SettingsRepository. Do not create AppRepository containing everything. Each type of data has its own lifecycle, sources and rules.
Naming conventions (recommended)
| Type | Pattern | Example |
|---|---|---|
| Data type | Data type + Repository |
PaymentsRepository |
| Data source | Type + source + DataSource |
PaymentsLocalDataSource, PaymentsRemoteDataSource |
| Models | Source-specific suffixes | PaymentEntity (database), PaymentDto or NetworkPayment (network), Payment (app model) |
A single model class shared by network, database and UI looks tidy but couples everything. If the server renames amt to amount_paid, the screen would break. The recommended approach is separate models with mappers.
// Network model: shaped like the server's JSON
@Serializable
data class PaymentDto(
val id: Int,
@SerialName("student_name") val studentName: String,
@SerialName("amount_kobo") val amountKobo: Long,
@SerialName("paid_at") val paidAt: String // ISO-8601
)
// Database model: shaped like the table
@Entity(tableName = "payments")
data class PaymentEntity(
@PrimaryKey val id: Int,
val studentName: String,
val amountKobo: Long,
val paidAtEpochMillis: Long
)
// App model: shaped like what the app needs
data class Payment(
val id: Int,
val studentName: String,
val amountNaira: Long,
val paidAt: Instant
)
Mapping functions sit next to the models (extension functions are idiomatic):
fun PaymentDto.toEntity() = PaymentEntity(
id = id,
studentName = studentName,
amountKobo = amountKobo,
paidAtEpochMillis = Instant.parse(paidAt).toEpochMilli()
)
fun PaymentEntity.toModel() = Payment(
id = id,
studentName = studentName,
amountNaira = amountKobo / 100,
paidAt = Instant.ofEpochMilli(paidAtEpochMillis)
)
Notice the benefit: money is stored in kobo (a whole number, avoiding rounding errors) while the UI model uses naira. The server's date string becomes a real Instant. The ViewModel never sees PaymentDto.
When is one model enough? For tiny apps where the formats match, a single model is fine. Introduce separate models when they start to diverge. Do not create three identical classes "just in case".
Local data source (Room)
@Dao
interface PaymentDao {
@Query("SELECT * FROM payments ORDER BY paidAtEpochMillis DESC")
fun observeAll(): Flow<List<PaymentEntity>>
@Upsert
suspend fun upsertAll(payments: List<PaymentEntity>)
@Query("DELETE FROM payments")
suspend fun clear()
}
(@Upsert inserts or updates in one call; it exists in Room 2.5 and later and in Room 3. Check the current imports as in lesson 34.)
Remote data source (Retrofit)
interface PaymentApi {
@GET("payments")
suspend fun getPayments(@Query("since") since: String? = null): List<PaymentDto>
@POST("payments")
suspend fun createPayment(@Body body: NewPaymentDto): PaymentDto
}
Retrofit's suspend functions run network work off the main thread by themselves, so a call can safely be made from viewModelScope with the default dispatcher. Even so, the repository still decides which dispatcher to use for CPU work such as mapping large lists.
Wrapping with DataSource classes
Wrapping the DAO and the API in small classes keeps the repository free of framework details and easy to fake:
class PaymentsLocalDataSource(private val dao: PaymentDao) {
fun observeAll(): Flow<List<PaymentEntity>> = dao.observeAll()
suspend fun saveAll(items: List<PaymentEntity>) = dao.upsertAll(items)
}
class PaymentsRemoteDataSource(private val api: PaymentApi) {
suspend fun fetchAll(): List<PaymentDto> = api.getPayments()
suspend fun create(payment: NewPaymentDto): PaymentDto = api.createPayment(payment)
}
Some teams skip the wrapper and let the repository use the DAO or API directly, because they are already interfaces. Both are acceptable; use the wrapper when it adds a real seam for testing or hides details.
The contract
interface PaymentsRepository {
/** Observes stored payments. Emits again whenever data changes. */
fun observePayments(): Flow<List<Payment>>
/** Fetches the latest payments from the server and stores them. */
suspend fun refresh(): Result<Unit>
/** Records a payment. */
suspend fun record(studentName: String, amountNaira: Long): Result<Payment>
}
Principles in this small interface:
- Reads are
Flow: the UI gets updates automatically. - One-shot actions are
suspend: callable only from a coroutine, off the main thread by the repository's own choice. - Results are explicit: failures are values (
Result), not surprises. - Nothing mutable leaks: lists are read-only; models are immutable.
The implementation
class DefaultPaymentsRepository(
private val local: PaymentsLocalDataSource,
private val remote: PaymentsRemoteDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : PaymentsRepository {
override fun observePayments(): Flow<List<Payment>> =
local.observeAll()
.map { entities -> entities.map { it.toModel() } }
.flowOn(ioDispatcher)
override suspend fun refresh(): Result<Unit> = withContext(ioDispatcher) {
runCatchingNetwork {
val dtos = remote.fetchAll()
local.saveAll(dtos.map { it.toEntity() })
}
}
override suspend fun record(studentName: String, amountNaira: Long): Result<Payment> =
withContext(ioDispatcher) {
runCatchingNetwork {
val dto = remote.create(NewPaymentDto(studentName, amountNaira * 100))
val entity = dto.toEntity()
local.saveAll(listOf(entity))
entity.toModel()
}
}
}
Key decisions here:
- The UI observes the database, which is the single source of truth.
refresh()writes new data into the database; observers update automatically (lesson 64 develops this). ioDispatcheris injected with a default. Tests pass a test dispatcher.flowOn(ioDispatcher)affects the upstream of the flow (the DAO query and the mapping), not the collector.- The repository maps
PaymentEntitytoPayment: no database type leaves the data layer.
Network and disk failures are normal events, not exceptional ones. Decide how errors travel.
A careful runCatching
Kotlin's runCatching catches every Throwable, including CancellationException. Catching that breaks coroutine cancellation. Write a helper that rethrows it:
suspend inline fun <T> runCatchingNetwork(block: () -> T): Result<T> =
try {
Result.success(block())
} catch (e: CancellationException) {
throw e // never swallow cancellation
} catch (e: IOException) {
Result.failure(e) // no connection, timeouts
} catch (e: HttpException) {
Result.failure(e) // server answered with an error code
}
(HttpException comes from Retrofit; IOException from java.io.)
A domain-flavoured error type
When the UI should react differently (retry, sign in again, show a message), a sealed type is clearer than a raw exception:
sealed interface DataError {
data object NoConnection : DataError
data object Unauthorised : DataError
data class Server(val code: Int) : DataError
data class Unknown(val cause: Throwable) : DataError
}
class DataException(val error: DataError) : Exception()
fun Throwable.toDataError(): DataError = when (this) {
is IOException -> DataError.NoConnection
is HttpException -> if (code() == 401) DataError.Unauthorised else DataError.Server(code())
else -> DataError.Unknown(this)
}
The ViewModel maps DataError to user-friendly state ("Check your connection"). The repository does not produce UI strings; it produces data and errors, and the UI layer decides the wording and language.
What about errors in flows?
An exception thrown inside a flow terminates it. The ViewModel adds .catch { ... } (lesson 61). Inside repositories, use catch only when you can recover (for example, fall back to cached data); otherwise let it flow downstream.
In-memory cache with a mutex
For data that is read often and changes rarely (such as school settings), keep a small in-memory cache with a mutex to protect it from concurrent access:
class SettingsRepository(
private val remote: SettingsRemoteDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
private val mutex = Mutex()
private var cached: Settings? = null
suspend fun settings(forceRefresh: Boolean = false): Settings {
if (!forceRefresh) cached?.let { return it } // fast path, no lock
return mutex.withLock {
// check again: another coroutine may have loaded while we waited
if (!forceRefresh) cached?.let { return it }
withContext(ioDispatcher) { remote.fetch() }.also { cached = it }
}
}
}
Points to notice: the cache is private, the lock covers only the update, and the second check avoids loading twice.
Combining sources: the policy decides
A repository may read from several sources. State your policy clearly:
| Policy | Behaviour | Good for |
|---|---|---|
| Network-first | Try the network, fall back to disk | Data that must be fresh |
| Cache-first | Show disk instantly, refresh in the background | Feeds, lists |
| Offline-first (database as truth) | UI reads only the database; sync updates it | Apps used with poor connectivity |
Offline-first is the recommended default for many apps and is the subject of lesson 64.
Writing: where do changes go?
| Strategy | Meaning | Trade-off |
|---|---|---|
| Online-only | Send to the server; update the database on success | Simple; fails offline |
| Queued | Save locally, mark as pending, upload later (WorkManager) | Works offline; needs sync logic |
| Lazy | Save locally, sync eventually | Fast; conflict handling needed |
The guidance is to prefer fakes over mocks: a fake is a small working implementation, so tests describe behaviour instead of recorded calls.
A fake can only replace a class if callers depend on an abstraction. So make the data sources interfaces (the Room and Retrofit versions from Build 2 become implementations such as RoomPaymentsLocalDataSource), and fakes become trivial:
interface PaymentsLocalDataSource {
fun observeAll(): Flow<List<PaymentEntity>>
suspend fun saveAll(items: List<PaymentEntity>)
}
class FakeLocal : PaymentsLocalDataSource {
private val state = MutableStateFlow<List<PaymentEntity>>(emptyList())
override fun observeAll() = state
override suspend fun saveAll(items: List<PaymentEntity>) {
state.value = (state.value.associateBy { it.id } + items.associateBy { it.id }).values.toList()
}
}
class FakeRemote(var result: () -> List<PaymentDto>) : PaymentsRemoteDataSource {
override suspend fun fetchAll(): List<PaymentDto> = result()
override suspend fun create(payment: NewPaymentDto): PaymentDto = error("not needed")
}
(PaymentsRemoteDataSource is likewise turned into an interface with the production class RetrofitPaymentsRemoteDataSource.) Now the tests:
class DefaultPaymentsRepositoryTest {
private val dto = PaymentDto(1, "Ada", 150_000_00, "2026-10-01T09:00:00Z")
@Test
fun refresh_stores_remote_payments_and_observers_see_them() = runTest {
val local = FakeLocal()
val repo = DefaultPaymentsRepository(local, FakeRemote { listOf(dto) }, StandardTestDispatcher(testScheduler))
val result = repo.refresh()
assertTrue(result.isSuccess)
assertEquals(listOf("Ada"), repo.observePayments().first().map { it.studentName })
}
@Test
fun refresh_failure_returns_failure_and_keeps_old_data() = runTest {
val local = FakeLocal().apply { saveAll(listOf(dto.toEntity())) }
val repo = DefaultPaymentsRepository(local, FakeRemote { throw IOException("offline") }, StandardTestDispatcher(testScheduler))
val result = repo.refresh()
assertTrue(result.isFailure)
assertEquals(1, repo.observePayments().first().size) // old data survives
}
}
The second test captures an important rule of offline apps: a failed refresh must not erase what the user already has.
Two questions decide whether a data layer is pleasant or painful to use: who chooses the thread, and who owns the work.
Who chooses the thread?
The recommendation is that suspend functions in the data layer are main-safe. That means a caller on the main thread can call repository.refresh() without freezing the screen, because the repository switches to the right dispatcher internally. The ViewModel then needs no withContext calls and no knowledge of how slow the operation is. Room and Retrofit already move their own blocking work off the main thread, but your own mapping of thousands of rows, file reads or image processing is not covered by that promise, so wrap such work in withContext(ioDispatcher) or defaultDispatcher yourself. Use the IO dispatcher for waiting on disks and sockets, and the Default dispatcher for heavy computation such as sorting, filtering or parsing a very large list.
Who owns the work?
Most work started by a repository should last only as long as the caller is interested. If the ViewModel is cleared, a running refresh() is cancelled with it. That is correct for reads. Some work must outlive the screen, such as uploading a payment the user has just recorded: if they press back, the upload should still finish. For that case the repository needs a longer-lived scope, which you inject as an application-level CoroutineScope (a SupervisorJob plus a dispatcher), or you hand the job to WorkManager, which also survives process death and retries with back-off. Avoid GlobalScope, because nothing owns it and you cannot cancel or test it.
class DefaultPaymentsRepository(
private val local: PaymentsLocalDataSource,
private val remote: PaymentsRemoteDataSource,
private val ioDispatcher: CoroutineDispatcher,
private val appScope: CoroutineScope // lives as long as the application
) : PaymentsRepository {
suspend fun recordAndKeepUploading(studentName: String, amountNaira: Long) {
// saved locally at once; the upload continues even if the caller is cancelled
appScope.launch(ioDispatcher) { upload(studentName, amountNaira) }
}
private suspend fun upload(studentName: String, amountNaira: Long) { /* ... */ }
}
In lesson 64 you will replace this ad hoc scope with a WorkManager worker so that unsent payments survive a restart.
Exercise 1: Leaky repository
Find three problems in this interface and fix them.
interface StudentsRepository {
fun getStudents(): MutableList<StudentEntity>
fun loadFromServer(callback: (List<StudentDto>) -> Unit)
fun dao(): StudentDao
}
(1) It returns a mutable list, so callers could change shared data. (2) It exposes database and network models (StudentEntity, StudentDto) instead of app models. (3) It exposes the DAO, breaking the abstraction, and uses a callback rather than coroutines. Fix:
interface StudentsRepository {
fun observeStudents(): Flow<List<Student>>
suspend fun refresh(): Result<Unit>
}
Exercise 2: Cancellation
Why is this helper dangerous, and what is the fix?
suspend fun <T> safeCall(block: suspend () -> T): Result<T> =
try { Result.success(block()) } catch (e: Exception) { Result.failure(e) }
CancellationException is an Exception. When the coroutine is cancelled (for example the user leaves the screen), the exception is caught and turned into a failure, so the coroutine may carry on instead of stopping. Rethrow it first:
suspend fun <T> safeCall(block: suspend () -> T): Result<T> =
try { Result.success(block()) }
catch (e: CancellationException) { throw e }
catch (e: Exception) { Result.failure(e) }
Exercise 3: Policy choice
For each data type choose network-first, cache-first or offline-first: (a) the bursar's list of payments in an area with unreliable networks; (b) a live exchange rate; (c) the school's timetable that changes once a term.
(a) Offline-first: the database is the truth and sync happens when possible. (b) Network-first: stale values are misleading, with the last known value as a labelled fallback. (c) Cache-first: show stored data immediately and refresh occasionally in the background.
Mistake #1: Exposing DAOs, DTOs or entities to the UI. Fix: map to app models in the repository.
Mistake #2: Returning mutable collections. Fix: return List, copy on change.
Mistake #3: Swallowing CancellationException. Fix: rethrow it.
Mistake #4: One giant repository. Fix: one per type of data.
Mistake #5: Hard-coded dispatchers. Fix: inject them with sensible defaults.
Mistake #6: Clearing local data before the refresh succeeds. Fix: write only after success; use transactions when replacing.
Mistake #7: Putting UI text in repositories. Fix: return typed errors; the UI picks wording.
Mistake #8: Money as Double. Fix: use whole units (kobo) or BigDecimal.
Mistake #9: Mocking every call in tests. Fix: write simple fakes.
Mistake #10: Using GlobalScope for sync. Fix: use a scope the app owns, or WorkManager.
Question 1. What are the main responsibilities of a repository?
Expose data, centralise changes, resolve conflicts between sources, abstract the sources, and hold business logic about the data.
Question 2. Why use separate network, database and app models?
So that a change in one format (a renamed JSON field, a new column) does not ripple through the whole app; mappers absorb the change.
Question 3. What does flowOn(ioDispatcher) change?
It changes the context of the upstream operators (the flow's producer and earlier operators), not the collector.
Question 4. Why prefer fakes over mocks?
Fakes behave like real implementations, so tests check results and stay valid when internal calls change; mocks tie tests to specific calls.
Question 5. What must happen if a refresh fails while offline?
The existing local data must remain, and the failure is reported as a value so the UI can show a message.
Go deeper: official resources
- Modern Android App Architecture programme
- Data layer
- Offline-first apps
- Recommendations for Android architecture
Project goal: Build and test the data layer for the FeeTracker payments feature.
Requirements:
- Create
PaymentDto,PaymentEntityandPayment, with mappers in both directions where needed. - Define
PaymentsLocalDataSourceandPaymentsRemoteDataSourceas interfaces, with Room and Retrofit implementations (or a fake remote withdelayif you have no server). - Implement
DefaultPaymentsRepositorywithobservePayments(),refresh()andrecord()exposingFlowandResult. - Use the careful
runCatchingNetworkhelper and aDataErrormapping. - Inject the dispatcher.
- Add fakes and write at least five tests: successful refresh, failed refresh keeps old data, record success updates observers, cancellation is not swallowed, and mapping converts kobo to naira.
- Document the repository's sync policy in a comment at the top of the class.
Stretch goal: add an in-memory cache for settings with a mutex and test that two concurrent calls hit the remote only once.
A cancellation test and a mapping test:
class RunCatchingNetworkTest {
@Test
fun cancellation_is_rethrown() = runTest {
val job = launch {
runCatchingNetwork { throw CancellationException("stop") }
fail("should not continue")
}
job.join()
assertTrue(job.isCancelled)
}
}
class MappersTest {
@Test
fun entity_converts_kobo_to_naira() {
val entity = PaymentEntity(1, "Ada", amountKobo = 150_000_00, paidAtEpochMillis = 0)
assertEquals(150_000L, entity.toModel().amountNaira)
}
}
The concurrent-cache stretch test:
@Test
fun concurrent_calls_load_once() = runTest {
var calls = 0
val remote = object : SettingsRemoteDataSource {
override suspend fun fetch(): Settings { calls++; delay(100); return Settings("ISCGS") }
}
val repo = SettingsRepository(remote, StandardTestDispatcher(testScheduler))
val a = async { repo.settings() }
val b = async { repo.settings() }
awaitAll(a, b)
assertEquals(1, calls)
}
A top-of-class policy comment (example):
Policy: offline-first. The UI observes the database only. refresh() downloads
payments and upserts them; a failed refresh never deletes local data.
record() posts to the server and, on success, stores the returned payment.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 62 project”.
On github.com click New repository, name it android-lesson-62-the-data-layer-repositories-data-sources-a, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 62 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Repositories and Data Sources
- Step-by-Step Build 1: Models for Each Layer
- Step-by-Step Build 2: Data Sources
- Step-by-Step Build 3: The Repository
- Step-by-Step Build 4: Errors
- Step-by-Step Build 5: Caching and Multiple Sources
- Step-by-Step Build 6: Testing with Fakes
- Step-by-Step Build 7: Threading and Ownership Rules
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check