Techbase Academy
Android · Lesson 30

The Data Layer: Repositories and Dependency Injection

11 phasesSwitchable Data SourceAndroid Studio
Phase 1 of 11
Lesson Introduction

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 Application subclass that holds it.
  • Create a ViewModel that takes constructor parameters using the viewModelFactory and initializer helpers.
  • 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.

✏️ 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 11
Prerequisite Concepts
  • Retrofit service and @Serializable models (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).
✏️ 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 11
Conceptual Understanding: The Data Layer

Recall the layers from lesson 24:

text
+------------------------------------------+
| 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):

kotlin
class PostsViewModel : ViewModel() {
 private val repository = NetworkPostsRepository(PostsApi.service) // hard-wired
}

With DI (the class receives it):

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

  1. Reuse: swap one implementation for another.
  2. 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.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: The Data Layer. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 11
Step-by-Step Build 1: Introduce the Repository

Start from the lesson 29 Posts app.

Define the repository interface

PostsRepository.kt
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

NetworkPostsRepository.kt
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Introduce the Repository. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 11
Step-by-Step Build 2: The Dependency Container

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.

AppContainer.kt
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:

  • AppContainer is an interface so a test can supply a different container.
  • Everything private except the repository: callers only see what they should.
  • by lazy creates 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.

PostsApplication.kt
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:

AndroidManifest.xml
<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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: The Dependency Container. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 11
Step-by-Step Build 3: ViewModel with Constructor Parameters

The ViewModel now needs a repository:

PostsViewModel.kt
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 more initializer blocks.
  • initializer { ... } is the recipe for creating a PostsViewModel. Inside it, this gives access to CreationExtras, a bag of values the system supplies, including the Application under APPLICATION_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():

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

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

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: ViewModel with Constructor Parameters. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 11
Step-by-Step Build 4: A Fake Repository for Tests

Because the ViewModel depends on an interface, a test can give it anything. A fake is a simple working stand-in.

FakePostsRepository.kt
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 }
}
PostsViewModelTest.kt
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.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: A Fake Repository for Tests. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 11
Putting the Data Layer in Context

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:

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

✏️ Your Task
Practise what you just learned about Putting the Data Layer in Context. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 11
Guided Practice Exercises
🎯 Your Challenge

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.

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

🎯 Your Challenge

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.

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

✏️ Your Task

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.

Phase 10 of 11
Common Beginner Mistakes (and How to Fix Them)

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.

✏️ 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 11 of 11
Knowledge Check

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

✏️ 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
Switchable Data Source

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:

  1. A PostsRepository interface with NetworkPostsRepository, SamplePostsRepository and FakePostsRepository (test only).
  2. A DefaultAppContainer that exposes a repository chosen by a useSampleData flag.
  3. A ViewModel built with a factory from the container.
  4. A Switch in the top bar that flips the flag in an app-level object and reloads the posts. (A simple MutableStateFlow<Boolean> in the container is enough; persistence comes in lesson 35.)
  5. 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.

AppContainer.kt
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() {

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

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

3

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.

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