Result Modeling in Kotlin
Result Modeling macht Erfolgs- und Fehlerpfade sichtbar. Du lernst, wie du erwartete Ergebnisse in Kotlin sauber modellierst.
Result Modeling bedeutet, dass du erwartete Ergebnisse deiner App nicht nur als lose Werte, Exceptions oder Nulls behandelst, sondern als klare Typen ausdrückst. Für Android ist das wichtig, weil Daten aus vielen unsicheren Quellen kommen: Netzwerk, Datenbank, Cache, Sensoren, Benutzeraktionen oder Berechtigungen. Wenn du Erfolg und Fehler sichtbar modellierst, wird dein Code lesbarer, testbarer und stabiler.
Was ist das?
Result Modeling ist die Technik, mögliche Ausgänge einer Operation explizit im Code zu beschreiben. Eine Funktion liefert dann nicht nur User, List<Article> oder null, sondern zum Beispiel Result<User> oder einen eigenen Typ wie LoadUserResult. Damit sagst du: Diese Operation kann gelingen, aber sie kann auch kontrolliert scheitern. Beide Fälle gehören zum normalen Ablauf und müssen vom aufrufenden Code behandelt werden.
Das mentale Modell ist einfach: Eine App besteht aus vielen Entscheidungen. Manche Entscheidungen hängen von Benutzereingaben ab, andere von Datenquellen. Bei jedem Schritt kann etwas Erwartbares passieren. Ein Login kann erfolgreich sein. Er kann aber auch wegen falscher Zugangsdaten scheitern. Eine Netzwerkabfrage kann Daten liefern. Sie kann aber auch wegen fehlender Verbindung fehlschlagen. Eine lokale Datenbank kann einen Eintrag finden. Sie kann aber auch leer sein. Result Modeling zwingt dich, diese Fälle nicht nebenbei zu behandeln, sondern bewusst in deine Architektur einzubauen.
In Kotlin hast du dafür mehrere Werkzeuge. Es gibt den Standardtyp Result<T>, der Erfolg oder Fehler transportieren kann. Für viele Android-Projekte sind aber eigene sealed class- oder sealed interface-Modelle noch klarer, weil du damit fachliche Fehler ausdrücken kannst. Statt nur eine technische Throwable weiterzureichen, kannst du sagen: InvalidCredentials, Offline, NotFound oder RateLimited. Genau hier kommt der Begriff sealed outcomes ins Spiel: Du definierst eine geschlossene Menge möglicher Ergebnisse. Der Compiler weiß dann, welche Fälle existieren, und kann dich bei when-Ausdrücken unterstützen.
Im Android-Kontext gehört Result Modeling besonders in die Data- und Domain-Layer. Die Data Layer spricht mit APIs, Datenbanken und anderen Quellen. Die Domain Layer enthält fachliche Operationen wie “Benutzer laden”, “Artikel speichern” oder “Bestellung prüfen”. In diesen Schichten entscheidest du, welche Fehler für den Rest der App relevant sind. Die UI, zum Beispiel eine Compose-Oberfläche, sollte nicht erraten müssen, ob eine Exception eine fehlende Internetverbindung, ein Serverproblem oder einen fachlichen Fehler bedeutet. Sie sollte einen klaren Zustand bekommen und daraus passende UI ableiten.
Wichtig ist die Abgrenzung: Result Modeling ersetzt nicht jede Exception. Programmierfehler wie ein ungültiger Index, ein falsch initialisierter Zustand oder ein verletzter interner Vertrag dürfen weiterhin auffallen. Result Modeling ist vor allem für erwartete Ergebnisse gedacht. Erwartet heißt nicht erwünscht, sondern realistisch und im Produktablauf vorgesehen. Kein Internet ist kein Programmierfehler. Eine leere Suchliste ist kein Crash-Grund. Ein abgelaufenes Token ist ein Zustand, auf den deine App reagieren muss.
Wie funktioniert es?
Technisch besteht Result Modeling aus drei Schritten. Erstens definierst du, welche Ergebnisse eine Operation haben kann. Zweitens gibst du diese Ergebnisse als Rückgabetyp zurück. Drittens behandelst du alle Fälle an den passenden Grenzen deiner Architektur. Das klingt nach zusätzlicher Arbeit, reduziert aber versteckte Annahmen. Du siehst an der Funktionssignatur, was passieren kann.
Ein typisches Modell sieht so aus:
sealed interface UserResult {
data class Success(val user: User) : UserResult
data object NotFound : UserResult
data object Offline : UserResult
data class UnknownError(val cause: Throwable) : UserResult
}
Dieses Modell sagt: Beim Laden eines Benutzers gibt es genau diese bekannten Ausgänge. Success enthält Daten. NotFound und Offline sind fachlich unterscheidbare Fehler. UnknownError ist ein Auffangfall für Situationen, die du noch nicht sauber einordnen kannst. Du solltest solche Auffangfälle sparsam einsetzen, aber sie sind in realen Apps oft sinnvoller als ein Crash oder ein verschluckter Fehler.
In Kotlin sind sealed interface und sealed class nützlich, weil alle direkten Untertypen im gleichen Paket oder Modul kontrolliert definiert werden. Dadurch kann ein when-Ausdruck vollständig sein:
val message = when (result) {
is UserResult.Success -> "Hallo ${result.user.name}"
UserResult.NotFound -> "Benutzer wurde nicht gefunden."
UserResult.Offline -> "Prüfe deine Internetverbindung."
is UserResult.UnknownError -> "Es ist ein unerwarteter Fehler aufgetreten."
}
Wenn du später einen neuen Fall wie Unauthorized ergänzt, zeigt dir der Compiler an vielen Stellen, dass dein when nicht mehr vollständig ist. Das ist ein großer Vorteil gegenüber losen Strings, Integer-Codes oder allgemein gefangenen Exceptions. Du bekommst früh Feedback, bevor der Fehler im Testgerät oder bei Nutzern sichtbar wird.
In einer modernen Android-Architektur läuft das oft so: Ein Repository ruft eine API auf, mappt technische Fehler auf ein Result-Modell und gibt dieses an einen Use Case oder direkt an ein ViewModel weiter. Das ViewModel wandelt das Ergebnis in einen UI-State um. Die Compose-UI beobachtet diesen State und rendert Ladezustand, Inhalt oder Fehlermeldung. Dabei bleibt jede Schicht bei ihrer Aufgabe. Das Repository kennt Datenquellen. Das ViewModel kennt UI-Zustände. Die Composable zeigt an, was sie bekommt.
Du solltest dabei zwischen Operationsergebnis und UI-Zustand unterscheiden. Ein UserResult.Offline beschreibt ein Ergebnis aus der Daten- oder Domain-Sicht. Ein UserScreenState.Error(message = ...) beschreibt, was der Bildschirm anzeigen soll. Beide hängen zusammen, sind aber nicht identisch. Wenn du diese Trennung sauber hältst, kannst du Fehler fachlich testen, ohne Compose zu starten. Gleichzeitig kannst du UI-Zustände testen, ohne echte Netzwerkfehler auszulösen.
Kotlin bietet auch Result<T>. Dieser Typ ist praktisch für einfache Wrapper um Erfolg und Exception, zum Beispiel innerhalb einer kleinen Utility-Funktion. Für öffentliche Schnittstellen in deiner App-Architektur ist ein eigenes Modell aber oft lesbarer. Result<User> sagt dir nur: Erfolg oder irgendein Fehler. UserResult sagt dir: Erfolg, nicht gefunden, offline oder unbekannt. Je näher du an fachlichen Entscheidungen bist, desto hilfreicher sind eigene Typen.
Eine wichtige Regel: Nutze Result Modeling für erwartete Fehler, nicht als Mülltonne für jedes Problem. Wenn du jede Exception fängst und in UnknownError verwandelst, kann deine App falsche Zustände verstecken. Dann sieht ein echter Programmierfehler für die UI aus wie ein normaler Fehler. Das erschwert Debugging. Fange deshalb gezielt die Fehler, die du behandeln willst. Lass klare Programmierfehler sichtbar, besonders während der Entwicklung.
In der Praxis
Stell dir vor, du baust einen Profilbildschirm. Die App soll einen Benutzer laden und dabei drei erwartete Fälle behandeln: Daten vorhanden, Benutzer nicht gefunden, keine Internetverbindung. Das Repository spricht mit einer Remote-Quelle und übersetzt technische Details in ein fachliches Result-Modell.
data class User(
val id: String,
val name: String
)
sealed interface LoadUserResult {
data class Success(val user: User) : LoadUserResult
data object NotFound : LoadUserResult
data object Offline : LoadUserResult
data class ServerError(val code: Int) : LoadUserResult
}
class UserRepository(
private val api: UserApi
) {
suspend fun loadUser(id: String): LoadUserResult {
return try {
val response = api.getUser(id)
when {
response.code == 200 && response.body != null ->
LoadUserResult.Success(response.body)
response.code == 404 ->
LoadUserResult.NotFound
else ->
LoadUserResult.ServerError(response.code)
}
} catch (exception: IOException) {
LoadUserResult.Offline
}
}
}
Das Beispiel ist bewusst klein. Entscheidend ist nicht die konkrete API-Klasse, sondern die Richtung: Technische Details werden am Rand übersetzt. Die restliche App muss nicht wissen, ob IOException, HTTP 404 oder ein leerer Body beteiligt waren. Sie bekommt ein Ergebnis, das zur Fachlogik passt.
Im ViewModel würdest du daraus einen UI-State machen:
sealed interface UserUiState {
data object Loading : UserUiState
data class Content(val name: String) : UserUiState
data class Error(val message: String) : UserUiState
}
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<UserUiState>(UserUiState.Loading)
val state: StateFlow<UserUiState> = _state.asStateFlow()
fun load(id: String) {
viewModelScope.launch {
_state.value = UserUiState.Loading
_state.value = when (val result = repository.loadUser(id)) {
is LoadUserResult.Success ->
UserUiState.Content(result.user.name)
LoadUserResult.NotFound ->
UserUiState.Error("Dieses Profil existiert nicht.")
LoadUserResult.Offline ->
UserUiState.Error("Keine Verbindung. Versuche es erneut.")
is LoadUserResult.ServerError ->
UserUiState.Error("Serverfehler ${result.code}.")
}
}
}
}
Hier siehst du den praktischen Nutzen im Alltag. Das ViewModel muss keine Exception-Typen auswerten. Es muss nicht null prüfen. Es muss auch keine HTTP-Codes tief in der UI kennen. Es verarbeitet ein begrenztes Modell. Dadurch werden Code-Reviews konkreter: Sind alle erwarteten Fälle modelliert? Sind die Meldungen passend? Wird ein fachlicher Fehler an der richtigen Stelle übersetzt?
In Compose würdest du den State sammeln und anzeigen:
@Composable
fun UserScreen(viewModel: UserViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val current = state) {
UserUiState.Loading -> {
CircularProgressIndicator()
}
is UserUiState.Content -> {
Text(text = current.name)
}
is UserUiState.Error -> {
Text(text = current.message)
}
}
}
Auch hier ist die Struktur klar. Die Composable kennt UI-Zustände, keine Repository-Details. Das passt zu den Android-Architektur-Empfehlungen: UI-Code sollte Zustände darstellen und Ereignisse auslösen, während Datenzugriff und fachliche Entscheidungen in passenden Schichten liegen.
Eine sinnvolle Entscheidungsregel lautet: Wenn ein Fehler Teil eines normalen Produktablaufs ist, modelliere ihn als Result-Fall. Wenn er einen kaputten internen Zustand beschreibt, behandle ihn nicht wie einen normalen Nutzerfehler. Falsche Zugangsdaten, leere Daten, fehlendes Netzwerk und abgelaufene Sessions sind erwartbare Fälle. Eine nicht initialisierte Dependency oder ein verletztes Datenmodell ist eher ein Entwicklungsfehler.
Eine typische Stolperfalle ist das zu grobe Modellieren. Viele Anfänger bauen nur Success und Error(message: String). Das ist besser als gar nichts, aber es verschenkt viel Typinformation. Ein String kann nicht erzwingen, dass Offline anders behandelt wird als NotFound. Außerdem sind Strings schlecht testbar, weil sie sich durch Übersetzung, Textanpassung oder Designvorgaben ändern. Besser ist ein fachlicher Fehler-Typ. Die Umwandlung in eine konkrete Meldung erfolgt später, nahe an der UI.
Eine zweite Stolperfalle ist doppeltes Modellieren ohne klare Grenze. Wenn Repository, Use Case, ViewModel und UI jeweils eigene Result-Typen besitzen, kann der Code unnötig schwer werden. Du brauchst nicht für jede Methode ein neues Ergebnisobjekt. Modellieren lohnt sich dort, wo eine Schicht wirklich eine Bedeutung hinzufügt. Ein Repository kann technische Fehler in Datenfehler übersetzen. Ein Use Case kann fachliche Regeln ergänzen. Ein ViewModel kann daraus UI-State machen. Wenn eine Schicht nur identisch durchreicht, ist ein zusätzlicher Typ oft nicht nötig.
Beim Testen zeigt Result Modeling seine Stärke. Du kannst Unit-Tests schreiben, die jeden Fall prüfen:
@Test
fun loadUser_returnsOffline_whenApiThrowsIOException() = runTest {
val api = FakeUserApi(exception = IOException())
val repository = UserRepository(api)
val result = repository.loadUser("42")
assertEquals(LoadUserResult.Offline, result)
}
Dieser Test ist klein und klar. Er beschreibt nicht nur, dass ein Fehler passiert, sondern welcher fachliche Fehler erwartet wird. Für Junior-Devs ist das ein guter Lernpunkt: Tests sollen nicht nur bestätigen, dass der Code im Idealfall läuft. Sie sollen auch die vertraglich erwarteten Fehlerpfade absichern. In Code-Reviews kannst du deshalb gezielt fragen: Gibt es Tests für Offline, NotFound und Serverfehler? Wird Success mit gültigen Daten geprüft? Wird ein unbekannter Fehler geloggt oder sinnvoll sichtbar gemacht?
Auch Debugging wird leichter. Wenn du im Debugger einen LoadUserResult.Offline siehst, musst du nicht erst durch verschachtelte try-catch-Blöcke gehen. Du erkennst direkt, in welchem Pfad du bist. Wenn dagegen überall null zurückkommt, musst du rekonstruieren, warum der Wert fehlt. War es ein leerer Cache? Ein Netzwerkfehler? Eine ungültige Antwort? Result Modeling macht diese Unterschiede sichtbar.
Achte außerdem auf Coroutines und Flow. Ein Flow<LoadUserResult> kann wiederholt Ergebnisse senden, etwa erst Cache-Daten und später Remote-Daten. Verwechsle dabei nicht Transportfehler des Flows mit fachlichen Result-Werten. Wenn dein Flow erwartete Fehler als Werte ausgibt, kann die UI sie normal beobachten. Wenn der Flow mit einer Exception abbricht, endet der Datenstrom. Beides kann sinnvoll sein, aber du solltest die Entscheidung bewusst treffen. Für viele App-Screens ist ein Result-Wert angenehmer, weil der StateFlow stabil bleibt und die UI weiterhin reagieren kann.
Fazit
Result Modeling hilft dir, Erfolg und erwartete Fehler als festen Teil deiner Android-Architektur zu behandeln. Du machst Annahmen sichtbar, nutzt Kotlin-Typen statt loser Nebenabsprachen und gibst Data Layer, Domain Layer und UI klare Verantwortlichkeiten. Übe das an einer bestehenden Repository-Methode: Schreibe zuerst alle erwarteten Ausgänge auf, modelliere sie als sealed interface, ersetze null oder pauschale Exceptions durch konkrete Result-Fälle und prüfe danach im Test oder Code-Review, ob jeder Pfad absichtlich behandelt wird.