Data Classes in Kotlin
Data Classes bündeln Werte klar und testbar. Du lernst, wie sie State, DTOs und einfache Modelle in Android strukturieren.
Data Classes sind eines der Kotlin-Werkzeuge, die du in Android-Projekten fast täglich siehst: bei API-Antworten, UI-State, Formularen, Listen, Navigation-Argumenten und einfachen Domain-Modellen. Ihr Nutzen liegt nicht darin, dass sie besonders viel Magie enthalten, sondern dass sie ein wiederkehrendes Problem sauber lösen: Du willst Daten beschreiben, vergleichen, kopieren und weiterreichen, ohne für jede kleine Modellklasse viel Standardcode zu schreiben.
Was ist das?
Eine Data Class ist eine Kotlin-Klasse, deren Hauptaufgabe das Speichern von Werten ist. Du erkennst sie am Schlüsselwort data vor class. Statt ein Objekt über seine Identität zu verstehen, denkst du bei einer Data Class vor allem über ihren Inhalt nach. Zwei Instanzen mit denselben Werten gelten als gleich, auch wenn sie an verschiedenen Stellen im Speicher liegen.
Das mentale Modell ist: Eine Data Class ist ein benanntes Paket aus zusammengehörigen Werten. Sie eignet sich sehr gut für Value Objects, DTOs und einfache Modelle, die keine komplexe eigene Lebenslogik tragen. Ein UserProfile, ein ArticlePreview, ein LoginUiState oder ein Price kann als Data Class verständlich sein, wenn die Werte im Vordergrund stehen.
Kotlin erzeugt für Data Classes automatisch mehrere Funktionen, die du sonst selbst schreiben müsstest. Dazu gehören equals(), hashCode(), toString(), copy() und sogenannte Component-Funktionen für Destructuring. Besonders wichtig sind dabei equality und copy(). Equality entscheidet, ob zwei Instanzen inhaltlich gleich sind. copy() erstellt eine neue Instanz auf Basis einer bestehenden Instanz und ersetzt nur die Werte, die du angibst.
Im Android-Kontext ist das relevant, weil moderne Apps stark mit klaren Datenflüssen arbeiten. Ein ViewModel hält beispielsweise einen UI-State. Eine Repository-Schicht liefert DTOs aus einer API oder Datenbank. Eine Composable-Funktion rendert Werte und reagiert auf Änderungen. Data Classes machen diese Werte explizit, lesbar und gut testbar.
Wichtig ist aber auch die Grenze: Eine Data Class ist keine allgemeine Lösung für jedes Objekt. Wenn ein Objekt eine eigene Identität hat, dauerhaft veränderlichen internen Zustand schützt oder komplexe Verhaltensregeln kapselt, kann eine normale Klasse passender sein. Eine DatabaseConnection, ein MediaPlayerController oder ein Objekt mit umfangreicher Lebenszykluslogik sollte nicht automatisch zur Data Class werden, nur weil Kotlin es erlaubt.
Wie funktioniert es?
Eine Data Class braucht mindestens einen Parameter im primären Konstruktor, der mit val oder var deklariert ist. Genau diese Konstruktor-Properties verwendet Kotlin für die automatisch erzeugten Funktionen. Felder, die du im Klassenrumpf definierst, zählen nicht zur automatisch erzeugten equality und nicht zu copy().
Das ist ein wichtiger Punkt für den Alltag. Wenn du erwartest, dass zwei Objekte anhand eines Feldes verglichen werden, muss dieses Feld im primären Konstruktor stehen. Sonst kann dein Code optisch korrekt wirken, aber Tests oder Listenvergleiche verhalten sich anders als erwartet.
Eine typische Data Class sieht so aus:
data class UserProfile(
val id: String,
val displayName: String,
val avatarUrl: String?
)
Bei dieser Klasse erzeugt Kotlin unter anderem eine sinnvolle Textausgabe. Wenn du println(profile) nutzt oder im Debugger auf das Objekt schaust, siehst du nicht nur einen kryptischen Klassennamen mit Speicheradresse, sondern die Werte. Das hilft beim Debuggen, besonders bei State-Objekten in ViewModels.
Noch wichtiger ist equality:
val first = UserProfile("42", "Mira", null)
val second = UserProfile("42", "Mira", null)
println(first == second) // true
Das == prüft in Kotlin strukturelle Gleichheit. Bei einer Data Class heißt das: Die Properties aus dem primären Konstruktor werden verglichen. Für Android ist das praktisch, weil viele Abläufe davon abhängen, ob sich Daten wirklich geändert haben. In Tests kannst du erwartete und tatsächliche Werte direkt vergleichen. In Listen kannst du einfacher prüfen, ob zwei Einträge denselben Inhalt haben. In Compose ist klarer, welcher State gerade an die UI übergeben wird.
Die Funktion copy() unterstützt unveränderliche Datenstrukturen. Wenn du Properties als val definierst, änderst du eine Instanz nicht nachträglich. Stattdessen erzeugst du eine neue Instanz mit geändertem Wert:
val updated = first.copy(displayName = "Mira Schulz")
Das wirkt anfangs umständlicher als ein direktes Setzen per var. In größeren Android-Apps ist es aber oft leichter zu verstehen. Ein alter Zustand bleibt erhalten, ein neuer Zustand entsteht explizit. Genau dieses Muster passt gut zu ViewModels, StateFlow, Compose-State und unidirektionalen Datenflüssen.
Du solltest Data Classes deshalb bevorzugt mit val verwenden. var ist erlaubt, macht das Modell aber leichter fehleranfällig. Wenn ein Objekt nachträglich an mehreren Stellen geändert werden kann, wird Debugging schwieriger. Du siehst dann nicht mehr so klar, wann ein Zustand entstanden ist und welche Aktion ihn verändert hat.
Ein weiterer Mechanismus ist Destructuring:
val (id, name, avatar) = first
Das kann in kleinen, klaren Fällen lesbar sein. In Android-Code solltest du es aber sparsam nutzen. Bei Modellen mit vielen Properties wird Destructuring schnell unklar, weil die Bedeutung nur noch über die Reihenfolge entsteht. Benannte Properties sind im App-Code meist besser wartbar.
In der Praxis
Ein sehr typischer Einsatz ist UI-State in einem ViewModel. Die UI soll nicht an vielen einzelnen Variablen hängen, sondern einen zusammenhängenden Zustand bekommen. So kannst du Ladezustand, Daten und Fehlermeldung gemeinsam beschreiben.
data class ProfileUiState(
val isLoading: Boolean = false,
val displayName: String = "",
val email: String = "",
val errorMessage: String? = null
)
Im ViewModel kann dieser State über StateFlow bereitgestellt werden:
class ProfileViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun onNameChanged(newName: String) {
_uiState.update { current ->
current.copy(displayName = newName)
}
}
fun showError(message: String) {
_uiState.update { current ->
current.copy(
isLoading = false,
errorMessage = message
)
}
}
}
Hier siehst du den praktischen Kern: Der alte State wird nicht direkt verändert. Stattdessen erzeugt copy() einen neuen State, der fast gleich ist, aber einzelne Werte ersetzt. Für Compose ist das angenehm, weil eine Composable den aktuellen State lesen und daraus UI ableiten kann. Du musst nicht erraten, welche einzelne Variable gerade relevant ist. Der Zustand ist als Objekt sichtbar.
In einer Composable könnte das so aussehen:
@Composable
fun ProfileScreen(
uiState: ProfileUiState,
onNameChanged: (String) -> Unit
) {
Column {
if (uiState.isLoading) {
CircularProgressIndicator()
}
TextField(
value = uiState.displayName,
onValueChange = onNameChanged,
label = { Text("Name") }
)
uiState.errorMessage?.let { message ->
Text(text = message)
}
}
}
Die Data Class ist hier kein UI-Framework. Sie ist die stabile Form, in der deine UI Daten bekommt. Das macht Code-Reviews einfacher: Du kannst prüfen, ob der State vollständig ist, ob Namen klar sind und ob eine Aktion wirklich nur den passenden Teil verändert.
Ein zweiter häufiger Einsatz sind DTOs, also Data Transfer Objects. Wenn deine App JSON von einem Backend bekommt, kannst du die Antwort in eine Data Class abbilden:
data class UserResponseDto(
val id: String,
val name: String,
val avatarUrl: String?
)
In vielen Projekten wird ein DTO danach in ein Domain-Modell übersetzt:
data class User(
val id: String,
val displayName: String,
val avatarUrl: String?
)
fun UserResponseDto.toDomain(): User {
return User(
id = id,
displayName = name,
avatarUrl = avatarUrl
)
}
Diese Trennung ist nützlich, weil API-Felder nicht automatisch deine App-Sprache bestimmen sollten. Ein Backend kann name liefern, während deine App fachlich displayName meint. Data Classes helfen dir, diese Schichten klar zu halten, ohne viel Zusatzcode zu schreiben.
Eine konkrete Entscheidungsregel: Nutze eine Data Class, wenn du sagen kannst: “Dieses Objekt beschreibt einen Wertzustand, und zwei Objekte mit denselben Werten sollen als gleich gelten.” Nutze eher eine normale Klasse, wenn du sagen musst: “Dieses Objekt hat eine eigene Identität, verwaltet Ressourcen oder schützt veränderliche Abläufe.”
Eine typische Stolperfalle betrifft verschachtelte veränderliche Daten. Eine Data Class macht nicht automatisch alle enthaltenen Werte unveränderlich. Wenn du eine MutableList in einer Data Class speicherst, kann diese Liste weiterhin verändert werden:
data class CartState(
val items: MutableList<String>
)
Das sieht nach stabilem State aus, ist aber riskant. Eine andere Stelle kann items.add(...) aufrufen, ohne dass eine neue CartState-Instanz entsteht. Für UI-State ist das schlecht nachvollziehbar. Besser ist eine unveränderlich verwendete Liste:
data class CartState(
val items: List<String>
)
fun CartState.addItem(item: String): CartState {
return copy(items = items + item)
}
So entsteht bei einer Änderung wieder ein neuer State. Das passt besser zu Tests, Compose und Debugging.
Auch equality kann eine Stolperfalle sein. Wenn du Properties aus der Gleichheitsprüfung heraushältst, indem du sie im Klassenrumpf definierst, kann das Verhalten überraschen:
data class DownloadItem(
val id: String,
val title: String
) {
var progress: Int = 0
}
Zwei DownloadItem-Objekte mit gleicher id und gleichem title, aber unterschiedlichem progress, gelten trotzdem als gleich. Das ist korrekt nach Kotlin-Regel, aber oft nicht das, was du beabsichtigst. Wenn progress zum Zustand gehört, gehört es in den primären Konstruktor:
data class DownloadItem(
val id: String,
val title: String,
val progress: Int
)
Für Tests sind Data Classes besonders angenehm. Du kannst erwartete Zustände direkt formulieren:
@Test
fun nameChangeUpdatesUiState() {
val initial = ProfileUiState(displayName = "Mira")
val result = initial.copy(displayName = "Mira Schulz")
assertEquals(
ProfileUiState(displayName = "Mira Schulz"),
result
)
}
Der Test prüft nicht einzelne technische Details, sondern den sichtbaren Zustand. Das ist ein gutes Signal für Lernende: Wenn dein State als Data Class sauber modelliert ist, werden Tests oft kürzer und klarer.
Fazit
Data Classes geben dir in Kotlin eine präzise Form für Werte: Du beschreibst State, DTOs und einfache Domain-Modelle mit wenig Standardcode, bekommst sinnvolle equality und kannst mit copy() unveränderliche Änderungen ausdrücken. Prüfe beim nächsten Android-Code-Review gezielt, ob eine Klasse wirklich einen Wert beschreibt, ob alle relevanten Properties im primären Konstruktor stehen und ob veränderliche Collections vermieden werden. Eine gute Übung ist, einen kleinen UI-State mit val, copy() und einem Test zu modellieren und anschließend im Debugger zu beobachten, wann neue State-Instanzen entstehen.