In lesson 29 your ViewModel called PostsApi.service directly. It works, but it quietly welded your screen logic to Retrofit. What if tomorrow the posts come from a database, or you want to show cached posts when offline, or you want to test the ViewModel without the internet? Every change would force you to edit the ViewModel.
The cure is the data layer: a part of your app whose only job is to provide and manage data. Its main citizen is the repository. You will also learn dependency injection, a fancy name for a simple idea: a class should receive the things it needs instead of creating them itself.
This lesson covers two items of Pathway 2, Unit 5 of the official Android Basics with Compose course: Architecture: The Data Layer and Add repository and Manual Dependency Injection. You will refactor the Posts app from lesson 29, and build a fake repository so that ViewModel tests run without any network.
By the end you will be able to:
- Explain the role of the data layer and of repositories and data sources.
- Define a repository as an interface with a real and a fake implementation.
- Explain dependency injection, inversion of control and why they make code testable.
- Build a manual DI container and an
Applicationsubclass that holds it. - Create a ViewModel that takes constructor parameters using the
viewModelFactoryandinitializerhelpers. - Write ViewModel tests with a fake repository.
Big idea: the UI should not know where data comes from. It asks a repository, and the repository decides: network, cache or database.
- Retrofit service and
@Serializablemodels (lesson 29). - ViewModel and UI state (lessons 24 and 25).
- Interfaces and classes (lesson 11). An interface defines what can be done; a class decides how.
- Unit tests with a fake (lesson 25).
Recall the layers from lesson 24:
+------------------------------------------+
| UI layer: Composables + ViewModel |
+----------------------|-------------------+
| calls suspend functions / collects Flows
+----------------------v-------------------+
| Data layer: Repositories |
| +-- Data sources: network, database, |
| files, DataStore |
+------------------------------------------+
- A data source talks to one origin of data: a Retrofit service, a Room DAO, a file. It has no business rules.
- A repository combines data sources and applies rules. For example: "fetch posts from the network; if that fails, return the cached ones; save new results to the database".
- The UI layer talks only to repositories, never directly to data sources.
Why have repositories?
| Benefit | Example |
|---|---|
| Single source of truth | Every screen reads posts through one object, so they never disagree. |
| Swap the implementation | Replace network with database without touching the UI. |
| Testability | A ViewModel test uses a fake repository that returns a prepared list. |
| Hide details | The ViewModel does not know about HttpException or SQL. |
| Shared logic | Caching, retries and mapping live in one place. |
Dependency injection in plain words
Imagine a tailor who must make a shirt. If the tailor grows the cotton, spins the thread and weaves the cloth before sewing, the shop is slow, and you cannot change the cloth. If cloth is handed to the tailor, they just sew. That is dependency injection.
Without DI (the class creates what it needs):
class PostsViewModel : ViewModel() {
private val repository = NetworkPostsRepository(PostsApi.service) // hard-wired
}
With DI (the class receives it):
class PostsViewModel(private val repository: PostsRepository) : ViewModel()
The second version does not care which repository it is given. This is also called inversion of control: the creator decides, not the user of the object. It gives you two big wins:
- Reuse: swap one implementation for another.
- Testing: pass in a fake.
Libraries such as Hilt automate the wiring (lesson 63). Today you write the wiring yourself, which is the best way to understand what the library does.
Start from the lesson 29 Posts app.
Define the repository interface
package com.example.posts.data
import com.example.posts.model.Post
interface PostsRepository {
suspend fun getPosts(): List<Post>
suspend fun getPost(id: Int): Post
}
The interface names only what the UI needs. It uses the app's own Post class and says nothing about Retrofit.
The network implementation
package com.example.posts.data
import com.example.posts.model.Post
import com.example.posts.network.PostsApiService
class NetworkPostsRepository(
private val apiService: PostsApiService
) : PostsRepository {
override suspend fun getPosts(): List<Post> = apiService.getPosts()
override suspend fun getPost(id: Int): Post = apiService.getPost(id)
}
Right now it just forwards calls. That looks pointless, but this is the seam that lets you add caching later without touching any screen. (Lesson 34 does exactly that with Room.)
Note: DTO versus domain model
Real services often return fields you do not want in your UI code (created_at as a string, nested objects). A common pattern is to parse into a network model (sometimes called a DTO) and map to a clean domain model inside the repository. Here the two are the same class to keep things small, and lesson 62 shows the mapping.
Somebody must create the repository and give it its service. In manual DI that somebody is a container, a class that builds and holds the app's long-lived objects.
package com.example.posts.data
import com.example.posts.network.PostsApiService
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
interface AppContainer {
val postsRepository: PostsRepository
}
class DefaultAppContainer : AppContainer {
private val baseUrl = "https://jsonplaceholder.typicode.com/"
private val json = Json { ignoreUnknownKeys = true }
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
private val retrofitService: PostsApiService by lazy {
retrofit.create(PostsApiService::class.java)
}
override val postsRepository: PostsRepository by lazy {
NetworkPostsRepository(retrofitService)
}
}
Notes:
AppContaineris an interface so a test can supply a different container.- Everything private except the repository: callers only see what they should.
by lazycreates objects when first needed, then reuses them. This makes them effectively singletons for the life of the container.
An Application subclass holds the container
The container must live as long as the app does, not as long as one Activity. Android gives you the Application class for this: one instance per process, created before any Activity.
package com.example.posts
import android.app.Application
import com.example.posts.data.AppContainer
import com.example.posts.data.DefaultAppContainer
class PostsApplication : Application() {
lateinit var container: AppContainer
override fun onCreate() {
super.onCreate()
container = DefaultAppContainer()
}
}
Register it in the manifest by adding android:name to the <application> tag. Forgetting this step is the number one reason the container is never created:
<application
android:name=".PostsApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
... >
Because onCreate of the Application runs before any Activity, container is ready by the time a ViewModel needs it. Keep Application.onCreate fast: it runs on every cold start, and anything slow here slows down launch. The by lazy properties help, as nothing heavy is built until used.
The ViewModel now needs a repository:
package com.example.posts.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.posts.PostsApplication
import com.example.posts.data.PostsRepository
import java.io.IOException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.HttpException
class PostsViewModel(
private val postsRepository: PostsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<PostsUiState>(PostsUiState.Loading)
val uiState: StateFlow<PostsUiState> = _uiState.asStateFlow()
init { loadPosts() }
fun loadPosts() {
viewModelScope.launch {
_uiState.value = PostsUiState.Loading
_uiState.value = try {
PostsUiState.Success(postsRepository.getPosts())
} catch (e: IOException) {
PostsUiState.Error("No connection. Check your data or Wi-Fi.")
} catch (e: HttpException) {
PostsUiState.Error("Server error (${e.code()}).")
}
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[APPLICATION_KEY] as PostsApplication
PostsViewModel(application.container.postsRepository)
}
}
}
}
(Add import androidx.lifecycle.ViewModelProvider.)
The framework creates ViewModels itself, and by default it only knows how to call a no-argument constructor. When yours has parameters you must teach it how to build one. A factory does that:
viewModelFactory { ... }builds a factory from one or moreinitializerblocks.initializer { ... }is the recipe for creating aPostsViewModel. Inside it,thisgives access toCreationExtras, a bag of values the system supplies, including theApplicationunderAPPLICATION_KEY.- We cast it to
PostsApplication, reach the container and pass the repository in.
Use it in the composable by passing the factory to viewModel():
@Composable
fun PostsApp(
modifier: Modifier = Modifier,
viewModel: PostsViewModel = viewModel(factory = PostsViewModel.Factory)
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PostsScreen(state = state, onRetry = viewModel::loadPosts, modifier = modifier)
}
Run the app. It looks exactly the same as in lesson 29, which is the point of a refactor: behaviour identical, structure better.
The dependency chain
PostsApplication
+-- container: DefaultAppContainer
+-- retrofit
+-- retrofitService (PostsApiService)
+-- postsRepository (NetworkPostsRepository(retrofitService))
ViewModel factory --> application.container.postsRepository --> PostsViewModel(repository)
Compare this with Hilt in lesson 63: Hilt generates this wiring from annotations, but the shape is the same.
Because the ViewModel depends on an interface, a test can give it anything. A fake is a simple working stand-in.
package com.example.posts.fake
import com.example.posts.data.PostsRepository
import com.example.posts.model.Post
import java.io.IOException
class FakePostsRepository : PostsRepository {
var shouldFail = false
var posts = listOf(
Post(1, "First", "Body one", 1),
Post(2, "Second", "Body two", 1)
)
override suspend fun getPosts(): List<Post> {
if (shouldFail) throw IOException("Pretend offline")
return posts
}
override suspend fun getPost(id: Int): Post = posts.first { it.id == id }
}
package com.example.posts
import com.example.posts.fake.FakePostsRepository
import com.example.posts.ui.PostsUiState
import com.example.posts.ui.PostsViewModel
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class PostsViewModelTest {
@get:Rule
val mainRule = MainDispatcherRule() // from lesson 25
@Test
fun init_loadsPostsIntoSuccessState() = runTest {
val fake = FakePostsRepository()
val viewModel = PostsViewModel(fake)
advanceUntilIdle()
val state = viewModel.uiState.value
assertTrue(state is PostsUiState.Success)
assertEquals(fake.posts, (state as PostsUiState.Success).posts)
}
@Test
fun whenRepositoryFails_stateIsError() = runTest {
val fake = FakePostsRepository().apply { shouldFail = true }
val viewModel = PostsViewModel(fake)
advanceUntilIdle()
assertTrue(viewModel.uiState.value is PostsUiState.Error)
}
@Test
fun retryAfterFailure_succeeds() = runTest {
val fake = FakePostsRepository().apply { shouldFail = true }
val viewModel = PostsViewModel(fake)
advanceUntilIdle()
fake.shouldFail = false
viewModel.loadPosts()
advanceUntilIdle()
assertTrue(viewModel.uiState.value is PostsUiState.Success)
}
}
No emulator, no network, no waiting: all three tests run in milliseconds. Compare that with testing against the real API, which would be slow, flaky (the server might be down) and impossible to force into a failure on demand.
Fakes versus mocks:
| Fake | Mock (Mockito, MockK) | |
|---|---|---|
| What it is | A real, simple implementation | An object that records calls and returns scripted answers |
| Strength | Readable, reusable, behaves like the real thing | Quick for one-off checks |
| Risk | You maintain it | Tests become tied to implementation details |
The official architecture guidance leans towards fakes, which is why this lesson does too.
Where does each kind of code belong?
| Code | Layer |
|---|---|
@Composable functions |
UI |
PostsViewModel, PostsUiState |
UI (state holder) |
PostsRepository, NetworkPostsRepository |
Data |
PostsApiService, Room DAOs |
Data (data sources) |
Post |
Shared model |
Dependencies point downwards only: UI knows the data layer, the data layer never knows about the UI. If a repository imports a composable or a Context for dialog purposes, something is wrong.
Where does the data layer's work run?
Repositories are main-safe when they expose suspend functions: the repository itself switches dispatchers if it needs to. A common pattern is to inject a dispatcher:
class NetworkPostsRepository(
private val apiService: PostsApiService,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : PostsRepository {
override suspend fun getPosts(): List<Post> =
withContext(ioDispatcher) { apiService.getPosts() }
}
Injecting the dispatcher means a test can pass its own test dispatcher. With Retrofit's suspend functions the withContext is unnecessary, but the pattern matters as soon as you call blocking APIs.
Exercise 1: Add an in-memory cache
Make NetworkPostsRepository remember the posts after the first successful fetch and return them without calling the network again until refresh = true is passed. Change the interface to suspend fun getPosts(refresh: Boolean = false): List<Post>. Update the fake and ViewModel.
interface PostsRepository {
suspend fun getPosts(refresh: Boolean = false): List<Post>
suspend fun getPost(id: Int): Post
}
class NetworkPostsRepository(
private val apiService: PostsApiService
) : PostsRepository {
private var cache: List<Post>? = null
override suspend fun getPosts(refresh: Boolean): List<Post> {
val cached = cache
if (cached != null && !refresh) return cached
return apiService.getPosts().also { cache = it }
}
override suspend fun getPost(id: Int): Post = apiService.getPost(id)
}
The fake just accepts the new parameter (override suspend fun getPosts(refresh: Boolean)). The retry button in the ViewModel should call getPosts(refresh = true).
Caveats to discuss: a plain var is not thread-safe if two coroutines call at once (a Mutex fixes this), and the cache dies with the process. Lesson 34 stores it in Room, which survives restarts.
Exercise 2: A second implementation
Write SamplePostsRepository, which returns a hard-coded list after a one-second delay, and add a build-time switch in DefaultAppContainer that uses it when a useSampleData flag is true. Explain what you did not have to change.
class SamplePostsRepository : PostsRepository {
override suspend fun getPosts(refresh: Boolean): List<Post> {
delay(1_000)
return listOf(Post(1, "Offline post", "Works without internet.", 1))
}
override suspend fun getPost(id: Int): Post = getPosts().first { it.id == id }
}
class DefaultAppContainer(private val useSampleData: Boolean = false) : AppContainer {
// ... retrofit as before
override val postsRepository: PostsRepository by lazy {
if (useSampleData) SamplePostsRepository() else NetworkPostsRepository(retrofitService)
}
}
You changed nothing in the ViewModel or any composable. That is the benefit of programming to an interface: the wiring is the only place that knows which implementation is used. This also gives you an app that demos without internet, useful for screenshots and for workshops with poor connectivity.
Draw your app's dependency graph on paper: which class creates which, and which class only receives. Mark every place that still uses new-style construction (SomeClass(...)) outside the container.
Mistake #1: Forgetting android:name in the manifest. Symptom: lateinit property container has not been initialized, or a ClassCastException on the cast. The fix: set android:name=".PostsApplication".
Mistake #2: Repository returns Retrofit types. What happens: the UI imports Retrofit and cannot be tested without it. The fix: return your own model types and hide transport details.
Mistake #3: A repository interface mirrors the API one to one. What happens: the repository adds nothing. The fix: design the interface around what the UI needs and put rules (caching, merging sources) inside.
Mistake #4: A ViewModel that builds its own dependencies. What happens: it cannot be tested without the network. The fix: constructor injection.
Mistake #5: Storing an Activity Context in the container. What happens: a memory leak. The fix: use the application context, which lives as long as the container.
Mistake #6: Heavy work in Application.onCreate. What happens: slow app start. The fix: lazy creation and background initialisation.
Mistake #7: Creating the factory inline on every recomposition. What happens: confusion about instances. The fix: a companion object factory like PostsViewModel.Factory, which viewModel() caches per owner.
Mistake #8: A "god" repository. What happens: one class handles users, posts, settings. The fix: one repository per data type.
Question 1. What is the role of the data layer?
To provide and manage the app's data and business rules, exposing it to the UI layer through repositories that hide where the data comes from.
Question 2. What is the difference between a repository and a data source?
A data source accesses one origin (network, database, file). A repository coordinates one or more data sources and applies rules such as caching, and is the only thing the UI layer talks to.
Question 3. What is dependency injection and why does it help testing?
A class receives its dependencies (usually through its constructor) instead of creating them. In tests you can pass fakes, so you test the class in isolation without the network or database.
Question 4. Why do ViewModels with constructor parameters need a factory?
The system creates ViewModels and by default only knows how to call a no-argument constructor. A factory tells it how to build your ViewModel, supplying the dependencies.
Question 5. Why is the Application class a good home for the container?
One instance exists for the whole process and is created before any Activity, so objects held there live as long as the app and are shared by all screens.
Go deeper: official resources
- Android Basics with Compose, Unit 5, Pathway 2: Data layer and dependency injection
- Data layer guide
- Guide to app architecture
Project goal: Extend the Posts app so that the same UI can run on live or sample data, selected by a switch in a debug settings screen, and prove that with tests.
Requirements:
- A
PostsRepositoryinterface withNetworkPostsRepository,SamplePostsRepositoryandFakePostsRepository(test only). - A
DefaultAppContainerthat exposes a repository chosen by auseSampleDataflag. - A ViewModel built with a factory from the container.
- A
Switchin the top bar that flips the flag in an app-level object and reloads the posts. (A simpleMutableStateFlow<Boolean>in the container is enough; persistence comes in lesson 35.) - Three ViewModel unit tests with the fake: success, failure, and retry.
Stretch goal: add an in-memory cache with a Mutex to make it thread-safe.
package com.example.posts.data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
interface AppContainer {
val useSampleData: StateFlow<Boolean>
fun setUseSampleData(value: Boolean)
val postsRepository: PostsRepository
}
class DefaultAppContainer(
private val network: PostsRepository,
private val sample: PostsRepository = SamplePostsRepository()
) : AppContainer {
private val _useSample = MutableStateFlow(false)
override val useSampleData: StateFlow<Boolean> = _useSample.asStateFlow()
override fun setUseSampleData(value: Boolean) { _useSample.value = value }
// A small delegating repository: the choice is read at call time,
// so flipping the switch takes effect without rebuilding anything.
override val postsRepository: PostsRepository = object : PostsRepository {
private fun current() = if (_useSample.value) sample else network
override suspend fun getPosts(refresh: Boolean) = current().getPosts(refresh)
override suspend fun getPost(id: Int) = current().getPost(id)
}
}
```PostsViewModel.kt (additions) class PostsViewModel( private val container: AppContainer ) : ViewModel() {
val useSampleData = container.useSampleData
fun onToggleSample(value: Boolean) {
container.setUseSampleData(value)
loadPosts()
}
// loadPosts() uses container.postsRepository.getPosts(refresh = true)
} ```
Notes: the delegating repository is itself an application of the lesson: callers hold one stable PostsRepository while the choice behind it changes. In the real PostsApplication, build the container with DefaultAppContainer(network = NetworkPostsRepository(retrofitService)). For the tests, construct DefaultAppContainer(network = FakePostsRepository(), sample = FakePostsRepository()) and check that after setUseSampleData(true) the sample fake's list is returned. The stretch goal's Mutex wraps the cache read and write: mutex.withLock { ... }.
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 30 project”.
On github.com click New repository, name it android-lesson-30-the-data-layer-repositories-and-dependency, 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 30 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: The Data Layer
- Step-by-Step Build 1: Introduce the Repository
- Step-by-Step Build 2: The Dependency Container
- Step-by-Step Build 3: ViewModel with Constructor Parameters
- Step-by-Step Build 4: A Fake Repository for Tests
- Putting the Data Layer in Context
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check