The repository pattern
One class becomes the only part of your app that knows where data comes from. It is five lines long, it looks pointless the first time you see it, and it is the reason a rewrite later costs an afternoon instead of a week.
The file that looks like it does nothing
Here is the entire NotesRepository from Pocket Notes:
1class NotesRepository(private val dao: NoteDao) {
2
3 val notes: Flow<List<Note>> = dao.observeAll()
4
5 suspend fun find(id: Long): Note? =
6 dao.findById(id)
7
8 suspend fun save(note: Note): Long =
9 dao.upsert(note)
10
11 suspend fun delete(note: Note) = dao.delete(note)
12}Every function forwards straight to the DAO. Nothing is added. A reasonable person looks at that and asks why the ViewModel does not just hold the NoteDao and delete the file.
It is a fair question, and the honest answer is: today, it changes nothing. Tomorrow is a different matter.
Think about the counter at a library.
You do not walk into the stacks. You go to the desk and say "the one about volcanoes, for a nine-year-old". The librarian works out whether it is on the shelf, in the back room, out on loan, or has to be ordered in from another branch.
Now the library reorganises. Non-fiction moves upstairs, half the collection goes digital, and the ordering system changes supplier. You notice nothing. You still walk up to the same desk and ask for the same thing, because the librarian absorbed the entire change.
A is that desk. The screens ask for notes. Whether notes currently live in Room, in a file, in memory, or on somebody's server is the repository's problem and nobody else's.
What it is actually for
One place to change. Add a network, a cache, or a second database and exactly one file is edited. NotesViewModel does not know Room exists — grep the file and you will not find the word.
The vocabulary is yours, not the database's. A DAO speaks in rows and queries: observeAll, upsert, findById. A repository can speak in the language of the app: notes, save, delete. It reads like the thing you are building rather than the thing you are storing it in.
It can combine sources. The moment a screen needs facts from two places — notes from Room and a sort order from DataStore — someone has to join them. Do it in the repository and every ViewModel gets the joined answer. Do it in each ViewModel and you write it twice, slightly differently.
It makes testing possible. A cannot easily open a real Android database. It can very easily hand your ViewModel a fake repository that returns three made-up notes.
Dependency injection, without the ceremony
Look again at the first line:
class NotesRepository(private val dao: NoteDao)The repository does not build its own DAO. It is handed one. That is the whole of — a phrase that sounds like it requires a framework and usually just means "pass it in as a constructor argument".
The benefit is immediate. Because the DAO arrives from outside, a test can pass in a different one. If the repository built its own, it would be welded to Room forever.
The ViewModel does the same thing one floor up:
1class NotesViewModel(
2 app: Application
3) : AndroidViewModel(app) {
4
5 private val repo = NotesRepository(
6 NotesDatabase.get(app).noteDao()
7 )
8}This is the one place the course apps wire things together by hand rather than with a library like Hilt. That is a deliberate choice: Hilt adds an annotation processor and a plugin, and every extra build-time tool is time your phone spends warm.
Three lines of hand-wiring is a fine trade for three apps. If you later build something with twenty screens, Hilt starts paying for itself.
The shape, top to bottom
Each layer knows only about the one directly below it. Nothing reaches past a neighbour, and nothing reaches back up. That is what lets you replace any single layer without a rewrite.
When to use an interface — and when not to
Plenty of tutorials write an interface for every repository on principle:
1interface NotesSource {
2 val notes: Flow<List<Note>>
3 suspend fun save(note: Note): Long
4}An is a contract with no bodies. It earns its keep the moment there are two things fulfilling it: the real repository and a fake one for tests, or a local one and a remote one.
With exactly one implementation it adds a file, a layer of indirection, and a second place to edit every time you add a function. Pocket Notes has one implementation, so it has no interface.
A good rule: write the interface when you write the second implementation, not before. Extracting one later is a two-minute job — Pocket Studio can do it from the class name — while removing a layer that turned out to be pointless is a much bigger apology.
Being honest about it
For a one-screen app with one DAO, a repository is a formality. If you write a tiny app and skip it, nothing bad happens.
Here are the three moments it stops being optional:
- A second ViewModel needs the same data. Without a repository, the logic for fetching it exists twice.
- Data starts coming from two places. Room plus DataStore, or local plus network. Something has to join them, in one place.
- You want to test the logic. The repository is the seam where a fake slots in.
Pocket Notes hits the first two, which is why it has one. Focus Flow's SettingsRepository earns its place differently: it is where the defaults, the IOException handling and the coerceIn limits live, so no screen has to know any of them.
You will prove the repository is a real seam by swapping what is behind it.
- Open Pocket Studio, tap Projects, then Pocket Notes.
- Tap Editor and open
data/NotesRepository.kt. - Change the
notesproperty to sort in the opposite direction without touching the DAO:val notes: Flow<List<Note>> = dao.observeAll().map { it.reversed() } - Add
import kotlinx.coroutines.flow.mapwhen Pocket Studio offers it. - Tap Run. The list is now oldest-first — and you changed one file. The ViewModel and both screens are untouched.
- Now open
ui/NotesViewModel.ktand search it for the worddao. There is no match anywhere. That absence is the entire point of the pattern. - Undo the change in step 3 and Run once more.
NotesRepository() with no arguments. The repository is deliberately unable to build its own DAO — it must be handed one.NotesRepository(NotesDatabase.get(app).noteDao()). If that feels awkward from where you are calling it, that usually means the repository is being created too deep in the app.dao public, the layering has gone wrong somewhere above.onClick.fun delete(note: Note) { viewModelScope.launch { repo.delete(note) } }. The screen calls the plain function; the ViewModel owns the coroutine.NotesSource(...). An interface is a contract, not a thing — something has to fulfil it.NotesRepository(dao). Hold it in a variable typed as the interface if you want callers to depend only on the contract.- A is the only class that knows where data actually comes from. Everything above it asks for notes, not for rows.
- It buys you: one place to change, vocabulary that matches the app, somewhere to join two sources, and a seam for tests.
- Handing the DAO in as a constructor parameter is . No framework required.
- Each layer knows only the one below: screen → ViewModel → repository → DAO → database.
- Write the when you write the second implementation, not before.
- For a one-screen app it is a formality. Two ViewModels, two data sources, or a test — that is when it stops being optional.
- Next: the last piece of the architecture puzzle. Rotation you have solved; you have not, and this is how to see it happen on your own phone.