Pocket Studio Academy
HomePart 44.7

The repository pattern

Full course11 min read·4 questions

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:

data/NotesRepository.ktkotlin
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 of it like this

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:

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

ui/NotesViewModel.ktkotlin
1class NotesViewModel(
2  app: Application
3) : AndroidViewModel(app) {
4
5  private val repo = NotesRepository(
6    NotesDatabase.get(app).noteDao()
7  )
8}
Note

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:

only worth it when you need itkotlin
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.

Tip

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:

  1. A second ViewModel needs the same data. Without a repository, the logic for fetching it exists twice.
  2. Data starts coming from two places. Room plus DataStore, or local plus network. Something has to join them, in one place.
  3. 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.

Try it in Pocket Studio

You will prove the repository is a real seam by swapping what is behind it.

  1. Open Pocket Studio, tap Projects, then Pocket Notes.
  2. Tap Editor and open data/NotesRepository.kt.
  3. Change the notes property to sort in the opposite direction without touching the DAO: val notes: Flow<List<Note>> = dao.observeAll().map { it.reversed() }
  4. Add import kotlinx.coroutines.flow.map when Pocket Studio offers it.
  5. Tap Run. The list is now oldest-first — and you changed one file. The ViewModel and both screens are untouched.
  6. Now open ui/NotesViewModel.kt and search it for the word dao. There is no match anywhere. That absence is the entire point of the pattern.
  7. Undo the change in step 3 and Run once more.
Error Doctor5 common errors
e: No value passed for parameter 'dao'
MeansYou wrote NotesRepository() with no arguments. The repository is deliberately unable to build its own DAO — it must be handed one.
FixPass it in: 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.
e: Class 'FakeNotesRepository' is not abstract and does not implement abstract member public abstract suspend fun save(note: Note): Long defined in com.nativeworks.pocketnotes.data.NotesSource
MeansYou added a function to the interface and one of the classes fulfilling it has not caught up. The contract has a hole in it.
FixAdd the missing function to that class. This error is the interface doing its job — it is impossible to forget an implementation, which is exactly why the second implementation is when an interface starts paying.
e: Cannot access 'dao': it is private in 'NotesRepository'
MeansSomething outside the repository tried to reach the DAO directly, going around the desk to walk into the stacks.
FixAdd a function to the repository that does what the caller needs and give it a name in the app's own vocabulary. If you find yourself wanting to make dao public, the layering has gone wrong somewhere above.
e: Suspend function 'delete' should be called only from a coroutine or another suspend function
MeansA repository function that touches storage was called from ordinary code — very often straight from an onClick.
FixWrap it in the ViewModel: fun delete(note: Note) { viewModelScope.launch { repo.delete(note) } }. The screen calls the plain function; the ViewModel owns the coroutine.
e: Interface NotesSource does not have constructors
MeansYou tried to create an interface directly with NotesSource(...). An interface is a contract, not a thing — something has to fulfil it.
FixCreate the class that implements it: NotesRepository(dao). Hold it in a variable typed as the interface if you want callers to depend only on the contract.
Recap
  • 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.