Network-Bound Resource Pattern
Lerne, wie du entfernte Daten mit einem lokalen Cache verbindest. Das Pattern hilft dir bei stabilen Offline-First-Apps.
Viele Android-Apps zeigen Daten, die von einem Server kommen, aber trotzdem schnell, stabil und auch bei schlechter Verbindung nutzbar sein sollen. Das Network-Bound Resource Pattern gibt dir dafür eine wiederverwendbare Struktur: Lies zuerst aus dem lokalen Cache, entscheide dann über einen Fetch aus dem Netzwerk, speichere neue Daten dauerhaft und lasse die UI immer aus derselben lokalen Quelle beobachten.
Was ist das?
Das Network-Bound Resource Pattern ist ein Architekturpattern für den Data Layer. Es beschreibt, wie du Daten aus zwei Welten zusammenführst: einer lokalen Datenquelle, zum Beispiel Room, und einer entfernten Datenquelle, zum Beispiel einer REST-API über Retrofit oder Ktor. Der Name klingt groß, die Grundidee ist aber gut greifbar: Netzwerkdaten werden nicht direkt an die UI durchgereicht, sondern zuerst in eine lokale Source of Truth geschrieben. Die UI liest danach aus dieser lokalen Quelle.
Damit löst du ein sehr typisches Problem im Android-Alltag. Wenn deine Compose-Oberfläche direkt auf Netzwerkantworten wartet, hängt die Nutzererfahrung stark an Verbindung, Latenz und Fehlerfällen. Außerdem musst du dann überall Sonderlogik für Loading, Retry, leere Antworten und alte Daten einbauen. Mit dem Pattern kapselst du diese Entscheidung im Repository. Das Repository weiß, wo Daten liegen, wie frisch sie sein müssen und wie neue Daten gespeichert werden.
Das mentale Modell ist: cache, fetch, persist. Erst kommt der Cache, weil er schnell und lokal ist. Dann kommt der Fetch, wenn Daten fehlen oder veraltet sind. Danach kommt Persistenz, damit die neue Antwort nicht nur kurz im Speicher liegt, sondern als stabiler Zustand verfügbar bleibt. Für moderne Android-Apps passt dieses Modell gut zur empfohlenen Schichtung aus UI Layer, Domain Layer und Data Layer. Compose sammelt beispielsweise einen Flow aus dem Repository, während Room Änderungen automatisch weitergibt.
Wichtig ist die Rollenverteilung. Die UI entscheidet nicht, ob eine API aufgerufen wird. Sie zeigt Zustand an und sendet Nutzeraktionen. Das Repository entscheidet über Datenstrategie. Die lokale Datenbank ist der verlässliche Stand der App. Das Netzwerk ist eine Quelle für Aktualisierungen, aber nicht der Ort, an dem deine UI ihren stabilen Zustand halten sollte.
Wie funktioniert es?
Ein typischer Ablauf beginnt mit einer Abfrage aus der lokalen Datenquelle. Das kann ein Flow<List<ArticleEntity>> aus einem Room-DAO sein. Die UI erhält dadurch sofort etwas: vorhandene Daten, eine leere Liste oder einen Ladezustand. Parallel oder vorab prüft das Repository, ob ein Netzwerkabruf sinnvoll ist. Kriterien können sein: Der Cache ist leer, ein Zeitstempel ist abgelaufen, der Nutzer hat Pull-to-refresh ausgelöst oder eine bestimmte Entität wurde noch nie geladen.
Wenn ein Fetch nötig ist, ruft das Repository die Remote-Datenquelle auf. Die Antwort wird in lokale Modelle umgewandelt und in einer Transaktion gespeichert. Danach muss das Repository die Daten nicht manuell zurück an die UI schieben, wenn Room als beobachtbare Source of Truth verwendet wird. Die Datenbankänderung triggert den Flow, und Compose aktualisiert die Oberfläche über collectAsStateWithLifecycle oder eine vergleichbare lifecycle-bewusste Sammlung.
Das Pattern besteht also nicht aus einer einzelnen Android-API. Es ist eher eine klare Koordination von APIs und Verantwortlichkeiten. Kotlin Coroutines liefern die asynchrone Ausführung. Flow liefert beobachtbare Datenströme. Room übernimmt lokale Persistenz. Retrofit oder ein anderer Client übernimmt den Netzwerkzugriff. Der Repository-Layer verbindet diese Bausteine und verbirgt Details vor ViewModels und UI.
Ein häufiges Zustandsmodell unterscheidet zwischen Daten und Ladeinformation. Du kannst zum Beispiel lokale Daten weiter anzeigen, während im Hintergrund ein Refresh läuft. Das ist oft besser als die Liste bei jedem Fetch leer zu räumen. Für Lernende ist dieser Punkt zentral: Loading bedeutet nicht automatisch, dass keine Daten angezeigt werden dürfen. Eine App kann vorhandene Cache-Daten zeigen und zusätzlich signalisieren, dass gerade aktualisiert wird.
Auch Fehler gehören zur Mechanik. Wenn der Fetch scheitert, solltest du nicht automatisch den Cache verwerfen. Bei Offline-First-Apps ist ein Netzwerkfehler oft kein kompletter App-Fehler, sondern nur ein fehlgeschlagener Refresh. Die UI kann vorhandene Daten weiter anzeigen und eine kurze Fehlermeldung oder einen Retry anbieten. Das Repository sollte daher sauber unterscheiden zwischen „keine lokalen Daten vorhanden“ und „lokale Daten vorhanden, aber Refresh fehlgeschlagen“.
Die Frische von Daten ist eine fachliche Entscheidung. Profilinformationen, Nachrichten, Preise oder Konfigurationen haben unterschiedliche Anforderungen. Das Pattern zwingt dich nicht zu einer festen Regel. Es gibt dir den Ort, an dem diese Regel kontrolliert umgesetzt wird. Genau dadurch bleibt dein Code testbar: Du kannst prüfen, ob bei leerem Cache ein Fetch passiert, ob bei frischem Cache kein Fetch passiert und ob neue Remote-Daten korrekt persistiert werden.
In der Praxis
Stell dir eine App vor, die eine Liste von Lernartikeln anzeigt. Die Artikel kommen von einer API, sollen aber auch beim zweiten Start sofort sichtbar sein. Das Repository gibt einen Flow<Resource<List<Article>>> zurück. Resource kann Daten, Ladezustand und Fehler bündeln. In einer echten App würdest du die Mapper und Modelle sauber trennen; das Beispiel zeigt nur die Struktur.
sealed interface Resource<out T> {
data class Success<T>(val data: T, val refreshing: Boolean = false) : Resource<T>
data class Error<T>(val data: T?, val message: String) : Resource<T>
data object Loading : Resource<Nothing>
}
class ArticleRepository(
private val dao: ArticleDao,
private val api: ArticleApi,
private val clock: Clock
) {
fun articles(forceRefresh: Boolean = false): Flow<Resource<List<Article>>> = flow {
val cached = dao.observeArticles()
.map { entities -> entities.map { it.toDomain() } }
emit(Resource.Loading)
val firstCached = dao.getArticlesOnce()
val shouldFetch = forceRefresh ||
firstCached.isEmpty() ||
firstCached.maxOf { it.updatedAtMillis } < clock.nowMillis() - CACHE_TTL_MILLIS
if (shouldFetch) {
try {
val remote = api.getArticles()
dao.replaceAll(remote.map { it.toEntity() })
} catch (exception: IOException) {
val fallback = firstCached.map { it.toDomain() }
emit(Resource.Error(fallback, "Daten konnten nicht aktualisiert werden."))
}
}
emitAll(cached.map { Resource.Success(it) })
}
private companion object {
const val CACHE_TTL_MILLIS = 30 * 60 * 1000L
}
}
Das Beispiel ist bewusst kompakt, zeigt aber die wichtigsten Entscheidungen. dao.getArticlesOnce() hilft bei der Fetch-Entscheidung. dao.observeArticles() liefert den dauerhaften Datenstrom für die UI. api.getArticles() wird nur aufgerufen, wenn die Cache-Regel es verlangt. dao.replaceAll() persistiert die Netzwerkantwort. In einer Room-Implementierung sollte replaceAll() als Transaktion laufen, damit die Datenbank nicht kurzzeitig in einem halbfertigen Zustand ist.
In Compose würde dein ViewModel diesen Flow sammeln oder als StateFlow bereitstellen. Die Composable selbst muss nicht wissen, ob Daten aus dem Netzwerk oder aus Room kommen. Sie unterscheidet nur Zustände: Ladeanzeige, Datenliste, leere Liste, Fehlermeldung. Diese Trennung macht UI-Code leichter lesbar und reduziert doppelte Logik.
Eine praktische Entscheidungsregel lautet: Wenn Daten für die Oberfläche wichtig sind, speichere sie lokal und rendere aus der lokalen Quelle. Direkte Netzwerkantworten eignen sich eher für einmalige Aktionen, etwa das Absenden eines Formulars oder das Anfordern eines Tokens. Für wiederholt angezeigte Listen, Detailseiten und Offline-First-Funktionen ist eine lokale Source of Truth meist robuster.
Eine typische Stolperfalle ist ein Repository, das nach dem Fetch direkt die Netzwerkantwort zurückgibt und zusätzlich irgendwann die Datenbank aktualisiert. Dadurch entstehen zwei Wahrheiten: Die UI zeigt eventuell Daten, die nicht exakt dem lokalen Zustand entsprechen. Beim nächsten App-Start sieht der Nutzer dann etwas anderes. Sauberer ist: Remote-Antwort validieren, in lokale Modelle mappen, speichern, danach aus der lokalen Quelle beobachten.
Eine zweite Stolperfalle liegt im Fehlerhandling. Viele Einsteiger setzen bei jedem Refresh erst Loading und leeren damit implizit die Liste. Bei schlechter Verbindung flackert die Oberfläche oder wirkt kaputt, obwohl brauchbare Cache-Daten vorhanden sind. Besser ist ein Zustand wie Success(data, refreshing = true) oder ein separates UI-Flag. So bleibt der Inhalt sichtbar, während du Aktualisierung und Fehler klar anzeigen kannst.
Testen solltest du das Pattern nicht nur über die UI. Schreibe Unit-Tests für die Fetch-Entscheidung im Repository: leerer Cache ruft die API auf, frischer Cache ruft sie nicht auf, erzwungener Refresh ruft sie auf. Prüfe außerdem, dass Remote-Daten über den DAO gespeichert werden. Mit Fake-DAO und Fake-API kannst du diese Fälle ohne echtes Netzwerk testen. Für Room-Mapping und Transaktionen können Integrationstests sinnvoll sein. In einer CI-Pipeline sollten diese Tests regelmäßig laufen, weil kleine Änderungen am Data Layer schnell sichtbare App-Qualität beeinflussen.
Beim Code-Review kannst du gezielt nach drei Fragen suchen. Erstens: Gibt es genau eine lokale Source of Truth für diesen Screen? Zweitens: Ist die Fetch-Regel verständlich und testbar? Drittens: Bleiben vorhandene Daten bei Netzwerkfehlern erhalten? Wenn du diese Fragen klar beantworten kannst, ist das Pattern meist sinnvoll umgesetzt.
Fazit
Das Network-Bound Resource Pattern hilft dir, Android-Datenflüsse stabil zu bauen: Die UI beobachtet den Cache, das Repository entscheidet über den Fetch, und neue Remote-Daten werden persistiert. Übe das Pattern an einer kleinen Liste mit Room, Fake-API und Repository-Tests. Setze Breakpoints vor der Fetch-Entscheidung, beim Speichern und beim erneuten Emittieren aus der Datenbank. So prüfst du nicht nur, ob der Code läuft, sondern ob du den Datenfluss wirklich verstanden hast.