A building architect does not start by choosing the colour of the doors. First they decide where the walls go, where the pipes and wiring run, and how each part will be reached for repairs. Software has the same need. A small app can survive with everything written in one big file, but the moment you add a second screen, a database, a network call and a teammate, a lack of structure begins to cost you: bugs appear far from their cause, a change in one place breaks three others, and testing becomes impossible.
This lesson opens Modern Android App Architecture, the programme at https://developer.android.com/courses/android-architecture (Pathway: Why invest in app architecture?, Architecture Overview, Recommendations for Android architecture). It is based on the official Guide to app architecture (https://developer.android.com/topic/architecture) and the architecture recommendations page (https://developer.android.com/topic/architecture/recommendations).
By the end you will be able to:
- Explain why architecture matters in terms of cost, testability and teamwork.
- Describe separation of concerns, drive UI from data models, single source of truth and unidirectional data flow.
- Name the UI layer, data layer and optional domain layer and say what belongs in each.
- Draw the dependency direction between layers.
- Refactor a "everything in the composable" screen into layers.
- Read the official recommendations and decide which are strong and which are situational.
Big idea: architecture is the set of rules about who is allowed to know what. Good rules make the app easy to change, test and understand.
Honesty note: architecture advice is guidance, not law. The official pages label recommendations as strongly recommended or recommended. Small apps can apply them lightly, and you should never add layers that give you nothing.
- ViewModel and
StateFlow(lessons 25 and 33). - Compose state and state hoisting (lessons 19 and 37).
- Room, repositories and the app container (lessons 30 and 34).
- Coroutines and Flow (lesson 33).
- Retrofit and network data (lessons 29 and 35).
The cost of no structure
Imagine a FeeTracker app for a school bursar. In version 1, the screen fetches payments from a server, calculates balances and shows a list, all inside one composable. It works. Then:
- The bursar wants it to work offline (so data must also be stored).
- The school adds a second screen with the same balances.
- The server changes a field name.
- A bug appears: a balance is wrong, but only after rotating the phone.
- You want to write a test for the balance calculation.
Each change forces you to open the same giant function. You cannot test the calculation without a phone, a network and a screen, because it is stuck inside the UI.
What good architecture buys you
| Benefit | What it means in practice |
|---|---|
| Maintainability | Change the network library without touching a screen |
| Testability | Test business rules with plain JUnit, no emulator |
| Scalability | Several developers can work on different layers |
| Robustness | Configuration changes and process death are handled in one place |
| Readability | Anyone can find where "fee balance" is calculated |
The cost of too much structure
Layers, interfaces and mappers add files. A tiny calculator app with five layers is over-engineering. A useful test: does this layer make a real change easier or a real test possible? If not, leave it out for now. You can add it later, which is cheap when your code is already tidy.
1. Separation of concerns
Each piece of code has one reason to change. A composable's job is to display things and report events. It should not parse JSON, choose a database query or decide business rules.
BAD : @Composable fun FeeScreen() { call API, parse, compute, format, display }
GOOD : UI shows state; ViewModel holds state; repository fetches/stores; use case calculates
Remember that Activities and Fragments are UI controllers, not places for business logic. The Android system can destroy and recreate them at any time (rotation, low memory), so they are a poor home for anything important.
2. Drive the UI from data models
The UI should be a function of state: UI = f(state). State lives in data models (ideally persistent ones), and the screen simply renders the latest model. This is what you did when uiState came from a ViewModel. Benefits:
- The UI survives being destroyed, because the data lives elsewhere.
- Testing is easier, because you can create a state and check what is shown.
- Offline support is natural: the model comes from a local database.
3. Single source of truth (SSOT)
For each type of data, exactly one owner can change it, and everyone else reads it. In an offline-first app the local database is the source of truth for the UI; the network only updates the database (lesson 64).
Network ──► Repository ──► Database (SSOT) ──► ViewModel ──► UI
If two places both own the same data, they will eventually disagree, and then you have bugs that appear "randomly".
4. Unidirectional data flow (UDF)
State flows down; events flow up.
state
┌───────────────────┐
│ ▼
ViewModel UI
▲ │
└───────────────────┘
events
The UI never edits state directly. It sends an event (onPayClicked(amount)), the state holder processes it and publishes a new state, and the UI re-renders. This is the pattern you used in lesson 25 and it is the foundation of Compose.
Benefits: one place changes the state (easy debugging), the UI cannot get out of sync, and you can log or test every event.
The official guide describes a UI layer and a data layer, with an optional domain layer between them.
┌─────────────────────────────────────────┐
│ UI layer composables + state holders│ shows data, handles user events
├─────────────────────────────────────────┤
│ Domain layer (optional) use cases │ reusable business logic
├─────────────────────────────────────────┤
│ Data layer repositories + data sources│ exposes data, owns business rules about data
└─────────────────────────────────────────┘
dependencies point DOWN only
| Layer | Contains | Knows about |
|---|---|---|
| UI | Composables, ViewModels or plain state holders, UI state classes | Domain/data models (through ViewModels), never data sources directly |
| Domain (optional) | Use cases that combine or transform data | Data layer only |
| Data | Repositories, local data sources (Room, DataStore), remote data sources (Retrofit) | Nothing above it |
The dependency rule: upper layers depend on lower ones; lower layers never know about upper ones. A repository must not import a ViewModel. A data source must not know what a screen is. Following this rule keeps each layer replaceable and testable.
Here is FeeTracker version 1: everything in one place. Read it carefully and list what is wrong with it.
@Composable
fun FeeScreen() {
var payments by remember { mutableStateOf<List<Payment>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
try {
val json = URL("https://api.example.com/payments").readText() // network on UI code
val array = JSONArray(json) // parsing in UI
payments = (0 until array.length()).map {
val o = array.getJSONObject(it)
Payment(o.getString("student"), o.getInt("amount"))
}
} catch (e: Exception) {
error = e.message
} finally {
loading = false
}
}
val total = payments.sumOf { it.amount } // business rule in UI
val owed = 450_000 - total
Column {
if (loading) CircularProgressIndicator()
error?.let { Text("Error: $it") }
Text("Paid: ₦$total, balance: ₦$owed") // formatting in UI
LazyColumn { items(payments) { Text("${it.student}: ₦${it.amount}") } }
}
}
Problems:
- Network on the UI path.
URL.readText()is blocking work (andLaunchedEffectruns on the main dispatcher by default). - State is lost on rotation, because
rememberdoes not survive configuration changes, so the request repeats. - No single source of truth: data exists only in this composable.
- Business rule inside UI: the fee total
450_000is hard-coded in a composable. - Untestable: you cannot test the balance without running Compose.
- No offline support, no reuse by a second screen.
We will rebuild it, small piece at a time.
Data layer: a model and a data source
// data/Payment.kt
data class Payment(val student: String, val amount: Int)
// data/PaymentsRemoteDataSource.kt
interface PaymentsRemoteDataSource {
suspend fun fetchPayments(): List<Payment>
}
class FakePaymentsRemoteDataSource : PaymentsRemoteDataSource {
override suspend fun fetchPayments(): List<Payment> = listOf(
Payment("Adaeze Okoye", 150_000),
Payment("Tunde Bello", 200_000)
)
}
(A real implementation would use Retrofit as in lesson 29. We keep a fake so you can run the example without a server.)
Data layer: the repository
// data/PaymentsRepository.kt
interface PaymentsRepository {
suspend fun payments(): List<Payment>
}
class DefaultPaymentsRepository(
private val remote: PaymentsRemoteDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : PaymentsRepository {
override suspend fun payments(): List<Payment> = withContext(ioDispatcher) {
remote.fetchPayments()
}
}
The repository hides where data comes from. It also decides the thread (withContext(ioDispatcher)), so callers need not worry.
Business rule in plain Kotlin
// domain/FeeSummary.kt
data class FeeSummary(val paid: Int, val balance: Int)
class CalculateFeeSummary(private val termFee: Int = 450_000) {
operator fun invoke(payments: List<Payment>): FeeSummary {
val paid = payments.sumOf { it.amount }
return FeeSummary(paid = paid, balance = termFee - paid)
}
}
This class has no Android imports. It can be tested with plain JUnit.
UI layer: state and ViewModel
// ui/FeeUiState.kt
data class FeeUiState(
val loading: Boolean = true,
val payments: List<Payment> = emptyList(),
val summary: FeeSummary? = null,
val error: String? = null
)
// ui/FeeViewModel.kt
class FeeViewModel(
private val repository: PaymentsRepository,
private val calculate: CalculateFeeSummary
) : ViewModel() {
private val _uiState = MutableStateFlow(FeeUiState())
val uiState: StateFlow<FeeUiState> = _uiState.asStateFlow()
init { refresh() }
fun refresh() {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null) }
try {
val payments = repository.payments()
_uiState.update {
it.copy(loading = false, payments = payments, summary = calculate(payments))
}
} catch (e: IOException) {
_uiState.update { it.copy(loading = false, error = "Could not load payments") }
}
}
}
}
UI layer: the composable
@Composable
fun FeeScreen(viewModel: FeeViewModel, modifier: Modifier = Modifier) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
FeeContent(state = state, onRetry = viewModel::refresh, modifier = modifier)
}
@Composable
fun FeeContent(state: FeeUiState, onRetry: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier.padding(16.dp)) {
if (state.loading) CircularProgressIndicator()
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Button(onClick = onRetry) { Text("Retry") }
}
state.summary?.let { Text("Paid: ₦${it.paid}, balance: ₦${it.balance}") }
LazyColumn {
items(state.payments, key = { it.student }) { Text("${it.student}: ₦${it.amount}") }
}
}
}
FeeContent is stateless: it takes plain state and callbacks. You can preview and test it without a ViewModel (lesson 37).
Why this is better
| Problem in version 1 | Fixed by |
|---|---|
| Network in UI | Repository + data source with an IO dispatcher |
| Lost on rotation | ViewModel holds state |
| Business rule in UI | CalculateFeeSummary |
| Untestable | Plain tests for the use case; fake repository for the ViewModel |
| No reuse | Second screen can use the same repository |
Because each part has a small job, tests are small too.
class CalculateFeeSummaryTest {
@Test
fun balance_is_term_fee_minus_payments() {
val summary = CalculateFeeSummary(termFee = 100)(listOf(Payment("A", 30), Payment("B", 20)))
assertEquals(50, summary.paid)
assertEquals(50, summary.balance)
}
}
class FakePaymentsRepository(private val data: List<Payment>) : PaymentsRepository {
override suspend fun payments(): List<Payment> = data
}
class FeeViewModelTest {
@get:Rule val mainRule = MainDispatcherRule() // sets Dispatchers.Main to a test dispatcher (lesson 25)
@Test
fun loads_payments_and_summary() = runTest {
val vm = FeeViewModel(FakePaymentsRepository(listOf(Payment("A", 100))), CalculateFeeSummary(450))
advanceUntilIdle()
val state = vm.uiState.value
assertFalse(state.loading)
assertEquals(350, state.summary?.balance)
}
}
The ViewModel is tested without Android, a network or a database, because it depends on the PaymentsRepository interface, which we replaced with a fake. This dependency on abstractions is the practical payoff of the layers.
The page Recommendations for Android architecture lists items labelled Strongly recommended or Recommended. A summary of the main ones (check the page for the current text):
| Area | Recommendation |
|---|---|
| Layered architecture | Use a clearly defined data layer and UI layer |
| Data flow | Use unidirectional data flow |
| UI layer | Use UI state and state holders; use lifecycle-aware collection (collectAsStateWithLifecycle) |
| UI layer | Do not send events from the ViewModel to the UI as one-off callbacks; model them as state |
| ViewModel | Make ViewModels independent of the Android lifecycle and do not pass Context, Activity or View into them |
| Data layer | Expose immutable data from repositories; use coroutines and Flow; inject dispatchers |
| Data layer | Create a repository for each type of data |
| Domain layer | Use use cases when you need them (reuse or complexity), not by default |
| Dependency management | Use dependency injection (Hilt is recommended) |
| Testing | Prefer fakes over mocks |
| Models | Use separate models per layer when it avoids leaking details |
| Naming | Follow clear conventions (...Repository, ...UiState, ...UseCase) |
Each of these appears in the next lessons: UI layer (61), data layer (62), domain layer (63), offline-first (64), and dependency injection (65).
A note on MVVM, MVI and "Clean Architecture"
You will hear many acronyms. The recommended Android approach resembles MVVM with UDF (some people call that MVI-flavoured). Clean Architecture adds strict layers and many mappers. The guidance says: take the principles (separation, SSOT, UDF) first. Do not memorise names. Choose the least structure that keeps your app testable and understandable.
Exercise 1: Sort the code
For each item decide the layer: UI, domain or data. (a) A Retrofit interface. (b) A function that decides whether a student has "cleared" fees. (c) A composable showing a list. (d) A Room DAO. (e) FeeUiState. (f) A class that merges payments from the network and database.
(a) Data (remote data source). (b) Domain, if reused or complex; otherwise a small function near the ViewModel or in the data layer, depending on the rule. (c) UI. (d) Data (local data source). (e) UI. (f) Data layer, the repository.
Exercise 2: Fix the dependency
A teammate's repository takes a FeeViewModel in its constructor "so it can update the screen". Explain what is wrong and how data should reach the screen.
The data layer now depends on the UI layer, which breaks the dependency rule: the repository cannot be reused or tested alone, and it risks leaking the ViewModel. The repository should expose data (suspend functions or a Flow), and the ViewModel should collect it and update uiState. State flows down; the repository never knows about screens.
Exercise 3: Over-engineered?
A single-screen app displays today's date from the system clock. A teammate proposes a repository, a use case, a domain model and a mapper. How do you respond?
There is no external data, no business rule worth isolating and nothing that needs testing separately, so the layers add files but no value. A composable reading LocalDate.now() (or a tiny state holder if you need to test the formatting) is enough. Add structure when a second screen, a data source or a real rule appears.
Mistake #1: Business logic in composables or Activities. Fix: move it to a ViewModel, use case or repository.
Mistake #2: Passing Context or Activity into a ViewModel. Fix: use an AndroidViewModel only if you must, and prefer injecting what you need (for example a StringProvider) or letting the UI resolve strings.
Mistake #3: Two sources of truth. Fix: choose one owner (usually the local database) and have others read from it.
Mistake #4: Layers that know each other upwards. Fix: follow the dependency rule.
Mistake #5: Exposing mutable state. Fix: expose StateFlow, keep MutableStateFlow private.
Mistake #6: Creating a layer "because the guide has one". Fix: add a layer when it solves a problem.
Mistake #7: Mocking everything. Fix: prefer simple fakes.
Mistake #8: Hard-coded dispatchers. Fix: inject them so tests can replace them.
Mistake #9: One-off events as callbacks from the ViewModel. Fix: model them as state that the UI consumes (lesson 61).
Mistake #10: Treating the guide as unchangeable. Fix: use judgement; the guide itself says recommendations are not mandatory.
Question 1. State the four principles of this lesson.
Separation of concerns, drive the UI from data models, a single source of truth, and unidirectional data flow.
Question 2. Which way do dependencies point between layers?
Downwards: UI depends on domain or data; the domain depends on data; the data layer depends on neither.
Question 3. What does "state flows down, events flow up" mean?
The UI receives state from a state holder and renders it; user actions are sent upwards as events, and the state holder publishes new state.
Question 4. Why prefer an interface-based repository?
Callers depend on an abstraction, so tests can pass a fake and you can change the implementation without touching callers.
Question 5. When is a domain layer worth adding?
When logic is complex or reused by several ViewModels, or when it combines several repositories; not by default.
Go deeper: official resources
- Modern Android App Architecture programme
- Guide to app architecture
- Recommendations for Android architecture
- UI layer and Data layer
Project goal: Take a one-file app and give it a clear architecture.
Requirements:
- Start from the "version 1" composable in Build 1 (use a fake data source so it runs offline).
- Create packages
data,domainandui. - Add
Payment, a remote data source interface with a fake, and a repository interface with a default implementation (inject the dispatcher). - Add
CalculateFeeSummaryas a use case with no Android imports. - Add
FeeUiState,FeeViewModeland a statelessFeeContent. - Write at least three tests: the use case, the repository (with a fake data source), and the ViewModel (with a fake repository).
- Write a one-page architecture note with a layer diagram and the dependency rule, and list two things you decided not to add and why.
Stretch goal: add a second screen "Defaulters" that reuses the same repository and use case, proving the benefit of the layers.
A complete wiring using a manual container (lesson 30); Hilt comes in lesson 65:
class AppContainer {
private val remote: PaymentsRemoteDataSource = FakePaymentsRemoteDataSource()
val repository: PaymentsRepository = DefaultPaymentsRepository(remote)
val calculate = CalculateFeeSummary()
}
class FeeViewModelFactory(private val container: AppContainer) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
FeeViewModel(container.repository, container.calculate) as T
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val container = AppContainer()
setContent {
MaterialTheme {
val vm: FeeViewModel = viewModel(factory = FeeViewModelFactory(container))
FeeScreen(vm)
}
}
}
}
The repository test:
class DefaultPaymentsRepositoryTest {
@Test
fun returns_what_the_data_source_gives() = runTest {
val remote = object : PaymentsRemoteDataSource {
override suspend fun fetchPayments() = listOf(Payment("A", 10))
}
val repo = DefaultPaymentsRepository(remote, ioDispatcher = StandardTestDispatcher(testScheduler))
assertEquals(listOf(Payment("A", 10)), repo.payments())
}
}
Architecture note (example):
UI layer FeeScreen / FeeContent / FeeViewModel / FeeUiState
Domain layer CalculateFeeSummary (kept: reused by two screens)
Data layer PaymentsRepository -> PaymentsRemoteDataSource
Rule dependencies point downwards only
Not added a separate domain Payment model and mapper (same fields as the data model today)
a Room database (no offline requirement yet; added in lesson 64)
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 60 project”.
On github.com click New repository, name it android-lesson-60-why-app-architecture-matters-principles-an, 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 60 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Why Bother?
- The Principles
- The Recommended Layers
- Step-by-Step Build 1: The Problem Version
- Step-by-Step Build 2: Refactor into Layers
- Step-by-Step Build 3: Testing the Layers
- Step-by-Step Build 4: Reading the Official Recommendations
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check