Room Relationships in Android
Lerne, wie du verwandte Daten in Room sauber modellierst. Der Artikel zeigt one-to-many, joins und embedded mit Praxisbezug.
Room Relationships beschreiben, wie du in Room zusammengehörige Daten aus mehreren Tabellen als sinnvolle Kotlin-Objekte liest. Das ist wichtig, sobald deine App nicht mehr nur einzelne Datensätze speichert, sondern echte Fachobjekte abbildet: Nutzer mit Beiträgen, Einkaufslisten mit Einträgen, Kurse mit Lektionen oder Chats mit Nachrichten. Wenn du diese Beziehungen klar modellierst, bekommt deine Data Layer stabile, nachvollziehbare Abfragen. Das passt direkt zu moderner Android-Architektur: Repositorys liefern saubere Datenmodelle an ViewModels, Compose beobachtet daraus abgeleitete UI-States, und Offline-first-Apps können lokale Daten konsistent anzeigen, auch wenn das Netzwerk gerade fehlt.
Was ist das?
Room Relationships sind kein eigenes Datenbank-Feature, das automatisch deine Tabellen verbindet. Sie sind eine Art, SQL-Beziehungen in Kotlin sichtbar zu machen. Room hilft dir dabei mit Annotationen wie @Embedded und @Relation, außerdem kannst du eigene JOIN-Abfragen mit @Query schreiben. Das Ziel ist immer gleich: Du willst verwandte Daten lesen, ohne langsame, doppelte oder widersprüchliche Abfragen in deiner App zu verteilen.
Das wichtigste mentale Modell ist: Tabellen bleiben getrennt, Ergebnisobjekte dürfen zusammengesetzt sein. Eine Author-Tabelle enthält Autoren, eine Book-Tabelle enthält Bücher. Ein Autor kann viele Bücher haben. In der Datenbank liegt das als one-to-many-Beziehung vor: Ein Datensatz auf der einen Seite, mehrere Datensätze auf der anderen Seite. In Kotlin willst du aber oft ein Objekt wie AuthorWithBooks, damit die UI oder die Domain-Schicht nicht selbst einzelne Listen zusammenbauen muss.
Das unterscheidet Room Relationships von einfachem Objektdenken. In Kotlin würdest du vielleicht direkt eine Liste in die Entity legen. In einer relationalen Datenbank ist das meistens falsch, weil eine Entity eine Tabelle beschreibt. Eine Liste von Kindobjekten gehört nicht als normales Feld in dieselbe Tabelle. Stattdessen speicherst du die Kindobjekte in einer eigenen Tabelle und verknüpfst sie über Schlüssel.
In Android-Projekten taucht dieses Thema in der Data Layer auf. Dort entscheidet sich, ob deine App Daten sauber laden kann oder ob jedes Feature eigene Spezialabfragen schreibt. Gerade bei Offline-first-Architektur ist das entscheidend: Die lokale Datenbank ist nicht nur Cache, sondern häufig die Quelle, aus der die UI liest. Wenn Beziehungen dort unsauber modelliert sind, siehst du später leere Listen, doppelte Einträge, langsame Screens oder Daten, die nach einer Synchronisierung nicht zusammenpassen.
Wie funktioniert es?
Room kennt mehrere Wege, verwandte Daten abzubilden. Die drei Begriffe, die du sicher einordnen solltest, sind embedded, one-to-many und joins.
@Embedded bedeutet: Ein Objekt wird in ein anderes Ergebnisobjekt eingebettet, seine Felder kommen aber aus derselben Ergebniszeile. Das ist praktisch für Wertobjekte oder für flache Projektionen. Beispiel: Eine UserEntity enthält eine Address, und die Adressfelder liegen als Spalten in der Nutzertabelle. @Embedded ist keine Beziehung zwischen Tabellen. Es ist ein Mapping-Hilfsmittel für zusammengesetzte Objekte.
@Relation beschreibt eine Beziehung zwischen einer Parent-Entity und einer Child-Entity. Room führt dafür intern zusätzliche Abfragen aus und ordnet die Ergebnisse anhand der Schlüssel zu. Für one-to-many gibst du an, welche Spalte im Parent zur Spalte im Child passt. Meist ist das die Primärschlüssel-Spalte der Parent-Tabelle und eine Fremdschlüssel-Spalte in der Child-Tabelle.
Ein JOIN ist dagegen SQL. Du schreibst eine Abfrage, die mehrere Tabellen in einem Ergebnis verbindet. Das ist nützlich, wenn du gezielt filtern, sortieren oder nur bestimmte Spalten laden willst. Ein Join liefert häufig flache Zeilen. Wenn ein Autor drei Bücher hat, kann der Autor in drei Ergebniszeilen auftauchen, jeweils mit einem Buch. Das ist korrektes SQL-Verhalten, aber du musst wissen, welches Ergebnisformat du erwartest.
Der Unterschied ist in der Praxis wichtig. @Relation passt gut, wenn du vollständige Parent-Objekte mit Listen von Child-Objekten brauchst. JOIN passt gut, wenn du eine konkrete Ansicht oder eine Suche baust, zum Beispiel eine Liste aus Buchtitel, Autorenname und letzter Aktualisierung. @Embedded passt gut, wenn ein Objekt in derselben Zeile steckt oder wenn du eine flache Query in ein schöneres Kotlin-Modell mappen willst.
Ein weiterer Punkt ist Transaktionalität. Wenn Room für eine Relation mehrere Abfragen ausführt, sollten diese Abfragen konsistent zusammengehören. Deshalb nutzt du bei relationalen Ergebnisobjekten häufig @Transaction. Das sorgt dafür, dass Parent- und Child-Daten innerhalb einer Datenbanktransaktion gelesen werden. Ohne diese Absicherung kann es bei parallelen Schreibvorgängen zu Ergebnissen kommen, die nicht denselben Datenstand widerspiegeln.
Auch Performance gehört zum Thema. Beziehungen wirken sauber, können aber teuer werden, wenn du große Datenmengen ohne Filter lädst. Lade nicht ungeprüft alle Nutzer mit allen Nachrichten, wenn ein Screen nur die letzten zehn Chats anzeigen soll. Setze sinnvolle WHERE-Bedingungen, nutze Indizes auf Fremdschlüsseln und prüfe, ob du wirklich vollständige Entities brauchst oder eine kleinere Projektion reicht.
In der Praxis
Angenommen, du baust eine Lern-App. Ein Kurs hat mehrere Lektionen. In der Datenbank speicherst du Kurse und Lektionen getrennt. Die Lektion enthält eine courseId, damit sie einem Kurs zugeordnet werden kann.
@Entity(tableName = "courses")
data class CourseEntity(
@PrimaryKey val id: String,
val title: String
)
@Entity(
tableName = "lessons",
foreignKeys = [
ForeignKey(
entity = CourseEntity::class,
parentColumns = ["id"],
childColumns = ["courseId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("courseId")]
)
data class LessonEntity(
@PrimaryKey val id: String,
val courseId: String,
val title: String,
val position: Int
)
data class CourseWithLessons(
@Embedded val course: CourseEntity,
@Relation(
parentColumn = "id",
entityColumn = "courseId"
)
val lessons: List<LessonEntity>
)
@Dao
interface CourseDao {
@Transaction
@Query("SELECT * FROM courses WHERE id = :courseId")
suspend fun getCourseWithLessons(courseId: String): CourseWithLessons?
}
Dieses Beispiel zeigt die typische one-to-many-Struktur. CourseEntity ist der Parent, LessonEntity ist das Child. Die Spalte lessons.courseId verweist auf courses.id. Der Index auf courseId ist kein Schmuck, sondern eine konkrete Performance-Maßnahme. Ohne ihn muss SQLite bei relationalen Abfragen mehr Arbeit leisten, besonders wenn die Tabelle wächst.
Für einen Compose-Screen würdest du diese DAO-Funktion nicht direkt in der UI aufrufen. Du würdest sie über ein Repository kapseln, im ViewModel in einen UI-State übersetzen und dann in Compose anzeigen. Der Punkt bleibt aber derselbe: Die Beziehung gehört in die Data Layer, nicht in eine Composable-Funktion. Eine Composable sollte nicht wissen müssen, welche Tabellen zusammengehören.
Wenn du eine Übersicht aller Kurse mit der Anzahl ihrer Lektionen anzeigen willst, ist @Relation nicht immer die beste Wahl. Dann brauchst du nicht jede Lektion vollständig, sondern nur eine gezählte Projektion. Ein Join oder eine gruppierte Query ist passender:
data class CourseSummary(
val id: String,
val title: String,
val lessonCount: Int
)
@Dao
interface CourseSummaryDao {
@Query(
"""
SELECT courses.id, courses.title, COUNT(lessons.id) AS lessonCount
FROM courses
LEFT JOIN lessons ON lessons.courseId = courses.id
GROUP BY courses.id, courses.title
ORDER BY courses.title
"""
)
suspend fun getCourseSummaries(): List<CourseSummary>
}
Hier wäre es unnötig, alle Lektionen zu laden, nur um anschließend in Kotlin lessons.size zu berechnen. Die Datenbank kann solche Aggregationen gut. Das ist eine gute Entscheidungsregel: Lade komplette Beziehungen, wenn du die verknüpften Objekte wirklich brauchst. Nutze Projektionen oder Joins, wenn der Screen nur ausgewählte Informationen benötigt.
Eine typische Stolperfalle ist die Verwechslung von @Embedded und @Relation. @Embedded verschachtelt keine Kindtabelle. Wenn du in CourseEntity ein Feld val lessons: List<LessonEntity> erwartest, wird Room das nicht als normale Tabellenspalte verstehen. Für Listen brauchst du eine Relation oder eine bewusst gewählte separate Query. Eine zweite Stolperfalle sind falsche Spaltennamen: parentColumn und entityColumn müssen exakt zu den Daten passen. Wenn du dort aus Versehen id zu id verknüpfst, obwohl die Child-Tabelle courseId nutzt, bekommst du leere oder falsche Ergebnisse.
Achte außerdem auf Sortierung. @Relation garantiert dir nicht automatisch die fachlich gewünschte Reihenfolge deiner Child-Liste. Wenn Lektionen nach position sortiert werden müssen, kann eine eigene Query oder ein gezieltes Mapping nötig sein. In vielen Apps fällt dieser Fehler erst in der UI auf, wenn Einträge scheinbar zufällig angeordnet sind. Für Lerninhalte, Chatverläufe oder Aufgabenlisten ist das ein echter Produktfehler, nicht nur ein kosmetisches Problem.
Beim Testen solltest du nicht nur den Erfolgsfall prüfen. Lege in einem Room-Instrumentation-Test oder in einem passenden lokalen Test mit In-Memory-Datenbank einen Kurs ohne Lektionen, einen Kurs mit mehreren Lektionen und eine verwaiste Lektion an, falls deine Constraints das zulassen. Prüfe dann, ob deine DAO-Funktion genau das liefert, was der Screen erwartet. Nutze auch Code-Reviews bewusst: Stimmen Fremdschlüssel, Indizes, @Transaction und Rückgabetyp zusammen? Wird zu viel geladen? Ist eine Relation wirklich nötig oder wäre eine Projektion klarer?
Für Offline-first-Apps kommt noch ein Synchronisationsaspekt dazu. Wenn Daten vom Server kommen, werden Parent- und Child-Datensätze oft getrennt aktualisiert. Deine lokale Struktur muss damit umgehen können. Fremdschlüssel und klare Löschregeln helfen, inkonsistente Zustände zu vermeiden. Trotzdem solltest du beim Sync darauf achten, in welcher Reihenfolge du Daten schreibst. Wenn Lektionen gespeichert werden, bevor der Kurs existiert, kann ein Fremdschlüssel fehlschlagen. Wenn du Fremdschlüssel nicht nutzt, kann die App später Daten anzeigen, die fachlich nicht mehr zusammenpassen.
Fazit
Room Relationships helfen dir, verwandte Daten so zu lesen, wie deine App sie wirklich braucht: mal als vollständige one-to-many-Struktur, mal als Join-Projektion, mal als eingebettetes Wertobjekt. Prüfe dieses Wissen aktiv an einer kleinen Beispiel-Datenbank: Schreibe eine Parent-Child-Beziehung, ergänze einen Index, teste eine @Relation mit @Transaction und vergleiche sie mit einer Join-Abfrage für eine Übersicht. Im Debugger und in Tests erkennst du schnell, ob du zu viel lädst, falsch verknüpfst oder eine Sortierung vergisst.