Dependency Inversion in Android
Dependency Inversion trennt Fachlogik von konkreten Implementierungen. Du lernst, wann Interfaces Tests und Austauschbarkeit verbessern.
Dependency Inversion hilft dir, Android-Code so zu strukturieren, dass wichtige Fachlogik nicht direkt an konkrete Details gebunden ist. Statt ein ViewModel fest mit einer bestimmten Datenbank-, Netzwerk- oder Repository-Klasse zu verdrahten, arbeitest du an passenden Stellen gegen eine Abstraktion. Das klingt zuerst nach zusätzlichem Aufwand, wird aber relevant, sobald du Logik testen, Implementierungen austauschen oder klare Modulgrenzen halten willst.
Was ist das?
Dependency Inversion ist ein Architekturprinzip: Code mit hoher fachlicher Bedeutung soll nicht von technischen Details abhängen. Beide Seiten sollen sich an einer Abstraktion orientieren. In Kotlin ist diese Abstraktion oft ein interface, manchmal auch eine kleine Funktionsschnittstelle oder ein klarer Vertrag über ein Repository.
Das Problem dahinter kennst du aus typischen Android-Projekten. Ein ViewModel soll etwa Aufgaben laden, einen Login ausführen oder Einstellungen speichern. Wenn es direkt eine konkrete Klasse wie RetrofitUserApi, RoomTaskDao oder EncryptedSharedPreferences erzeugt, hängt deine App-Logik an diesem Detail. Du kannst dann schwer testen, was passiert, wenn der Server einen Fehler liefert. Du kannst die Datenquelle nicht leicht ersetzen. Und du vermischst Zuständigkeiten: Das ViewModel entscheidet nicht nur über UI-Zustand, sondern kennt auch technische Infrastruktur.
Das mentale Modell ist einfach: Die stabile innere Logik soll sagen, was sie braucht. Die äußere technische Schicht liefert, wie es umgesetzt wird. Ein Interface ist dabei eine Grenze. Es beschreibt zum Beispiel: „Ich kann Aufgaben laden“ oder „Ich kann den aktuellen Nutzer speichern“. Ob die Daten aus Room, DataStore, Retrofit, einem Cache oder einem Fake im Test kommen, bleibt außerhalb der nutzenden Klasse.
Im Android-Kontext passt Dependency Inversion gut zu Jetpack-Architektur, Compose und Testing. Compose-Funktionen sollten möglichst Zustände anzeigen und Events weiterreichen. ViewModels koordinieren UI-nahe Logik. Repositories kapseln Datenzugriff. Dependency Inversion hilft dir, diese Schichten sauber zu trennen, ohne jede Klasse unnötig kompliziert zu machen. Der Kern der Roadmap-Regel lautet: Hänge dort von Abstraktionen ab, wo Austauschbarkeit und Testisolation wirklich besser werden.
Wie funktioniert es?
Dependency Inversion funktioniert über drei Schritte. Erstens erkennst du eine Grenze. Zweitens formulierst du einen Vertrag. Drittens übergibst du die konkrete Implementierung von außen.
Eine Grenze liegt oft dort, wo dein Code die eigene App-Logik verlässt. Beispiele sind Netzwerkzugriffe, Datenbankoperationen, Dateien, Android-Systemdienste, Zeit, Zufall, Standort, Push-Nachrichten oder externe SDKs. Diese Dinge sind langsam, fehleranfällig, schwer deterministisch zu testen oder abhängig von Gerät und Umgebung. Genau dort lohnt sich eine Abstraktion häufig.
Der Vertrag sollte klein und fachlich sein. Ein schlechtes Interface kopiert nur technische Details einer Bibliothek. Ein besseres Interface beschreibt die Fähigkeit, die deine App braucht. Statt ein komplettes Retrofit-API durch alle Schichten zu reichen, kann ein UserRepository zum Beispiel suspend fun loadProfile(): Result<UserProfile> anbieten. Das ViewModel muss dann nicht wissen, welche HTTP-Methode verwendet wird oder wie JSON geparst wird.
In Kotlin sieht das Prinzip oft so aus: Eine Klasse bekommt ihre Abhängigkeit im Konstruktor. Sie erstellt sie nicht selbst. Dieses Muster nennt man Constructor Injection. Du kannst dafür manuell Objekte zusammenbauen oder ein Dependency-Injection-Framework wie Hilt nutzen. Das Prinzip bleibt gleich: Die nutzende Klasse hängt an einem Vertrag, die konkrete Implementierung wird von außen bereitgestellt.
Wichtig ist die Richtung der Abhängigkeit. Ohne Dependency Inversion kennt die innere Logik die konkrete äußere Klasse. Mit Dependency Inversion kennt die innere Logik nur das Interface. Die äußere Klasse implementiert dieses Interface. Damit zeigt die fachliche Schicht nicht mehr direkt auf technische Details. Die technische Schicht erfüllt den Vertrag.
Für Tests ist das besonders wertvoll. Die Android-Testgrundlagen betonen, dass du Verhalten zuverlässig prüfen sollst. Wenn eine Klasse direkt echte Netzwerk- oder Datenbankobjekte nutzt, wird ein Unit-Test langsam, instabil oder nur mit viel Setup möglich. Wenn sie ein Interface nutzt, kannst du im Test einen Fake einsetzen. Dieser Fake liefert definierte Antworten, speichert Aufrufe im Speicher oder simuliert Fehlerfälle. So prüfst du die Logik der Klasse isoliert.
Dependency Inversion bedeutet aber nicht, dass jede einzelne Klasse ein Interface braucht. Ein häufiger Anfängerfehler ist, für jede Implementierung sofort ein passendes Interface zu erzeugen, obwohl es nur eine Nutzung, keinen Testbedarf und keine echte Grenze gibt. Dann entsteht mehr Code, aber keine bessere Architektur. Ein Interface ist kein Qualitätsmerkmal an sich. Es ist ein Werkzeug für Austauschbarkeit, Testbarkeit und klare Zuständigkeiten.
Im Alltag erscheint das Prinzip meist unspektakulär. Du siehst es, wenn ein ViewModel ein Repository als Konstruktorparameter bekommt. Du siehst es, wenn ein Use Case nur ein kleines Gateway kennt. Du siehst es, wenn ein Test keine echte Datenbank öffnet, sondern eine In-Memory-Implementierung nutzt. Und du siehst es im Code-Review, wenn jemand fragt: „Warum kennt diese UI-nahe Klasse Details der Netzwerkbibliothek?“
In der Praxis
Nimm eine einfache Aufgabenliste. Das ViewModel soll Aufgaben laden und der UI einen Zustand anbieten. Ohne Dependency Inversion könnte das ViewModel selbst eine konkrete API-Klasse erstellen. Das koppelt UI-Logik an Netzwerkdetails. Besser ist eine Grenze über ein Repository-Interface.
data class Task(
val id: String,
val title: String,
val done: Boolean
)
interface TaskRepository {
suspend fun loadTasks(): List<Task>
}
class RemoteTaskRepository(
private val api: TaskApi
) : TaskRepository {
override suspend fun loadTasks(): List<Task> {
return api.fetchTasks().map { dto ->
Task(
id = dto.id,
title = dto.title,
done = dto.completed
)
}
}
}
data class TaskUiState(
val isLoading: Boolean = false,
val tasks: List<Task> = emptyList(),
val errorMessage: String? = null
)
class TaskListViewModel(
private val repository: TaskRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(TaskUiState())
val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
fun load() {
viewModelScope.launch {
_uiState.value = TaskUiState(isLoading = true)
runCatching { repository.loadTasks() }
.onSuccess { tasks ->
_uiState.value = TaskUiState(tasks = tasks)
}
.onFailure {
_uiState.value = TaskUiState(
errorMessage = "Aufgaben konnten nicht geladen werden."
)
}
}
}
}
Das ViewModel kennt nur TaskRepository. Es weiß nicht, ob RemoteTaskRepository Retrofit nutzt, ob ein Cache davorliegt oder ob die Daten aus einer lokalen Quelle kommen. Die konkrete Implementierung wird an anderer Stelle verdrahtet, zum Beispiel in einem DI-Modul oder beim manuellen Erzeugen der Objektgraphen.
Für einen Test kannst du nun einen Fake bauen:
class FakeTaskRepository(
private val result: Result<List<Task>>
) : TaskRepository {
override suspend fun loadTasks(): List<Task> {
return result.getOrThrow()
}
}
Damit prüfst du gezielt, wie das ViewModel auf erfolgreiche Daten oder Fehler reagiert. Du brauchst keinen echten Server, keine echte Datenbank und keine Netzwerkberechtigung. Genau hier zahlt sich Dependency Inversion aus: Du isolierst den Test auf das Verhalten, das dich interessiert.
Eine sinnvolle Entscheidungsregel lautet: Erzeuge ein Interface, wenn die Abhängigkeit über eine echte Grenze geht oder wenn du sie in Tests ersetzen willst. Typische Kandidaten sind Repositories, Datenquellen, Clock-Provider, Analytics-Tracker, Location-Provider oder Auth-Services. Weniger sinnvoll ist ein Interface für eine kleine reine Hilfsklasse, die keine externen Ressourcen nutzt und nur an einer Stelle verwendet wird.
Eine typische Stolperfalle ist ein zu technisches Interface. Wenn dein Interface Methoden wie executeGetRequest(path: String) oder insertEntity(entity: TaskEntity) enthält, kann es sein, dass du nur die technische Schicht umbenannt hast. Die nutzende Klasse hängt dann zwar formal an einem Interface, denkt aber weiter in HTTP- oder Datenbankdetails. Besser ist ein Vertrag, der zur fachlichen Aufgabe passt: loadTasks, saveTask, observeCompletedTasks.
Eine zweite Stolperfalle ist ein zu großes Interface. Wenn UserRepository Login, Profil, Einstellungen, Avatar-Upload, Session-Refresh und Admin-Funktionen enthält, müssen Tests oft Methoden implementieren, die für den konkreten Fall unwichtig sind. Kleine, gezielte Verträge sind leichter zu verstehen und leichter zu faken. Das heißt nicht, dass jede Methode ein eigenes Interface braucht. Achte darauf, welche Fähigkeiten zusammengehören und welche Klasse sie wirklich benötigt.
In Compose-Projekten solltest du außerdem darauf achten, die Abstraktion nicht direkt in composable Funktionen zu verstecken. Eine Composable sollte nicht selbst ein Repository aus einem Service Locator holen und dann Daten laden. Häufig ist es besser, dass ein ViewModel den Zustand vorbereitet und die Composable nur uiState und Event-Callbacks bekommt. So bleibt die UI gut testbar und die Abhängigkeitsgrenze liegt dort, wo sie besser kontrollierbar ist.
Auch Release-Qualität profitiert davon. Wenn technische Details sauber getrennt sind, kannst du Varianten leichter testen: eine Fake-Implementierung für Preview- oder Demo-Daten, eine In-Memory-Quelle für schnelle Tests, eine produktive Implementierung für echte Nutzer. Das passt zu moderner Android-Qualität, weil stabile Tests, klare Architektur und vorhersehbares Verhalten vor Releases wichtiger sind als eine theoretisch perfekte Klassendatei-Struktur.
Du kannst dein Verständnis im Code-Review prüfen. Suche nach Klassen, die Retrofit.Builder, Room.databaseBuilder, Calendar.getInstance, LocationManager oder externe SDKs direkt in UI-naher Logik verwenden. Frage dann: Ist das eine Grenze, die ich abstrahieren sollte? Würde ein Fake den Test einfacher machen? Wird durch ein Interface die fachliche Absicht klarer? Wenn du diese Fragen konkret beantworten kannst, nutzt du Dependency Inversion mit Ziel statt aus Gewohnheit.
Fazit
Dependency Inversion ist kein Aufruf, überall Interfaces zu verteilen. Es ist eine konkrete Technik, um wichtige Android-Logik von austauschbaren Details zu trennen. Du solltest sie dort einsetzen, wo Grenzen entstehen: Netzwerk, Persistenz, Systemdienste, externe SDKs oder schwer kontrollierbare Umgebung. Prüfe das Gelernte aktiv an einem bestehenden ViewModel: Ersetze eine konkrete Datenquelle durch ein kleines fachliches Interface, schreibe einen Fake und teste einen Erfolgs- sowie einen Fehlerfall. Wenn der Test klarer wird und die Klasse weniger über technische Details weiß, hast du das Prinzip richtig angewendet.