Data and domain layers
A repository is a policy, not a wrapper. It decides which source of truth wins, when to cache, and what a failure means — and that decision is why the data layer exists at all.
The most common architectural mistake is not a wrong pattern; it is a repository that is a thin passthrough to a Retrofit interface. A repository earns its name by owning a policy: where truth lives, how long it is trusted, and what happens when a source fails.
TL;DR#
- The data layer holds repositories, each owning one type of data, plus the data sources they use (network, database, file).
- The domain layer is optional. Add a use case when business logic is shared across ViewModels or is complex enough to name.
- Single source of truth: one source is authoritative, usually the local database, and the network refreshes it.
- Expose , not one-shot calls, when the data can change underneath the UI.
- Map between layers at the boundary — network DTOs, database entities and UI models are different types for a reason.
Mental model#
Think of a library with a stack of newspapers.
The archive (database) is the authoritative record. The newspaper delivery (network) brings new editions. A librarian (repository) decides whether the archive is current, fetches a new edition if not, and hands you the archive copy — never the fresh paper directly, because it has not been filed yet.
Where the analogy breaks#
The librarian also tells you when the archive changes. That is the Flow part: you subscribe once
and get updates, rather than asking again. A real archive cannot push, which is why the observer
pattern is the part people forget to design.
The big picture#
Text description of this diagram
The three-layer app architecture as a layered diagram. At the bottom, the data layer holds repositories plus remote and local data sources; a repository owns the policy for one type of data and is the only thing that talks to a network client or a database. Above it, an optional domain layer holds use cases — one operation each, reused across ViewModels. At the top, the UI layer holds a ViewModel that produces UiState and composables that render it. Dependencies point downward: the UI depends on the domain or data layer, and the data layer knows nothing about the UI.
The arrow direction is the important part. A repository that imports a Context is fine; one that
imports a ViewModel is a design error.
Deep dive#
A repository that earns its name#
class NotesRepository(
private val api: NotesApi,
private val dao: NotesDao,
private val io: CoroutineDispatcher = Dispatchers.IO,
) {
fun observe(query: String): Flow<List<Note>> =
dao.observeMatching(query)
.map { entities -> entities.map(NoteEntity::toDomain) }
suspend fun refresh(): Result<Unit> = withContext(io) {
runCatching {
val remote = api.list()
dao.replaceAll(remote.map(NoteDto::toEntity))
}
}
}The policy is visible in three decisions:
observereads only from the database. The UI never waits on the network, and it gets updates whenever the database changes — including from a background sync.refreshwrites the network result into the database. The network is a source of updates, not a source of truth.- Failures are returned, not thrown, so the caller decides whether to show an error or ignore it.
That is the difference between a repository and a wrapper. A wrapper would be:
// Not a repository: no policy, no cache, no single source of truth.
class NotesApiWrapper(private val api: NotesApi) {
suspend fun list() = api.list()
}Single source of truth#
The pattern above works because exactly one thing is authoritative. The alternatives fail in predictable ways:
| Design | Failure |
|---|---|
| network only | no data offline, no instant render |
| database only | never updates |
| both, read from whichever is faster | UI flickers between two truths |
| both, merged at read time | merge logic leaks into every caller |
The local-database-as-truth design wins because it makes the merge a write operation, in one place, at one time.
Room, and why the DAO returns a Flow#
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String,
val title: String,
val body: String,
val updatedAt: Long,
)
@Dao
interface NotesDao {
@Query("SELECT * FROM notes WHERE title LIKE '%' || :query || '%' ORDER BY updatedAt DESC")
fun observeMatching(query: String): Flow<List<NoteEntity>>
@Upsert
suspend fun upsertAll(notes: List<NoteEntity>)
@Query("DELETE FROM notes")
suspend fun clear()
}A Flow-returning query is observable: registers an invalidation tracker per table and
re-runs the query when the table changes. That is what makes the single-source-of-truth design work
without manual notification.
@Transaction
suspend fun replaceAll(notes: List<NoteEntity>) {
clear()
upsertAll(notes)
}@Transaction matters: without it, an observer can see the empty state between clear() and
upsertAll(), and the UI flickers.
DataStore instead of SharedPreferences#
val Context.settings: DataStore<Settings> by dataStore(
fileName = "settings.json",
serializer = SettingsSerializer,
)
class SettingsRepository(private val store: DataStore<Settings>) {
val settings: Flow<Settings> = store.data
suspend fun setTheme(theme: Theme) {
store.update { it.copy(theme = theme) }
}
}DataStore is the modern replacement for SharedPreferences: asynchronous, transactional, and
observable as a Flow. SharedPreferences reads block the calling thread on first access, which is
why it so often shows up in StrictMode reports.1
The geographic rule: map at the boundary#
// Network layer
@Serializable
data class NoteDto(
@SerialName("id") val id: String,
@SerialName("title") val title: String,
@SerialName("updated_at") val updatedAt: String,
)
// Database layer
@Entity data class NoteEntity(@PrimaryKey val id: String, val title: String, val updatedAt: Long)
// Domain / UI
data class Note(val id: String, val title: String, val updatedAt: Instant)Three types for one concept looks like duplication and is not:
- The DTO must match the wire format, including its nullability and its date encoding.
- The entity must match the schema, including indices and column names.
- The domain model must be convenient for the UI, with no serialisation concerns.
The mapping functions are the only place where those concerns meet. Skip them and a server-side rename becomes a UI refactor.
The domain layer: when it is worth it#
A use case is a class with one operator fun invoke:
class GetRecentNotes(
private val repository: NotesRepository,
private val clock: Clock,
) {
operator fun invoke(limit: Int = 20): Flow<List<Note>> =
repository.observe("")
.map { notes ->
notes.filter { it.updatedAt > clock.now().minus(7.days) }
.take(limit)
}
}Add a domain layer when:
- The same logic is needed by two ViewModels.
- The logic is complex enough that it deserves a name and its own tests.
- The logic combines several repositories.
Do not add one because a diagram has three boxes. A use case that just calls a repository method adds a file and a layer of indirection with no benefit; the official guide says the domain layer is optional for exactly this reason.2
Where WorkManager fits#
Some writes must happen even if the app exits — an upload, a sync, a message send. Those belong behind the repository too, so the UI does not know whether the write was immediate or deferred:
class OutboxRepository(
private val dao: OutboxDao,
private val workManager: WorkManager,
) {
suspend fun enqueue(message: Draft) {
dao.insert(message.toEntity())
workManager.enqueueUniqueWork(
"outbox",
ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<SendMessageWorker>().build(),
)
}
}The repository owns the decision "write locally first, then attempt delivery", and the rest of the app sees a single operation. This is also where an offline-first design stops being a slogan and becomes code.
Threading in the data layer#
Every repository method that touches disk or network is suspend or returns a Flow. That is a
contract, not a decoration:
- The caller chooses the dispatcher, or the repository pins one internally.
- Room's
suspendqueries already move off the main thread; wrapping them inwithContext(IO)again is harmless but redundant. - A
Flowfrom Room emits on the query executor, so a.mapon it runs there too unless youflowOn.
fun observe(query: String): Flow<List<Note>> =
dao.observeMatching(query)
.map { it.map(NoteEntity::toDomain) }
.flowOn(Dispatchers.Default) // mapping off the query executorRetention and invalidation#
A cache without an invalidation policy is a bug that has not happened yet. The repository is where the policy lives, and three policies cover most apps:
- Time-based: refresh if the last sync is older than N minutes. Simple, and wrong for data that changes on the server's schedule rather than yours.
- Event-based: refresh when a push arrives or a screen is opened. Correct, and requires the event to actually arrive.
- Version-based: the server returns an ETag; the client sends it and gets a 304 when nothing changed. The most efficient, and the one that requires server cooperation.
Whichever you choose, write it down in the repository. A cache policy spread across ViewModels is a cache policy nobody can reason about.
Error handling as data#
sealed interface DataResult<out T> {
data class Success<T>(val value: T) : DataResult<T>
data class Failure(val reason: Reason, val cause: Throwable? = null) : DataResult<Nothing>
}
enum class Reason { Network, NotFound, Unauthorized, Unknown }Returning a typed failure rather than a raw exception lets the UI decide what to show, and lets the domain layer decide what to retry. It also makes tests assert on a value rather than on a thrown exception, which is easier to read.
Paging, and why a list is not a query#
A list screen cannot load every row. Paging solves it by loading a window and growing it as the user scrolls:
class NotesPager(
private val dao: NotesDao,
) {
fun pager(query: String): Flow<PagingData<Note>> =
Pager(
config = PagingConfig(pageSize = 30, enablePlaceholders = false),
pagingSourceFactory = { dao.pagingSource(query) },
).flow
}The architectural point is that paging is a data-layer concern. The ViewModel collects
PagingData and the UI renders it; neither knows how many rows exist. Trying to paginate in the
ViewModel means reimplementing the windowing logic that the library already does.
When the network is the source of truth#
The single-source-of-truth rule has one legitimate exception: data that cannot be cached meaningfully. A live stock quote, a one-time verification code, a search result that is different every time.
For those, the repository returns a suspend function rather than a Flow:
suspend fun quote(symbol: String): Result<Quote> = runCatching {
api.quote(symbol).toDomain()
}The distinction is not "network versus database" but "can the UI render the last value while the next one loads?" If yes, it belongs in observable storage. If no, a one-shot call is honest.
How this connects to the rest#
The data layer is the only layer that knows about Context (for a database builder or a DataStore),
which means it is the natural home for the dependency-injection container's module definitions. The UI
layer never sees a Context, which is why ViewModels are testable on the JVM. That separation is the
payoff for all of the above.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 4 | 1.6 Donut |
|
| 5 | 2.0 Eclair |
|
| 8 | 2.2 Froyo |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 13 | 3.2 Honeycomb |
|
| 14 | 4.0 Ice Cream Sandwich |
|
| 16 | 4.1 Jelly Bean |
|
| 17 | 4.2 Jelly Bean |
|
| 18 | 4.3 Jelly Bean |
|
| 19 | 4.4 KitKat |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 22 | 5.1 Lollipop |
|
| 23 | 6.0 Marshmallow |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 27 | 8.1 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 32 | 12L |
|
| 33 | 13 Tiramisu |
|
| 34 | 14 Upside Down Cake |
|
| 35 | 15 Vanilla Ice Cream |
|
| 36 | 16 Baklava |
|
| 37 | 17 Cinnamon Bun |
|
Three steps: SharedPreferences and raw SQLite (2008), Room and DataStore (2017–2020), and the
offline-first framing that WorkManager plus observable queries made practical. Android 17's stricter
memory limits put a small premium on streaming from the database rather than holding large lists in
memory.3
Under the hood#
- SQLiteDatabase.javathe platform database API Room sits on
- ContentResolver.javathe provider client Room and DataStore can use
- Application.javathe process-wide context the data layer is built from
Curiosity corner#
- Room's invalidation tracker is per table. A query that joins two tables re-runs when either
changes, which is why
@Transactionmatters for multi-step writes. @Upsertremoves the "insert or replace" ambiguity: replace deletes the row first, which breaks foreign keys.- DataStore is transactional. A failed
updateleaves the previous value, whichSharedPreferencesdid not guarantee across a crash.
Try it#
adb shell "run-as com.example.notes ls -l databases/"
adb shell "run-as com.example.notes sqlite3 databases/notes.db '.tables'"
adb logcat -s Room
Check yourself#
Check yourself
Why model a screen as one immutable `UiState` rather than several independent `StateFlow`s?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docs应用架构:数据层 - DataStore - Android 开发者 | App architecturedeveloper.android.comaccessed 2026-10-27↩
- docsGuide to app architecturedeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩
- docsSave data in a local database using Room | App data and filesdeveloper.android.comaccessed 2026-10-27↩
- docsKotlin coroutines on Androiddeveloper.android.comaccessed 2026-10-27↩
- docsJetpackdeveloper.android.comaccessed 2026-10-27↩