Data Sources: Lokale und Remote-Daten sauber trennen
Data Sources kapseln den Zugriff auf lokale und Remote-Daten. Repositories koordinieren sie, damit der Rest der App nichts vom Speicherort wissen muss.
Wer eine Android-App mit mehreren Datenquellen baut – etwa einer lokalen Room-Datenbank und einer REST-API –, steht schnell vor der Frage: Wer fragt wo nach, und wer entscheidet, welche Antwort gilt? Data Sources liefern eine klare Antwort. Sie sind die Basisschicht der Data-Layer-Architektur und sorgen dafür, dass jede Datenherkunft in einer eigenen, austauschbaren Klasse lebt, die das Repository über sich nicht zu wissen braucht.
Was ist das?
Eine Data Source ist eine Klasse mit genau einer Zuständigkeit: dem Zugriff auf eine einzelne Datenquelle. In modernem Android unterscheidest du zwei Kategorien. Lokale Data Sources greifen auf geräteinterne Speicher zu – typischerweise Room für strukturierte Daten, DataStore für einfache Schlüssel-Wert-Paare oder SharedPreferences für Nutzereinstellungen. Remote Data Sources kommunizieren mit externen Systemen, meist einer REST- oder GraphQL-API via Retrofit.
Das Entscheidende ist die strikte Einzelverantwortung. Eine LocalArticleDataSource weiß nichts vom Netzwerk; eine RemoteArticleDataSource weiß nichts von Room. Diese Trennung ist kein akademisches Prinzip – sie ist der Grund, warum du Unit-Tests für beide Seiten schreiben kannst, ohne Mocks für die jeweils andere aufzubauen. Jede Klasse bleibt klein, leicht lesbar und einzeln austauschbar.
Im offiziellen Android-Architekturmodell leben Data Sources unterhalb des Repositories. Das Repository ist der einzige Ort, der beide kennt und ihre Zusammenarbeit koordiniert. Der Rest der App – ViewModels, Use Cases, UI – sieht nur das Repository und hat keine Ahnung, ob die Daten aus einer SQLite-Datenbank oder einem HTTP-Response stammen.
Wie funktioniert es?
Das typische Setup sieht so aus: Das Repository hält Referenzen auf eine LocalDataSource und eine RemoteDataSource. Beide implementieren im Idealfall ein abgestimmtes Interface, damit das Repository sie einheitlich ansprechen kann.
Der Ablauf für einen Lesezugriff folgt häufig dem Cache-First-Muster:
- Lokalen Cache prüfen. Gibt es aktuelle Daten in Room, liefere sie sofort als Flow an die UI.
- Netzwerk befragen. Sind die Daten veraltet oder fehlen sie, ruf die Remote-Quelle auf.
- Cache aktualisieren. Frische Remote-Daten werden in die lokale Data Source geschrieben, bevor sie nach oben weitergegeben werden.
Für Schreibzugriffe in Offline-First-Apps dreht sich die Reihenfolge um: Lokal zuerst schreiben, dann den Server benachrichtigen – so bleibt die UI reaktiv, auch wenn die Verbindung kurz abbricht.
Room eignet sich besonders als lokale Data Source, weil es reaktive Flows liefert. Mit @Query und Flow<List<T>> bekommst du automatische UI-Updates, sobald sich Daten in der Datenbank ändern – ohne manuellen Anstoß. Das macht die Kombination aus Room-DataSource und collectAsStateWithLifecycle in Compose zu einem sehr reibungslosen Muster.
In der Praxis
Betrachte eine News-App. Du definierst zunächst die Interfaces für beide Quellen:
interface LocalArticleDataSource {
fun observeArticles(): Flow<List<Article>>
suspend fun saveArticles(articles: List<Article>)
suspend fun clearAll()
}
interface RemoteArticleDataSource {
suspend fun fetchArticles(): List<Article>
}
Die Room-Implementierung delegiert an ein DAO:
class RoomArticleDataSource(
private val dao: ArticleDao
) : LocalArticleDataSource {
override fun observeArticles(): Flow<List<Article>> = dao.observeAll()
override suspend fun saveArticles(articles: List<Article>) = dao.insertAll(articles)
override suspend fun clearAll() = dao.deleteAll()
}
Das Repository verbindet beide Quellen und implementiert das Cache-First-Muster:
class ArticleRepository(
private val local: LocalArticleDataSource,
private val remote: RemoteArticleDataSource
) {
fun getArticles(): Flow<List<Article>> = flow {
emitAll(local.observeArticles())
runCatching { remote.fetchArticles() }
.onSuccess { fresh ->
local.clearAll()
local.saveArticles(fresh)
}
}
}
Typische Stolperfalle: Cache-Invalidierung vergessen
Ein sehr häufiger Fehler ist, neue Remote-Daten einfach per insertAll in Room zu schreiben, ohne veraltete Einträge zu entfernen. Wenn der Server einen Artikel löscht, bleibt er im lokalen Cache – für den Nutzer unsichtbar, aber im Datenbankinhalt dauerhaft vorhanden. Nutze entweder OnConflictStrategy.REPLACE im DAO oder rufe clearAll() explizit vor dem Neuschreiben auf, wenn die Datenmenge überschaubar ist. Für große Datensätze eignet sich ein Timestamp-basiertes Invalidierungsfeld besser.
Eine weitere Falle: Netzwerkanfragen direkt im ViewModel aufzurufen und das Ergebnis manuell an Room weiterzugeben. Das umgeht die Data-Source-Schicht vollständig, macht Klassen schwer testbar und verstreut Caching-Logik durch die gesamte Codebasis. Halte das ViewModel konsequent raus – es kennt ausschließlich das Repository.
Fazit
Data Sources sind das Fundament, auf dem Repositories erst sinnvoll werden. Wer lokale und Remote-Logik sauber trennt, gewinnt isoliert testbare Einheiten, eine klare Caching-Strategie und eine Architektur, die Offline-First-Anforderungen ohne große Umbauten aufnehmen kann. Schau dir jetzt deine aktuelle Codebasis an: Greift dein Repository direkt auf Retrofit-Calls oder Room-DAOs zu, ohne eine Data-Source-Klasse dazwischen? Dann ist genau jetzt der richtige Moment, diese Schicht einzuziehen. Schreib zuerst die Interfaces, dann die Implementierungen, und lass das Repository danach ausschließlich über die Interfaces kommunizieren – das gibt dir sofort die Möglichkeit, beide Seiten mit einfachen Fake-Implementierungen zu testen.