Android Coden
Android 9 min lesen

Serialization Basics: JSON, DTOs und sichere Datenmodelle

Du lernst, wie JSON, DTOs und kotlinx.serialization Daten sauber übersetzen, ohne Netzmodelle in die App-Logik zu ziehen.

Wenn deine Android-App Daten aus einer REST-API lädt, lokale Dateien schreibt oder Informationen in einer Datenbank speichert, müssen diese Daten übersetzt werden. JSON aus dem Netzwerk ist kein ViewModel-State, und eine Datenbankzeile ist kein UI-Modell. Serialization Basics helfen dir, diese Grenzen sauber zu behandeln: Du wandelst Daten kontrolliert zwischen Transportformaten, DTOs und Kotlin-Modellen um, ohne technische Details überall in deiner App zu verteilen.

Was ist das?

Serialisierung bedeutet, ein Objekt aus deinem Kotlin-Code in ein übertragbares oder speicherbares Format zu verwandeln. Deserialisierung ist der umgekehrte Weg: Aus einem Format wie JSON entsteht wieder ein Kotlin-Objekt. In Android-Projekten begegnet dir das besonders häufig bei Netzwerkantworten, Request-Bodys, lokalen Cache-Dateien, DataStore-Werten oder beim Austausch strukturierter Daten zwischen Schichten.

Ein typisches Beispiel ist eine API-Antwort wie {"id":42,"display_name":"Mira"}. Deine App kann mit diesem Text nicht direkt arbeiten. Sie braucht eine Kotlin-Struktur, die Felder, Typen und optionale Werte beschreibt. Genau hier kommen DTOs ins Spiel. DTO steht für Data Transfer Object. Ein DTO beschreibt die Form der Daten an einer technischen Grenze, etwa zur API oder zu einem Persistenzformat. Es ist nicht automatisch dein Domain-Modell und auch nicht automatisch dein Compose-UI-State.

Diese Trennung ist wichtig, weil externe Datenformen selten ideal zu deiner App passen. Eine API nutzt vielleicht snake_case, liefert null, ändert Feldnamen oder enthält Daten, die deine Oberfläche nicht anzeigen soll. Wenn du diese Form ungeprüft in deine ViewModels, Composables oder Use Cases übernimmst, wird deine App schwerer zu ändern. Kleine API-Details wandern dann in viele Dateien. Später musst du nicht nur den Data Layer anpassen, sondern auch UI, Tests und Geschäftslogik.

Das mentale Modell für den Einstieg lautet: Datenmodelle haben einen Ort und einen Zweck. Ein Netzwerk-DTO beschreibt, was über HTTP kommt. Ein Datenbankmodell beschreibt, was lokal gespeichert wird. Ein Domain-Modell beschreibt, womit deine App fachlich arbeitet. Ein UI-State beschreibt, was der Bildschirm gerade anzeigen soll. Serialisierung ist die Technik an den Grenzen. Mapping ist die bewusste Übersetzung zwischen diesen Modellen.

In modernem Android passt dieses Thema direkt in die Architektur rund um den Data Layer. Repositories holen Daten aus Remote- und Local-Sources, entscheiden über Quellen und geben stabile Modelle an die restliche App weiter. Jetpack Compose muss dabei nicht wissen, wie ein JSON-Feld in der API heißt. Compose sollte nur einen passenden State bekommen. So bleibt die Oberfläche unabhängig von Transportdetails, und dein Projekt bleibt besser testbar.

Wie funktioniert es?

Mit kotlinx.serialization beschreibst du serialisierbare Kotlin-Typen über Annotationen und lässt den Compiler passenden Code erzeugen. Die zentrale Annotation ist @Serializable. Sie sagt: Für diese Klasse darf Kotlin Serialisierungslogik erzeugen. Die Json-API übernimmt dann das Dekodieren aus einem String und das Kodieren zurück in einen String.

Ein DTO könnte so aussehen: Eine Datenklasse enthält nur die Felder, die aus der API kommen oder an die API gesendet werden. Wenn JSON-Feldnamen nicht zu Kotlin-Namen passen, nutzt du @SerialName. Damit kannst du im Kotlin-Code lesbare Namen verwenden und trotzdem korrekt mit der externen Datenform sprechen. Optionale Werte modellierst du mit Nullable-Typen oder Default-Werten. Dabei solltest du bewusst entscheiden, ob ein fehlender Wert wirklich erlaubt ist oder ob er einen Datenfehler darstellt.

Wichtig ist auch die Konfiguration des JSON-Parsers. In echten Apps liefern APIs manchmal zusätzliche Felder, die deine App nicht braucht. Mit ignoreUnknownKeys = true kann dein Parser solche Felder tolerieren. Das ist oft sinnvoll, weil Backend-Teams Felder ergänzen können, ohne deine App zu brechen. Trotzdem ist das kein Ersatz für saubere Verträge. Wenn ein Feld fachlich wichtig ist, sollte dein DTO es klar modellieren und deine Tests sollten prüfen, wie deine App mit fehlenden oder ungültigen Daten umgeht.

Der Lebenszyklus der Daten sieht in vielen Android-Apps so aus: Eine Remote Data Source lädt JSON. kotlinx.serialization erzeugt daraus ein DTO. Ein Mapper übersetzt das DTO in ein Domain-Modell. Das Repository gibt dieses Domain-Modell weiter. Ein ViewModel formt daraus UI-State. Compose rendert den State. In die Gegenrichtung funktioniert es ähnlich, etwa wenn du ein Formular abschickst: UI-Eingaben werden validiert, in ein Request-DTO übersetzt und dann als JSON gesendet.

Diese Schichten sind kein Selbstzweck. Sie verhindern konkrete Probleme. Wenn du API-DTOs direkt in Composables verwendest, koppelt sich deine Oberfläche an Netzwerkdetails. Wenn du dasselbe DTO für API, Datenbank und UI nutzt, wird jede Änderung riskanter. Wenn du jedes Feld als String modellierst, vermeidest du kurzfristig Typfehler, verlierst aber fachliche Klarheit. Ein Datum, ein Preis, ein Status oder eine ID haben meist klarere Typen als nur Text.

kotlinx.serialization passt gut zu Kotlin, weil es mit Datenklassen, Nullability, Default-Werten, Sealed Classes und dem Typsystem arbeitet. Du kannst dadurch viele Fehler früher erkennen. Eine Eigenschaft vom Typ Int ist nicht dasselbe wie ein optionaler Text. Eine Liste ist nicht dasselbe wie ein einzelnes Objekt. Das klingt grundlegend, ist aber im Alltag wertvoll: Je klarer deine DTOs sind, desto weniger defensive Sonderfälle landen später in der UI.

Eine typische Stolperfalle liegt bei Default-Werten. Wenn du jedem DTO-Feld einen scheinbar harmlosen Default gibst, kann deine App kaputte Daten still akzeptieren. Ein fehlender name wird dann vielleicht zu "", eine fehlende id zu 0. Das kann Bugs verdecken. Nutze Default-Werte dort, wo sie fachlich sinnvoll sind, nicht als pauschalen Schutz gegen jede fehlerhafte Antwort. Für kritische Daten ist ein sichtbarer Fehler oft besser als ein unauffälliger falscher Zustand.

In der Praxis

Stell dir eine App vor, die Profile aus einer API lädt. Die API liefert ein JSON-Objekt mit einer technischen ID, einem Anzeigenamen und einem optionalen Avatar. Im Data Layer definierst du dafür ein DTO. Danach mapst du es in ein Domain-Modell, das deine App unabhängig von der API verwenden kann.

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class UserDto(
    val id: Long,
    @SerialName("display_name")
    val displayName: String,
    @SerialName("avatar_url")
    val avatarUrl: String? = null
)

data class User(
    val id: Long,
    val name: String,
    val avatarUrl: String?
)

fun UserDto.toDomain(): User {
    return User(
        id = id,
        name = displayName.trim(),
        avatarUrl = avatarUrl
    )
}

val json = Json {
    ignoreUnknownKeys = true
    explicitNulls = false
}

fun parseUser(body: String): User {
    val dto = json.decodeFromString<UserDto>(body)
    return dto.toDomain()
}

Dieses Beispiel zeigt drei wichtige Entscheidungen. Erstens: Das DTO kennt die API-Namen über @SerialName. Dein Domain-Modell muss nicht wissen, dass das Backend display_name nennt. Zweitens: Das Mapping ist ein eigener Schritt. Dort kannst du leichte Normalisierung ausführen, etwa Leerzeichen entfernen. Drittens: Die JSON-Konfiguration ist explizit. Du entscheidest also sichtbar, ob unbekannte Felder erlaubt sind.

Im Alltag würdest du parseUser nicht direkt in einem Composable aufrufen. Der Code gehört in eine Remote Data Source oder in eine API-Schicht. Das Repository würde dann User statt UserDto zurückgeben. Dein ViewModel arbeitet mit User oder mit einem daraus gebauten UI-State. So bleibt die Richtung sauber: Die technischen Modelle bleiben außen, die fachlichen Modelle bewegen sich nach innen.

Eine hilfreiche Entscheidungsregel lautet: Wenn ein Modell ein Feld nur deshalb hat, weil die API es so liefert, ist es wahrscheinlich ein DTO. Wenn ein Modell eine Bedeutung in deiner App hat, unabhängig von der aktuellen API-Form, ist es eher ein Domain-Modell. Wenn ein Modell direkt beschreibt, was ein Screen anzeigen soll, gehört es in Richtung UI-State. Diese Regel ist nicht perfekt, aber sie schützt dich vor dem häufigsten Fehler: ein einziges Modell für alles zu verwenden.

Bei Listen, verschachtelten Objekten und Fehlerantworten gilt dieselbe Logik. Du kannst ein UserListResponseDto für die API-Antwort haben, daraus eine Liste von User bauen und Fehler separat modellieren. Besonders bei Pagination solltest du vorsichtig sein: Felder wie next_page, cursor oder total_count sind technische Steuerdaten. Sie müssen nicht automatisch in jedem Domain-Objekt auftauchen. Oft gehören sie in ein eigenes Ergebnisobjekt im Data Layer.

Tests sind hier sehr wertvoll. Du brauchst keinen großen Android-Instrumentation-Test, um Serialisierung zu prüfen. Ein normaler Unit-Test reicht oft aus. Du gibst einen JSON-String vor, dekodierst ihn und prüfst das Ergebnis. Zusätzlich testest du dein Mapping getrennt. So erkennst du schnell, ob ein Feldname falsch geschrieben ist, ob ein optionaler Wert korrekt behandelt wird oder ob eine Backend-Änderung deine Annahmen bricht.

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class UserSerializationTest {

    @Test
    fun `maps user dto from json to domain model`() {
        val body = """
            {
                "id": 42,
                "display_name": " Mira ",
                "avatar_url": null,
                "ignored_field": "not needed"
            }
        """.trimIndent()

        val user = parseUser(body)

        assertEquals(42, user.id)
        assertEquals("Mira", user.name)
        assertNull(user.avatarUrl)
    }
}

Wenn dieser Test fehlschlägt, hast du einen klaren Startpunkt für die Fehlersuche. Du kannst zuerst prüfen, ob der JSON-String zur DTO-Struktur passt. Danach prüfst du den Mapper. Erst dann schaust du in Repository, ViewModel oder UI. Diese Reihenfolge spart Zeit, weil du die Grenze untersuchst, an der Daten ihre Form wechseln.

In Code-Reviews solltest du gezielt nach Modell-Leaks suchen. Tauchen Klassen mit Namen wie UserDto, ResponseDto oder ApiModel in Composables auf, ist das ein Warnsignal. Dasselbe gilt, wenn ein DTO direkt in mehreren Schichten genutzt wird. Frage dann: Welche Schicht besitzt dieses Modell? Welche andere Schicht sollte stattdessen ein eigenes Modell bekommen? Du musst nicht bei jedem kleinen Projekt eine große Architektur bauen, aber du solltest die Grenze bewusst ziehen.

Auch beim Speichern lokaler Daten ist Vorsicht nötig. Ein DTO für eine API ist nicht automatisch passend für Room, Proto DataStore oder eine Cache-Datei. Netzwerkdaten und lokale Daten haben oft unterschiedliche Stabilitätsanforderungen. Eine API kann sich ändern, ein lokales Format muss vielleicht über App-Versionen hinweg migriert werden. Wenn du dasselbe Modell für beides nutzt, vermischst du zwei Arten von Verträgen. Besser ist oft: ein DTO für das Netzwerk, ein Entity- oder Storage-Modell für lokale Daten und ein Mapper dazwischen.

Für Anfänger wirkt das zuerst nach zusätzlicher Arbeit. In kleinen Beispielen stimmt das sogar. Der Vorteil zeigt sich, sobald sich etwas ändert. Ein Backend-Feld wird umbenannt. Ein Screen braucht einen formatierten Namen. Ein Cache speichert nur einen Teil der Daten. Mit klaren DTOs und Mappers passt du die betroffene Grenze an, statt deine gesamte App nach API-Details zu durchsuchen.

Fazit

Serialization Basics geben dir eine stabile Grundlage für echte Android-Apps: Du übersetzt JSON mit kotlinx.serialization in klare DTOs, mapst diese DTOs in passende App-Modelle und hältst technische Datenformen aus UI und Fachlogik heraus. Prüfe dein Verständnis praktisch, indem du eine kleine API-Antwort als Test-JSON modellierst, sie dekodierst, bewusst ein Feld umbenennst und beobachtest, welcher Test bricht. Danach schau in einem eigenen Projekt nach, ob DTOs bis in Composables oder ViewModels wandern. Wenn ja, übe das saubere Mapping im Data Layer und lasse den Debugger an genau dieser Grenze anhalten.

Quellen (3)
Redaktion

Geschrieben von

Redaktion

Das Redaktionsteam recherchiert und schreibt Artikel zu aktuellen Themen rund um Tech, Lifestyle und Ratgeber.