Repository Pattern in Android
Repositorys trennen Datenquellen von deiner App-Logik. So bleibt Zugriff testbar, austauschbar und klar.
Das Repository Pattern hilft dir, Datenzugriff in Android-Apps sauber zu ordnen. Statt dass dein ViewModel direkt mit Retrofit, Room, DataStore oder einer Cache-Klasse spricht, fragt es ein Repository nach den Daten, die es für einen Bildschirm oder einen Anwendungsfall braucht. Dadurch entsteht eine klare Grenze: Die UI kennt die fachliche Absicht, das Repository kennt die Quellen.
Was ist das?
Ein Repository ist eine Klasse, die Datenquellen hinter einer passenden Schnittstelle versteckt. Diese Schnittstelle beschreibt nicht, woher Daten kommen, sondern was deine App fachlich benötigt. Ein UserRepository liefert also zum Beispiel ein Benutzerprofil, aktualisiert Einstellungen oder beobachtet Login-Zustände. Ob diese Informationen aus einer REST-API, einer lokalen Room-Datenbank, DataStore, einem In-Memory-Cache oder einer Kombination davon stammen, bleibt außerhalb verborgen.
Das mentale Modell ist: Ein Repository ist kein Sammelordner für beliebigen Code, sondern eine fachliche Tür zur Datenschicht. Deine UI-Schicht und dein ViewModel sollen nicht entscheiden, ob zuerst der Cache gelesen, danach ein Netzwerkaufruf gestartet oder ein Fehler in ein Domain-Modell übersetzt wird. Diese Entscheidungen gehören in die Datenschicht. Das passt zu moderner Android-Architektur, bei der Verantwortlichkeiten getrennt werden: Compose zeigt Zustand an, ViewModels bereiten UI-State vor, und Repositorys liefern oder verändern Daten.
Der wichtigste Nutzen liegt in Abstraktion und Testbarkeit. Wenn ein ViewModel nur ein Interface wie TaskRepository kennt, kannst du im Test ein Fake-Repository einsetzen. Der Test muss dann keinen echten HTTP-Client, keine echte Datenbank und keinen Android-Kontext initialisieren. Du prüfst gezielt, wie sich die App-Logik verhält, wenn Daten geladen werden, leer sind oder ein Fehler auftritt. Das macht Tests schneller, klarer und weniger anfällig.
Im Alltag begegnet dir das Pattern fast überall: Login, Profile, Warenkorb, Notizen, Nachrichten, Einstellungen, Synchronisierung oder Offline-Modus. Je mehr Quellen beteiligt sind, desto wichtiger wird die Grenze. Ohne Repository rutschen technische Details schnell nach oben in ViewModels oder sogar direkt in Composables. Dann werden Bildschirme schwer zu lesen, Tests kompliziert und Änderungen riskanter. Wenn später eine API-Version gewechselt, ein lokaler Cache ergänzt oder eine Offline-Strategie eingebaut wird, willst du nicht jeden UI-Aufrufer anfassen müssen.
Für Lernende ist dabei eine Sache besonders wichtig: Ein Repository ist kein magischer Architekturbaustein, den du immer in jede kleine Beispiel-App pressen musst. Es lohnt sich, sobald Datenzugriff mehr als ein trivialer Funktionsaufruf ist oder sobald du App-Logik testen willst. In einer echten App passiert das sehr früh. Schon eine Liste, die aus dem Netzwerk geladen, lokal zwischengespeichert und als Flow beobachtet wird, profitiert von einer Repository-Grenze.
Wie funktioniert es?
Das Repository Pattern arbeitet meistens mit drei Bausteinen: einem Interface, einer Implementierung und einer Einbindung per Dependency Injection oder manueller Übergabe. Das Interface ist der Vertrag für den Rest der App. Es sagt: Diese Daten oder Aktionen kannst du nutzen. Die Implementierung entscheidet: So beschaffe, speichere, kombiniere und mappe ich sie.
Ein gutes Repository-Interface richtet sich an den Bedürfnissen der App aus. Schlechte Namen verraten oft technische Details, etwa getTasksFromApi() oder insertTaskIntoDb(). Bessere Namen beschreiben den fachlichen Zugriff: observeTasks(), refreshTasks(), completeTask(id) oder createTask(title). Dadurch bleibt das ViewModel unabhängig von der Quelle. Wenn observeTasks() heute aus Room liest und morgen einen synchronisierten Cache nutzt, bleibt der Aufrufer stabil.
In Android werden Repositorys häufig mit Coroutines und Flow kombiniert. Ein lesender Zugriff kann ein Flow<List<Task>> zurückgeben, wenn sich die Daten über Zeit ändern. Ein einzelner Netzwerkaufruf kann eine suspend-Funktion sein. Schreibende Aktionen sind ebenfalls oft suspend, weil sie Datenbank- oder Netzwerkzugriff enthalten können. Wichtig ist, dass das Repository keine UI-Objekte zurückgibt. Es sollte keine Compose-States, keine SnackbarHostState und keine Android-Views kennen. Es liefert Datenmodelle oder Domain-Modelle, die von höheren Schichten in UI-State übersetzt werden.
Die Datenschicht kann mehrere Quellen kombinieren. Ein typischer Ablauf ist: Die App beobachtet lokale Daten aus Room, das Repository startet bei Bedarf eine Aktualisierung aus dem Netzwerk, speichert die Antwort lokal, und die UI bekommt die Änderung über den lokalen Datenstrom. Damit liegt die Wahrheit für die Anzeige oft in der lokalen Quelle, während das Netzwerk für Aktualisierung sorgt. Diese Struktur hilft auch bei schlechter Verbindung, weil die App weiterhin vorhandene Daten anzeigen kann.
Fehlerbehandlung gehört ebenfalls zur Repository-Grenze, aber mit Maß. Das Repository sollte technische Fehler so übersetzen, dass höhere Schichten sinnvoll reagieren können. Ein IOException kann zum Beispiel in ein fachliches Ergebnis wie NetworkUnavailable übersetzt werden. Gleichzeitig sollte das Repository nicht entscheiden, welche Fehlermeldung exakt im UI-Text steht. Diese Entscheidung hängt vom Bildschirm, von Sprache und Nutzerführung ab. Du trennst also technische Ursache, fachliche Bedeutung und Darstellung.
Für Compose ist diese Trennung auch aus Performance-Sicht nützlich. Composables sollten möglichst wenig Arbeit bei jeder Recomposition erledigen. Wenn Datenzugriff, Mapping und Aktualisierung sauber im ViewModel und Repository liegen, bleibt Compose stärker auf Anzeige und Interaktion fokussiert. Das bedeutet nicht, dass ein Repository direkt Performance optimiert. Es verhindert aber, dass schwere Arbeit versehentlich in die UI rutscht und dort bei Zustandsänderungen mehrfach ausgeführt wird.
Ein Repository ist außerdem eine gute Stelle für Qualitätsregeln. Du kannst dort festlegen, ob Daten zuerst lokal gelesen werden, wann aktualisiert wird, wie lange ein Cache gültig ist und welche Modelle nach außen gelangen. Diese Regeln sind für die App-Qualität relevanter als sie auf den ersten Blick wirken. Nutzer merken nicht, ob du intern ein Pattern verwendest. Sie merken aber, ob die App verlässlich lädt, alte Daten klar behandelt, Fehler verständlich meldet und bei wechselnder Verbindung stabil bleibt.
In der Praxis
Stell dir eine kleine Aufgaben-App vor. Der Bildschirm zeigt eine Liste von Aufgaben, erlaubt das Abhaken und kann die Liste aktualisieren. Ohne Repository könnte dein ViewModel direkt einen Retrofit-Service und ein Room-DAO verwenden. Das funktioniert zunächst, aber das ViewModel wird schnell zur Mischklasse aus UI-State, Netzwerklogik, Cache-Regeln und Fehlerbehandlung. Mit Repository bleibt es schmaler.
Ein mögliches Interface sieht so aus:
interface TaskRepository {
fun observeTasks(): Flow<List<Task>>
suspend fun refreshTasks()
suspend fun setTaskDone(taskId: String, done: Boolean)
}
class DefaultTaskRepository(
private val remoteDataSource: TaskRemoteDataSource,
private val localDataSource: TaskLocalDataSource
) : TaskRepository {
override fun observeTasks(): Flow<List<Task>> {
return localDataSource.observeTasks()
.map { entities -> entities.map { it.toDomain() } }
}
override suspend fun refreshTasks() {
val remoteTasks = remoteDataSource.fetchTasks()
localDataSource.replaceTasks(
remoteTasks.map { it.toEntity() }
)
}
override suspend fun setTaskDone(taskId: String, done: Boolean) {
localDataSource.updateDone(taskId, done)
remoteDataSource.updateDone(taskId, done)
}
}
class TaskViewModel(
private val repository: TaskRepository
) : ViewModel() {
val uiState: StateFlow<TaskUiState> =
repository.observeTasks()
.map { tasks -> TaskUiState.Content(tasks) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TaskUiState.Loading
)
fun refresh() {
viewModelScope.launch {
repository.refreshTasks()
}
}
fun setDone(taskId: String, done: Boolean) {
viewModelScope.launch {
repository.setTaskDone(taskId, done)
}
}
}
Das Beispiel zeigt die Kernidee: Das ViewModel weiß, dass es Aufgaben beobachten und Aktionen auslösen kann. Es weiß nicht, ob refreshTasks() Retrofit nutzt, ob observeTasks() aus Room kommt oder ob später ein anderer Cache eingebaut wird. Die Methoden sind an der App-Sprache orientiert. Das Repository versteckt die technischen Einzelheiten hinter einem Vertrag.
Für Tests ist dieses Design sehr hilfreich. Du kannst ein Fake schreiben, das nur das Interface erfüllt:
class FakeTaskRepository : TaskRepository {
private val tasks = MutableStateFlow<List<Task>>(emptyList())
override fun observeTasks(): Flow<List<Task>> = tasks
override suspend fun refreshTasks() {
tasks.value = listOf(Task(id = "1", title = "Test schreiben", done = false))
}
override suspend fun setTaskDone(taskId: String, done: Boolean) {
tasks.value = tasks.value.map { task ->
if (task.id == taskId) task.copy(done = done) else task
}
}
}
Damit kannst du dein ViewModel testen, ohne echte Datenquellen aufzubauen. Du steuerst Daten und Fehler gezielt. Genau das ist der praktische Wert von Testbarkeit: Du testest Verhalten, nicht Infrastruktur. Wenn ein Test fehlschlägt, liegt die Ursache eher in deiner Logik und weniger in Netzwerk, Dateisystem oder Geräteumgebung.
Eine Entscheidungsregel für den Alltag lautet: Das Repository-Interface sollte so aussehen, als würdest du einem anderen Entwickler erklären, was der fachliche Bereich kann. Wenn du beim Lesen viele technische Wörter wie API, DAO, Cursor, JSON oder Retrofit siehst, ist die Abstraktion wahrscheinlich zu nah an der Implementierung. Wenn du dagegen nur noch unklare Sammelmethoden wie loadData() oder saveStuff() hast, ist sie zu unscharf. Gute Repository-Methoden sind konkret, aber nicht quellenspezifisch.
Eine typische Stolperfalle ist das zu große Repository. Anfänger bauen gern ein AppRepository, das Login, Nutzerprofil, Einstellungen, Aufgaben und Statistiken verwaltet. Das wirkt zunächst bequem, führt aber zu hoher Kopplung. Jede Änderung kann viele Tests betreffen, und die Klasse bekommt mehrere Gründe, sich zu ändern. Besser ist eine fachliche Trennung: AuthRepository, UserRepository, TaskRepository, SettingsRepository. Diese Namen müssen zu deiner App passen, nicht zu einem Lehrbuch.
Eine zweite Stolperfalle ist das Durchreichen fremder Datenmodelle. Wenn dein Repository direkt Retrofit-DTOs an das ViewModel gibt, hängt die UI indirekt am API-Vertrag. Wenn du direkt Room-Entities nach oben gibst, hängt die UI am Datenbankschema. In kleinen Beispielen wirkt das harmlos. In echten Apps erschwert es Migrationen, Offline-Logik und Tests. Meist ist es sauberer, DTOs und Entities im Repository oder in Mappern in App-Modelle zu übersetzen.
Auch Fehler beim Threading sind häufig. Datenbank- und Netzwerkzugriffe dürfen die UI nicht blockieren. Coroutines helfen dir, aber sie ersetzen keine klare Verantwortung. Das Repository sollte suspend-Funktionen und Flow so anbieten, dass Aufrufer nicht wissen müssen, welche Quelle dahinterliegt. Gleichzeitig sollte es nicht heimlich neue globale Scopes starten. Nutze strukturierte Nebenläufigkeit: Das ViewModel startet Arbeit in viewModelScope, das Repository führt die angeforderte Operation aus.
Achte außerdem darauf, Repositorys nicht als Ort für jede Geschäftsregel zu missbrauchen. Manche Apps haben zusätzlich eine Domain-Schicht mit Use-Cases. Das ist sinnvoll, wenn fachliche Abläufe mehrere Repositorys koordinieren oder komplexe Regeln enthalten. Für das Repository Pattern selbst reicht aber die klare Grenze zur Datenschicht: Es kapselt Datenquellen, stellt passende Operationen bereit und bleibt testbar. Du musst nicht jede App mit zusätzlichen Schichten aufblasen, nur weil das Pattern existiert.
In Code-Reviews kannst du gezielt nach Repository-Qualität suchen. Frage dich: Greift ein ViewModel direkt auf Retrofit, Room oder DataStore zu? Verraten Methodennamen technische Quellen? Werden DTOs oder Entities bis in die UI gereicht? Ist das Repository fachlich zu breit? Gibt es ein Interface, das Tests erleichtert? Sind Fehler und Ladezustände nachvollziehbar modelliert? Diese Fragen bringen dich schnell von abstrakter Architektur zu konkreten Verbesserungen.
Für Compose-Bildschirme ist die praktische Regel: Der Composable erhält UI-State und ruft Callbacks auf. Er startet keine Datenbankabfragen und baut keine Repositorys selbst. Das ViewModel hängt am Repository, sammelt Datenströme und formt sie zu UI-State. Dadurch bleibt die UI leichter zu verstehen und leichter zu testen. Außerdem kannst du Previews und isolierte UI-Tests einfacher bauen, weil der Bildschirm nicht an echte Infrastruktur gekoppelt ist.
Wenn du mit Dependency Injection arbeitest, wird das Repository-Interface an einer zentralen Stelle mit der Implementierung verbunden. Ob du Hilt, Koin oder manuelle Konstruktorübergabe nutzt, ist für das Pattern zweitrangig. Entscheidend ist, dass Abhängigkeiten von außen kommen und nicht tief in Klassen selbst gebaut werden. Ein ViewModel, das intern DefaultTaskRepository(Retrofit..., Room...) erstellt, ist schwer zu testen. Ein ViewModel, das ein TaskRepository im Konstruktor erhält, ist flexibel.
Du kannst dein Verständnis praktisch prüfen, indem du eine vorhandene kleine App nimmst und eine Datenquelle hinter ein Repository verschiebst. Starte mit einem konkreten Bereich, etwa Aufgaben, Notizen oder Einstellungen. Schreibe zuerst das Interface, dann die Implementierung, dann passe das ViewModel an. Danach schreibe einen Fake und teste einen einfachen Ablauf: leerer Startzustand, Laden von Daten, Änderung eines Eintrags, Fehlerfall. Wenn du dabei keine echten Android-Komponenten brauchst, ist deine Grenze wahrscheinlich brauchbar.
Fazit
Das Repository Pattern ist eine einfache, aber wichtige Architekturgrenze: Deine App fragt nach fachlichen Daten und Aktionen, während die technischen Datenquellen verborgen bleiben. Dadurch wird Datenzugriff klarer, App-Logik besser testbar und spätere Änderung an Netzwerk, Datenbank oder Cache weniger riskant. Prüfe das Gelernte aktiv an eigenem Code: Suche direkte Datenquellen im ViewModel, ersetze sie durch ein passendes Repository-Interface, schreibe einen Fake für Tests und kontrolliere im Code-Review, ob Methodennamen zur App und nicht zur Infrastruktur sprechen.