Android Coden
Android 8 min lesen

DTO Design in Android

DTOs trennen API-Verträge von App-Logik. Du lernst, warum Mapping stabile Android-Architektur unterstützt.

DTO Design klingt zuerst nach einem kleinen Detail in der Netzwerkschicht. In echten Android-Projekten entscheidet es aber oft darüber, ob deine App stabil bleibt, wenn sich ein Backend ändert, ob dein UI-Code verständlich bleibt und ob du Daten später sauber offline speichern kannst. Ein DTO ist kein Modell für deine gesamte App. Es ist ein bewusst begrenztes Transportmodell für einen API-Vertrag.

Was ist das?

DTO steht für Data Transfer Object. In Android meinst du damit meistens eine Kotlin-Klasse, die eine Antwort oder Anfrage einer Schnittstelle abbildet. Sie beschreibt also, wie Daten über eine Grenze transportiert werden: zum Beispiel von einer REST-API in deine App oder von deiner App zurück an den Server. Diese Grenze ist wichtig. Ein DTO gehört nicht automatisch in deine Domain-Logik und auch nicht direkt in deine Compose-UI.

Das mentale Modell ist: Ein DTO spricht die Sprache des Backends. Dein Domain-Modell spricht die Sprache deiner App. Dein UI-Modell spricht die Sprache des Bildschirms. Diese drei Sprachen können ähnlich aussehen, sind aber nicht identisch. Ein Backend liefert vielleicht first_name, premium_until, avatar_url und mehrere nullable Felder, weil ältere Clients unterstützt werden müssen. Deine App braucht dagegen vielleicht ein UserProfile mit displayName, einem klaren isPremium und einem optionalen ImageUrl-Wert. Das sind andere Ziele.

DTO Design bedeutet deshalb: Du entwirfst Transportmodelle so, dass sie den API-Vertrag korrekt und robust abbilden, aber du verhinderst, dass dieser Vertrag unkontrolliert in andere Schichten wandert. Die offiziellen Android-Architektur-Empfehlungen legen genau diese Richtung nahe: Datenquellen, Repositories, Domain-Logik und UI haben unterschiedliche Verantwortlichkeiten. DTOs passen in die Data Layer, genauer in die Nähe deiner Netzwerkdatenquelle.

Das löst ein sehr praktisches Problem. APIs ändern sich. Felder werden ergänzt, umbenannt, anders formatiert oder bleiben aus Kompatibilitätsgründen lange nullable. Wenn du solche Netzwerkmodelle direkt in ViewModels, Composables oder Business-Regeln nutzt, verknüpfst du deine ganze App mit Details, die sie nicht kontrolliert. Eine kleine Änderung am API-Vertrag kann dann an vielen Stellen Fehler auslösen. Mit separaten DTOs und Mapping begrenzt du diesen Schaden.

Wie funktioniert es?

Ein DTO ist meistens eine einfache data class. Bei Retrofit mit Kotlin Serialization oder Moshi entspricht sie häufig direkt der JSON-Struktur. Ihre Eigenschaften dürfen technische Namen tragen, wenn sie zum Vertrag passen. Sie dürfen nullable sein, wenn das Backend das Feld weglassen kann. Sie dürfen Rohwerte enthalten, etwa Strings für Statuswerte oder Datumsangaben. Das ist kein Designfehler, solange diese Rohwerte nicht ungeprüft in deine Fachlogik rutschen.

Der zentrale Schritt ist Mapping. Mapping übersetzt ein Transportmodell in ein internes Modell. Dabei triffst du bewusste Entscheidungen: Welche Felder sind Pflicht? Welche Default-Werte sind sinnvoll? Welche Rohwerte werden in Enums, Value Objects oder klarere Typen übersetzt? Was passiert, wenn ein unbekannter Status kommt? Genau dort entsteht Qualität.

Typisch ist diese Richtung:

DTO aus Netzwerkdatenquelle -> Mapper -> Domain-Modell oder Entity -> Repository -> ViewModel -> UI-State

Nicht jede App braucht jede Zwischenstufe. Eine kleine Lern-App kann weniger Klassen haben als eine große Produktions-App. Die Trennung bleibt trotzdem sinnvoll: Das DTO ist nicht die Wahrheit deiner App. Es ist ein externer Vertrag, den du annimmst und übersetzt.

In einer offline-fähigen App kommt noch ein weiterer Punkt dazu. Du solltest auch Datenbank-Entities nicht mit DTOs verwechseln. Ein DTO beschreibt den Transport über das Netzwerk. Eine Entity beschreibt, wie Daten lokal gespeichert werden. Beide können ähnliche Felder haben, aber sie dienen unterschiedlichen Zwecken. Für Offline-First-Apps ist diese Unterscheidung besonders wertvoll, weil lokale Daten oft länger leben als eine einzelne API-Antwort. Dein lokaler Speicher braucht stabile, migrationsfähige Strukturen. Dein DTO darf näher am Server bleiben.

Im Alltag taucht DTO Design bei fast jedem Feature auf, das Daten lädt: Login, Profil, Produktliste, Nachrichtenfeed, Einstellungen oder Sync. Du bekommst ein JSON-Beispiel oder eine OpenAPI-Spezifikation, schreibst DTO-Klassen, baust einen Service, definierst Mapper und gibst nach außen nur Modelle weiter, die zur App passen. In Code-Reviews ist eine häufige Frage: Verlässt dieses DTO gerade seine Schicht? Wenn ja, ist das oft ein Warnsignal.

Ein guter Mapper ist nicht nur Kopierarbeit. Er ist die Stelle, an der du externe Unsicherheit in interne Klarheit verwandelst. Wenn der Server null für einen Namen schickt, entscheidest du dort, ob die App einen Fallback zeigt, den Datensatz verwirft oder einen Fehlerzustand erzeugt. Wenn der Server einen unbekannten Typ liefert, entscheidest du dort, ob du ihn als Unknown modellierst oder als Fehler behandelst. Diese Entscheidungen sollten nicht verstreut in Composables liegen.

In der Praxis

Angenommen, deine App lädt ein Benutzerprofil. Das Backend liefert JSON mit technischen Feldnamen und nullable Werten. Dein Compose-Screen braucht aber einen stabilen UI-State, der direkt angezeigt werden kann.

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class UserProfileDto(
    @SerialName("id")
    val id: String?,

    @SerialName("first_name")
    val firstName: String?,

    @SerialName("last_name")
    val lastName: String?,

    @SerialName("avatar_url")
    val avatarUrl: String?,

    @SerialName("premium_until")
    val premiumUntil: String?
)

data class UserProfile(
    val id: UserId,
    val displayName: String,
    val avatarUrl: String?,
    val isPremium: Boolean
)

@JvmInline
value class UserId(val value: String)

fun UserProfileDto.toDomain(clockDate: String): UserProfile {
    val safeId = requireNotNull(id) { "User id is required" }

    val nameParts = listOfNotNull(
        firstName?.trim()?.takeIf { it.isNotEmpty() },
        lastName?.trim()?.takeIf { it.isNotEmpty() }
    )

    return UserProfile(
        id = UserId(safeId),
        displayName = nameParts.joinToString(" ").ifBlank { "Unbekannter Nutzer" },
        avatarUrl = avatarUrl?.takeIf { it.startsWith("https://") },
        isPremium = premiumUntil != null && premiumUntil >= clockDate
    )
}

Dieses Beispiel ist absichtlich klein, zeigt aber mehrere wichtige Punkte. Das DTO erlaubt nullable Felder, weil die API so sein kann. Das Domain-Modell ist strenger: Eine Profil-ID ist Pflicht. Der Anzeigename wird aus mehreren Feldern gebaut. Die Avatar-URL wird grob geprüft. Der Premium-Status wird nicht als roher String durch die App getragen, sondern in ein boolesches Konzept übersetzt.

In einem echten Projekt würdest du Datumswerte nicht als Strings vergleichen, sondern mit einem passenden Typ wie Instant oder LocalDate, abhängig von deiner Plattform- und Bibliothekswahl. Für das DTO kann der String trotzdem korrekt sein, wenn die API ihn als String liefert. Der Mapper ist dann die Stelle, an der du sauber parst und Fehler behandelst.

Eine einfache Entscheidungsregel hilft dir: Ein DTO darf nur in der Netzwerkdatenquelle, im Repository oder im Mapper sichtbar sein. Sobald du ein DTO als Parameter in einem ViewModel, als Zustand in Compose oder als Rückgabetyp einer Domain-Funktion siehst, solltest du stoppen und die Abhängigkeit prüfen. Meist fehlt dann ein internes Modell.

Eine typische Stolperfalle ist das direkte Wiederverwenden von DTOs, weil es am Anfang schneller wirkt. Du schreibst UserProfileDto und gibst es direkt an den Screen weiter. Kurz darauf braucht der Screen einen formatierten Namen, dann einen Fallback für fehlende Bilder, dann eine Premium-Anzeige. Diese Logik landet Stück für Stück im UI. Später ändert das Backend premium_until oder liefert einen neuen Status. Jetzt musst du UI-Code, Tests und eventuell mehrere Screens anfassen. Das Problem war nicht Retrofit oder Compose. Das Problem war die fehlende Grenze.

Eine zweite Stolperfalle ist übertriebenes Mapping ohne Zweck. Wenn du für jedes Feld blind drei identische Klassen erzeugst, aber keine Schicht eine eigene Verantwortung hat, entsteht nur Ballast. Trenne dort, wo sich Gründe für Änderungen unterscheiden. Netzwerkvertrag, lokale Speicherung, Fachlogik und UI-Anzeige haben oft unterschiedliche Gründe. Bei sehr kleinen Features kann ein internes Modell reichen, solange das DTO nicht nach außen leckt.

Für Tests eignet sich DTO Mapping sehr gut. Du kannst Mapper mit festen Beispieldaten prüfen, ohne Emulator und ohne Netzwerk. Teste dabei nicht nur den perfekten Fall, sondern auch fehlende optionale Felder, unbekannte Statuswerte und ungültige Rohdaten. So lernst du schnell, ob dein internes Modell wirklich stabil ist.

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

class UserProfileMapperTest {

    @Test
    fun mapsMissingNameToFallback() {
        val dto = UserProfileDto(
            id = "42",
            firstName = null,
            lastName = null,
            avatarUrl = "https://example.com/avatar.png",
            premiumUntil = null
        )

        val profile = dto.toDomain(clockDate = "2026-04-25")

        assertEquals("Unbekannter Nutzer", profile.displayName)
        assertEquals(false, profile.isPremium)
    }

    @Test
    fun rejectsMissingRequiredId() {
        val dto = UserProfileDto(
            id = null,
            firstName = "Ada",
            lastName = "Lovelace",
            avatarUrl = null,
            premiumUntil = null
        )

        assertFailsWith<IllegalArgumentException> {
            dto.toDomain(clockDate = "2026-04-25")
        }
    }
}

Beim Debuggen kannst du ebenfalls gezielt an der Mapper-Grenze ansetzen. Prüfe zuerst, ob das DTO korrekt aus der Netzwerkantwort gefüllt wird. Prüfe danach, ob der Mapper die richtigen Entscheidungen trifft. Erst dann lohnt sich der Blick ins ViewModel oder in Compose. Diese Reihenfolge spart Zeit, weil du Datenprobleme nicht mit UI-Problemen vermischst.

In Code-Reviews kannst du nach drei Dingen suchen. Erstens: Stimmen die DTO-Felder mit dem API-Vertrag überein? Zweitens: Gibt es eine klare Übersetzung in interne Modelle? Drittens: Werden technische Details wie JSON-Namen, nullable Rohdaten oder Backend-Statuswerte außerhalb der Data Layer sichtbar? Wenn du diese Fragen sauber beantworten kannst, ist das DTO Design meistens auf einem guten Weg.

Fazit

DTO Design ist eine kleine, aber wichtige Architekturdisziplin: Du hältst Transportmodell, API-Vertrag und App-Modell bewusst getrennt und nutzt Mapping als klare Grenze. Übe das an einem echten API-Beispiel, schreibe zwei Mapper-Tests für Randfälle und prüfe im Code-Review, ob ein DTO bis in ViewModel oder Compose wandert. Wenn du diese Grenze erkennst und verteidigst, werden deine Android-Features leichter wartbar, besser testbar und robuster gegenüber Änderungen am Backend.

Quellen (4)
Redaktion

Geschrieben von

Redaktion

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