Techbase Academy
Android · Lesson 60

Why App Architecture Matters: Principles and Layers

12 phasesArchitecture Audit: Refactor FeeTrackerAndroid Studio
Phase 1 of 12
Lesson Introduction

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.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 12
Prerequisite Concepts
  • 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).
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 12
Conceptual Understanding: Why Bother?

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.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: Why Bother?. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 12
The Principles

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.

text
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).

text
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.

text
         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.

✏️ Your Task
Practise what you just learned about The Principles. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 12
The Recommended Layers

The official guide describes a UI layer and a data layer, with an optional domain layer between them.

text
┌─────────────────────────────────────────┐
│  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.

✏️ Your Task
Practise what you just learned about The Recommended Layers. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 12
Step-by-Step Build 1: The Problem Version

Here is FeeTracker version 1: everything in one place. Read it carefully and list what is wrong with it.

kotlin
@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:

  1. Network on the UI path. URL.readText() is blocking work (and LaunchedEffect runs on the main dispatcher by default).
  2. State is lost on rotation, because remember does not survive configuration changes, so the request repeats.
  3. No single source of truth: data exists only in this composable.
  4. Business rule inside UI: the fee total 450_000 is hard-coded in a composable.
  5. Untestable: you cannot test the balance without running Compose.
  6. No offline support, no reuse by a second screen.
✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: The Problem Version. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 12
Step-by-Step Build 2: Refactor into Layers

We will rebuild it, small piece at a time.

Data layer: a model and a data source

kotlin
// 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

kotlin
// 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

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

kotlin
// 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

kotlin
@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
✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Refactor into Layers. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 12
Step-by-Step Build 3: Testing the Layers

Because each part has a small job, tests are small too.

kotlin
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Testing the Layers. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 12
Step-by-Step Build 4: Reading the Official Recommendations

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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: Reading the Official Recommendations. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 12
Guided Practice Exercises
🎯 Your Challenge

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.

🎯 Your Challenge

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.

🎯 Your Challenge

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.

✏️ Your Task
Practise what you just learned about Guided Practice Exercises. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 12
Common Beginner Mistakes (and How to Fix Them)

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.

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 12
Knowledge Check

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

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Architecture Audit: Refactor FeeTracker

Project goal: Take a one-file app and give it a clear architecture.

Requirements:

  1. Start from the "version 1" composable in Build 1 (use a fake data source so it runs offline).
  2. Create packages data, domain and ui.
  3. Add Payment, a remote data source interface with a fake, and a repository interface with a default implementation (inject the dispatcher).
  4. Add CalculateFeeSummary as a use case with no Android imports.
  5. Add FeeUiState, FeeViewModel and a stateless FeeContent.
  6. Write at least three tests: the use case, the repository (with a fake data source), and the ViewModel (with a fake repository).
  7. 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:

kotlin
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:

kotlin
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):

text
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)
1

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.

2

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”.

3

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.

4

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.

5

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
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close