Zygote
App architectureintermediate

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.

11 minAPI 21–37 · 17interactivequizverified 2026-10-27

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#

Loading diagram…
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:

  1. observe reads only from the database. The UI never waits on the network, and it gets updates whenever the database changes — including from a background sync.
  2. refresh writes the network result into the database. The network is a source of updates, not a source of truth.
  3. 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:

DesignFailure
network onlyno data offline, no instant render
database onlynever updates
both, read from whichever is fasterUI flickers between two truths
both, merged at read timemerge 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 suspend queries already move off the main thread; wrapping them in withContext(IO) again is harmless but redundant.
  • A Flow from Room emits on the query executor, so a .map on it runs there too unless you flowOn.
fun observe(query: String): Flow<List<Note>> =
    dao.observeMatching(query)
        .map { it.map(NoteEntity::toDomain) }
        .flowOn(Dispatchers.Default)     // mapping off the query executor

Retention 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#

Architecture changes29 releases
APIVersionWhat changed
11.0
  • Initial AOSP platform: Linux kernel, Dalvik VM, core libraries
  • The first zygote-based app spawning model
41.6 Donut
  • Support for different screen densities via resource qualifiers
52.0 Eclair
  • First multi-core-friendly scheduling and graphics work
82.2 Froyo
  • Trace-based JIT compiler added to Dalvik
  • Support for x86 and ARMv7 optimisations
92.3 Gingerbread
  • Concurrent garbage collector added to Dalvik (GC pause reduction)
113.0 Honeycomb
  • Hardware acceleration for the View system (opt-in per app)
133.2 Honeycomb
  • New screen-size resource qualifiers for compatibility scaling
144.0 Ice Cream Sandwich
  • One system image for phones and tablets
  • Hardware acceleration on by default
164.1 Jelly Bean
  • Project Butter: vsync, triple buffering, Choreographer
  • RenderThread architecture begins
174.2 Jelly Bean
  • Multi-user support on tablets
  • SELinux enforcement begins in the kernel (behind the scenes)
184.3 Jelly Bean
  • SELinux enforcement rolled out more broadly
  • Binder changes for restricted profiles
194.4 KitKat
  • Android Runtime (ART) appears as an experimental runtime
  • Kernel moved under a tighter vendor control
204.4W KitKat (Wear)
  • First time the platform shipped for a new form factor
215.0 Lollipop
  • ART becomes the only runtime: ahead-of-time compilation at install
  • 64-bit ARM and x86 support
  • RenderThread and OpenGL ES 3.1
  • Project Volta and JobScheduler
  • SELinux fully enforcing
225.1 Lollipop
  • Device protection ties verified boot to Google accounts
236.0 Marshmallow
  • Runtime permission model changes how every app asks for access
  • Doze batches background work
  • Fingerprint HAL
  • Adoptable storage
247.0 Nougat
  • Project Treble groundwork
  • A/B seamless updates
  • Hybrid JIT + AOT compilation in ART
  • MediaServer split into sandboxed processes
268.0 Oreo
  • Project Treble: the vendor interface separates vendor and system code
  • Background execution limits
  • Broadcast and service restrictions
  • HIDL for HALs
278.1 Oreo
  • Android Go: a lighter configuration of the same platform
289 Pie
  • App Standby Buckets
  • Mainline groundwork
  • Non-SDK interface restrictions (greylists) begin
  • BiometricPrompt
2910
  • Project Mainline: APEX and updatable system modules
  • Scoped storage
  • Bubbles
  • Project Mainline starts shipping through Play
3011
  • GKI: the Generic Kernel Image splits the kernel into core + vendor modules
  • App hibernation
  • Package visibility filtering
  • Incremental app installs
3112
  • Bluetooth permissions split into the nearby-devices family
  • Exact alarm permissions
  • Foreground service launch restrictions
3212L
  • Window size classes and activity embedding
3313 Tiramisu
  • Granular media permissions replace READ_EXTERNAL_STORAGE
  • The notification permission becomes a runtime permission
  • Predictive back gesture
3414 Upside Down Cake
  • Foreground service types are mandatory
  • Selected photos access via READ_MEDIA_VISUAL_USER_SELECTED
3515 Vanilla Ice Cream
  • 16 KB page size support for native libraries
  • Edge-to-edge is enforced when targeting 35
3616 Baklava
  • The release moves to Q2 (June) with a Q4 feature drop
  • Two SDK releases per year starting with 36
  • Desktop windowing arrives in developer options then stable
3717 Cinnamon Bun
  • Strict per-device app memory limits are enforced by the system
  • Generational garbage collection is added to ART's Concurrent Mark-Compact collector
  • A lock-free MessageQueue for apps targeting API 37
  • Compose-first: new platform and Jetpack APIs target Compose only; View libraries enter maintenance mode
  • Hybrid APK signing (v3.2) combines classical and ML-DSA post-quantum signatures
  • Native library dynamic code loading must be read-only

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#

Curiosity corner#

  • Room's invalidation tracker is per table. A query that joins two tables re-runs when either changes, which is why @Transaction matters for multi-step writes.
  • @Upsert removes the "insert or replace" ambiguity: replace deletes the row first, which breaks foreign keys.
  • DataStore is transactional. A failed update leaves the previous value, which SharedPreferences did not guarantee across a crash.

Try it#

Inspect an app's database

adb shell "run-as com.example.notes ls -l databases/" adb shell "run-as com.example.notes sqlite3 databases/notes.db '.tables'"

Watch Room queries in logcat

adb logcat -s Room

Check yourself#

Check yourself

1 / 12

Why model a screen as one immutable `UiState` rather than several independent `StateFlow`s?

0 answered · 0 correct

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.

  1. docs应用架构:数据层 - DataStore - Android 开发者 | App architecturedeveloper.android.comaccessed 2026-10-27↩
  2. docsGuide to app architecturedeveloper.android.comaccessed 2026-10-27↩
  3. docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩
  4. docsSave data in a local database using Room | App data and filesdeveloper.android.comaccessed 2026-10-27↩
  5. docsKotlin coroutines on Androiddeveloper.android.comaccessed 2026-10-27↩
  6. docsJetpackdeveloper.android.comaccessed 2026-10-27↩