DAO-Queries in Android: SQL, suspend und Flow
DAO-Queries kapseln Datenbankzugriffe sauber. Du lernst, wann suspend reicht und wann Flow die bessere Wahl ist.
DAO-Queries sind die Zugriffsmethoden, mit denen du in Android-Apps Daten aus einer lokalen Datenbank liest, einfügst, änderst oder löschst. In modernen Projekten begegnen sie dir meist in Room-DAOs: Du formulierst SQL nah an der Datenbank, nutzt Kotlin-Typen an der Schnittstelle und entscheidest bewusst zwischen suspend für einzelne Ergebnisse und Flow für beobachtbare Datenströme.
Was ist das?
Ein DAO, also Data Access Object, ist eine klar abgegrenzte Schnittstelle zur Datenbank. Eine DAO-Query ist eine Methode in diesem DAO, die eine konkrete Datenbankfrage oder Datenbankaktion beschreibt. Du kannst dir das DAO als Vertrag vorstellen: Der Rest deiner App sagt, welche Daten er braucht, aber nicht, wie Tabellen, Joins oder Filter genau abgefragt werden.
Das ist in Android wichtig, weil Apps selten nur eine einzige Datenquelle haben. Eine typische App hat eine UI in Jetpack Compose, ein ViewModel, ein Repository und eine lokale Datenbank. Das DAO sitzt in der Data Layer und kapselt die SQL-Details. Dadurch muss deine Compose-Oberfläche nicht wissen, ob ein Eintrag aus einer Tabelle tasks, users oder articles kommt. Sie bekommt fertige Kotlin-Objekte, meist über Repository und ViewModel.
Der Kern dieses Roadmap-Themas ist die Unterscheidung zwischen zwei Arten von Zugriffen. Einmalige Reads und Writes passen zu suspend: Du rufst eine Funktion auf, wartest nicht blockierend auf das Ergebnis und arbeitest danach weiter. Beobachtbare Daten passen zu Flow: Du abonnierst eine Abfrage und bekommst neue Werte, sobald sich die zugrunde liegenden Daten ändern. Genau diese Unterscheidung macht DAO-Queries in echten Android-Projekten so nützlich.
Als mentales Modell hilft dir: Eine suspend-DAO-Methode ist wie eine Momentaufnahme. Eine Flow-DAO-Methode ist wie ein laufender Blick auf dieselbe Frage. Beide verwenden SQL, aber sie bedienen unterschiedliche Anforderungen deiner App.
Wie funktioniert es?
In Room definierst du ein DAO meistens als interface oder abstract class und annotierst Methoden mit @Query, @Insert, @Update oder @Delete. Bei @Query schreibst du SQL direkt an die Methode. Room prüft viele Dinge schon beim Kompilieren: ob Tabellen und Spalten existieren, ob Rückgabetypen passen und ob Parameter korrekt gebunden werden. Das ist ein großer Qualitätsgewinn gegenüber selbstgebautem SQL über rohe Cursor.
Eine suspend-Query nutzt Kotlin Coroutines. Sie blockiert den Main Thread nicht, obwohl sie dir wie normaler sequenzieller Code vorkommt. Das ist gerade für Android zentral, weil Datenbankarbeit nicht die UI einfrieren darf. Du verwendest suspend, wenn du einen einzelnen Wert, eine Liste oder das Ergebnis einer Aktion brauchst. Beispiele sind: einen Nutzer per ID laden, einen Datensatz einfügen, eine Prüfung beim App-Start ausführen oder einen einmaligen Suchlauf starten.
Flow verwendest du, wenn die UI mit lokalen Daten synchron bleiben soll. Room kann bei Flow beobachten, welche Tabellen eine Query betrifft. Ändert sich eine dieser Tabellen, führt Room die Abfrage erneut aus und gibt einen neuen Wert aus. Für Compose ist das sehr passend: Das ViewModel sammelt den Flow ein oder stellt ihn als State bereit, und die UI zeichnet sich neu, wenn neue Daten ankommen.
Wichtig ist dabei die Richtung der Verantwortung. Das DAO sollte Datenbankzugriffe beschreiben, aber keine UI-Logik enthalten. Ein DAO entscheidet nicht, ob ein leerer Zustand angezeigt wird, ob ein Snackbar-Text erscheint oder ob ein Netzwerk-Refresh gestartet werden soll. Diese Entscheidungen gehören in Repository, Use Case oder ViewModel. Das DAO bleibt klein, präzise und testbar.
Im Alltag sieht das so aus: Du definierst erst, welche Daten die App wirklich braucht. Dann formulierst du eine konkrete SQL-Query. Danach entscheidest du, ob die Antwort einmalig oder beobachtbar sein soll. Diese Entscheidung sollte nicht aus Bequemlichkeit fallen, sondern aus dem Verhalten der Funktion. Wenn eine Aufgabenliste in Compose automatisch aktualisiert werden soll, ist Flow<List<TaskEntity>> passend. Wenn du beim Speichern nur wissen musst, ob eine ID existiert, reicht oft suspend fun exists(id: Long): Boolean.
Für Offline-First-Architekturen ist dieser Unterschied besonders relevant. Die lokale Datenbank ist dort häufig die zentrale Quelle für den UI-Zustand. Netzwerkdaten werden in die Datenbank synchronisiert, und die UI beobachtet die Datenbank per Flow. So muss die UI nicht direkt wissen, ob Daten gerade aus dem Cache, aus einer Synchronisierung oder aus einer vorherigen Sitzung stammen. Das DAO liefert stabile lokale Wahrheit, während Repository und Sync-Logik die Aktualität verwalten.
In der Praxis
Stell dir eine einfache Notiz-App vor. Du hast eine Tabelle mit Notizen, und die Startansicht soll alle nicht archivierten Notizen anzeigen. Wenn eine Notiz hinzugefügt, geändert oder archiviert wird, soll die Liste automatisch reagieren. Gleichzeitig brauchst du für den Detailbildschirm eine einzelne Notiz anhand ihrer ID.
@Dao
interface NoteDao {
@Query("""
SELECT *
FROM notes
WHERE archived = 0
ORDER BY updatedAt DESC
""")
fun observeActiveNotes(): Flow<List<NoteEntity>>
@Query("""
SELECT *
FROM notes
WHERE id = :id
LIMIT 1
""")
suspend fun getNoteById(id: Long): NoteEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(note: NoteEntity)
@Query("UPDATE notes SET archived = 1 WHERE id = :id")
suspend fun archive(id: Long)
}
An diesem Beispiel siehst du die Regel recht klar. Die Liste ist beobachtbar, also gibt sie Flow<List<NoteEntity>> zurück. Die Detailabfrage ist eine einzelne Momentaufnahme, also ist sie suspend. Das Speichern und Archivieren sind Aktionen, deshalb sind auch sie suspend.
Im Repository würdest du diese Methoden nicht einfach blind weiterreichen, sondern in Domänenmodelle übersetzen und eine saubere API für den Rest der App anbieten:
class NoteRepository(
private val noteDao: NoteDao
) {
fun observeActiveNotes(): Flow<List<Note>> =
noteDao.observeActiveNotes()
.map { entities -> entities.map { it.toDomain() } }
suspend fun getNote(id: Long): Note? =
noteDao.getNoteById(id)?.toDomain()
suspend fun save(note: Note) {
noteDao.upsert(note.toEntity())
}
suspend fun archive(id: Long) {
noteDao.archive(id)
}
}
Das DAO bleibt dadurch auf SQL und Datenbanktypen fokussiert. Das Repository übersetzt zur Sprache deiner App. In einem ViewModel kann die Liste dann als State für Compose genutzt werden, ohne dass Compose die SQL-Query kennen muss:
class NotesViewModel(
repository: NoteRepository
) : ViewModel() {
val notes: StateFlow<List<Note>> =
repository.observeActiveNotes()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
}
Eine typische Stolperfalle ist, jede DAO-Methode als Flow zu schreiben, nur weil Flow modern wirkt. Das macht den Code nicht automatisch besser. Ein Flow ist ein Datenstrom mit Lebenszyklus, Sammlung und potenziell mehreren Emissionen. Wenn du nur einmal einen Wert brauchst, erzeugt ein Flow unnötige Komplexität. Andersherum ist es auch ein Fehler, eine UI-Liste per suspend einmal zu laden und danach manuell aktuell halten zu wollen. Dann baust du oft zusätzlichen Zustand, der bei Updates, Deletes oder Sync-Läufen schnell fehleranfällig wird.
Eine zweite Stolperfalle betrifft SQL selbst. SELECT * ist in kleinen Beispielen lesbar, aber in größeren Apps solltest du bewusster werden. Wenn deine UI nur drei Felder braucht, kann eine eigene Projektion sinnvoll sein. Wenn du sortierst, filterst oder suchst, prüfe, ob passende Indizes nötig sind. DAO-Queries sind nicht nur Kotlin-Schnittstellen, sondern echte Datenbankabfragen. Schlechte SQL-Entscheidungen werden bei wachsenden Datenmengen sichtbar.
Auch Fehlerbehandlung gehört nicht wahllos ins DAO. Eine suspend-Funktion kann Exceptions werfen, wenn die Datenbankoperation scheitert. Ein Flow kann ebenfalls Fehler weitergeben. Ob du daraus einen UI-State, ein Retry-Verhalten oder einen Log-Eintrag machst, sollte oberhalb des DAO entschieden werden. So bleibt die Architektur nachvollziehbar: DAO fragt Daten ab, Repository koordiniert Datenquellen, ViewModel formt UI-Zustand.
Für Tests kannst du DAO-Queries sehr direkt prüfen. Nutze eine In-Memory-Room-Datenbank, füge Testdaten ein und kontrolliere, ob deine Query genau die erwarteten Datensätze liefert. Bei Flow solltest du nicht nur den Startwert testen, sondern auch eine Änderung: erst Flow sammeln, dann Datensatz einfügen oder ändern, dann prüfen, ob eine neue Liste emittiert wird. In Code-Reviews lohnt sich eine einfache Frage: Passt der Rückgabetyp zum Verhalten? Wenn die Antwort „einmalig“ ist, spricht vieles für suspend. Wenn die Antwort „soll aktuell bleiben“ lautet, spricht vieles für Flow.
Fazit
DAO-Queries sind ein kleiner, aber entscheidender Baustein sauberer Android-Architektur: Sie verbinden SQL mit Kotlin und geben deiner Data Layer klare Grenzen. Übe die Unterscheidung aktiv, indem du in einem Beispielprojekt eine Liste als Flow und Detail- oder Schreiboperationen als suspend modellierst. Prüfe danach mit Tests oder Debugger, wann die Query ausgeführt wird, wann neue Werte ankommen und ob deine UI wirklich nur den Zustand beobachtet, statt Datenbankdetails zu kennen.