Interfaces in Kotlin
Interfaces trennen Vertrag und Umsetzung. So bleibt Android-Code austauschbar, testbar und leichter wartbar.
Interfaces gehören zu den wichtigsten Werkzeugen, wenn du Kotlin-Code für Android sauber strukturieren willst. Sie helfen dir, klare Grenzen zwischen App-Logik, Datenzugriff, Plattform-APIs und Tests zu ziehen, ohne dich früh auf eine konkrete Umsetzung festzulegen. Genau dadurch wird Code besser wartbar: Du kannst eine Implementierung austauschen, ohne alle Aufrufer neu zu schreiben.
Was ist das?
Ein Interface ist ein Vertrag. Es beschreibt, welche Funktionen oder Eigenschaften eine Klasse anbieten muss. Es beschreibt aber nicht zwingend, wie diese Funktionen intern umgesetzt werden. Wenn eine Klasse ein Interface implementiert, verspricht sie: „Ich kann das, was dieser Vertrag verlangt.“
In Kotlin kann ein Interface Funktionen ohne Implementierung enthalten. Es kann auch Standardimplementierungen enthalten, wenn ein Verhalten für alle Implementierungen gleich sein soll. Trotzdem bleibt der wichtigste Gedanke: Das Interface ist die Grenze, die der Rest deines Codes kennt. Die konkrete Klasse dahinter ist austauschbar.
Für Android ist das besonders wertvoll, weil Apps viele Bereiche verbinden: Netzwerk, Datenbank, Dateisystem, Sensoren, UI, Navigation, Hintergrundarbeit und Tests. Wenn deine ViewModels direkt konkrete Klassen für Retrofit, Room oder Android-Systemdienste verwenden, wird dein Code schnell fest verdrahtet. Du kannst ihn schwer testen, schwer ersetzen und schwer in kleinen Schritten verbessern.
Ein Interface schafft hier Abstraktion. Abstraktion heißt nicht, dass du Dinge unnötig kompliziert machst. Es heißt, dass du bewusst entscheidest, welche Details ein Teil deines Programms kennen darf. Ein ViewModel muss zum Beispiel meistens nicht wissen, ob Benutzerdaten aus Room, aus dem Netzwerk oder aus einem Cache kommen. Es muss nur wissen, dass es Benutzerdaten anfragen kann.
Ein gutes mentales Modell ist die Steckdose: Das Gerät kennt die Form des Anschlusses, aber nicht das Kraftwerk. In Code ist das Interface diese Anschlussform. Die Implementierung ist die konkrete Technik dahinter. Wenn der Vertrag stabil bleibt, kannst du die Technik wechseln.
Das Thema hängt eng mit Dependency Injection zusammen. DI bedeutet, dass eine Klasse ihre Abhängigkeiten nicht selbst baut, sondern von außen bekommt. Interfaces machen diese Abhängigkeiten flexibler. Statt UserRepositoryImpl() direkt im ViewModel zu erzeugen, bekommt das ViewModel ein UserRepository. Welche Klasse diesen Vertrag erfüllt, entscheidet eine andere Schicht deiner App, zum Beispiel ein DI-Container wie Hilt oder eine manuelle Factory.
Wie funktioniert es?
In Kotlin definierst du ein Interface mit dem Schlüsselwort interface. Klassen implementieren es mit einem Doppelpunkt. Wenn eine Funktion im Interface keinen Body hat, muss die Klasse sie überschreiben. Wenn das Interface eine Standardimplementierung hat, kann die Klasse sie übernehmen oder bei Bedarf ersetzen.
Wichtig ist dabei die Richtung der Abhängigkeit. Stabiler Code hängt von Verträgen ab, nicht von Details. Dein ViewModel sollte also eher von LoginRepository abhängen als von RetrofitLoginRepository. Die konkrete Klasse darf die Details kennen: API-Service, Datenbank, Mapper, Fehlerbehandlung. Der Aufrufer bleibt auf der fachlichen Ebene.
Ein Interface kann auch Eigenschaften definieren. In Android-Code werden Interfaces aber oft vor allem für Funktionen verwendet, etwa für Repositories, Datenquellen, Tracker, Validatoren oder Scheduler. Diese Verträge stehen häufig in einer Domain- oder Feature-Schicht. Die Implementierungen liegen dann in einer Data-Schicht oder in einem Android-spezifischen Modul.
Der Lebenszyklus spielt indirekt ebenfalls eine Rolle. Ein Interface hat keinen Android-Lebenszyklus. Genau das ist oft ein Vorteil. Wenn du Logik hinter einem Interface formulierst, kannst du sie außerhalb von Activity, Fragment oder Compose testen. Ein ViewModel kann eine suspendierende Funktion aufrufen, ohne zu wissen, ob dahinter ein Netzwerkrequest läuft oder ein Fake sofort Testdaten zurückgibt.
Bei Coroutines und Flow passen Interfaces sehr gut. Ein Repository kann zum Beispiel Flow<List<Task>> liefern oder eine suspend fun refresh() anbieten. Das Interface beschreibt damit nicht nur Methoden, sondern auch den erwarteten Kommunikationsstil: einmaliger Aufruf, kontinuierlicher Datenstrom, Fehler als Exception oder Fehler als Ergebnisobjekt.
In Compose ist der Nutzen ähnlich. Composables sollten möglichst wenig über Datenquellen wissen. Sie bekommen Zustand und Events. Die Schicht darunter, oft ein ViewModel, kann über Interfaces mit Repositories oder anderen Diensten sprechen. Dadurch bleibt die UI leichter austauschbar und einfacher zu testen, weil sie nicht direkt mit Infrastruktur verbunden ist.
Ein häufiger Fehler ist ein zu großes Interface. Wenn du ein Interface mit zwanzig Methoden definierst, zwingst du jede Implementierung, sehr viel zu können. Tests werden dann mühsam, weil ein Fake viele irrelevante Methoden bereitstellen muss. Besser sind kleine, fachlich geschnittene Verträge. Ein Interface sollte ausdrücken, was ein bestimmter Aufrufer wirklich braucht.
Ein zweiter Fehler ist ein Interface für jede Klasse. Interfaces sind kein Selbstzweck. Wenn du nur eine kleine Hilfsklasse hast, die nie ersetzt, nie getestet und nie über Modulgrenzen hinweg verwendet wird, brauchst du nicht automatisch ein Interface. Setze Interfaces dort ein, wo eine echte Grenze entsteht: zwischen Fachlogik und Infrastruktur, zwischen Produktionscode und Testcode oder zwischen Modul und Plattform.
In der Praxis
Stell dir vor, du baust eine Aufgaben-App. Ein ViewModel soll Aufgaben laden und neue Aufgaben speichern. Du möchtest später entscheiden können, ob die Daten aus einer lokalen Room-Datenbank, einer Web-API oder einem Fake im Test kommen. Das ViewModel soll diese Entscheidung nicht kennen.
Ein möglicher Vertrag sieht so aus:
data class Task(
val id: String,
val title: String,
val isDone: Boolean
)
interface TaskRepository {
fun observeTasks(): kotlinx.coroutines.flow.Flow<List<Task>>
suspend fun addTask(title: String)
suspend fun setTaskDone(id: String, isDone: Boolean)
}
class TaskViewModel(
private val repository: TaskRepository
) : ViewModel() {
val tasks: StateFlow<List<Task>> =
repository.observeTasks()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun add(title: String) {
viewModelScope.launch {
repository.addTask(title)
}
}
fun toggle(task: Task) {
viewModelScope.launch {
repository.setTaskDone(task.id, !task.isDone)
}
}
}
Das ViewModel kennt nur TaskRepository. Es weiß nicht, ob die Daten aus Room, DataStore, einer REST-API oder einem In-Memory-Speicher kommen. Dadurch bleibt seine Aufgabe klar: Zustand für die UI bereitstellen und Nutzeraktionen in fachliche Operationen übersetzen.
Eine produktive Implementierung könnte intern eine Datenbank verwenden. Ein Test kann dagegen eine kleine Fake-Implementierung nutzen:
class FakeTaskRepository : TaskRepository {
private val tasks = MutableStateFlow<List<Task>>(emptyList())
override fun observeTasks(): Flow<List<Task>> = tasks
override suspend fun addTask(title: String) {
val task = Task(
id = "task-${tasks.value.size + 1}",
title = title,
isDone = false
)
tasks.value = tasks.value + task
}
override suspend fun setTaskDone(id: String, isDone: Boolean) {
tasks.value = tasks.value.map { task ->
if (task.id == id) task.copy(isDone = isDone) else task
}
}
}
Damit kannst du ViewModel-Tests schreiben, ohne Netzwerk, Datenbank oder echte Android-Komponenten zu starten. Genau hier wird der Zusammenhang zu Android-Qualität sichtbar: Testbarer Code entsteht selten nachträglich. Er entsteht, wenn du die Grenzen früh klar setzt.
Eine praktische Entscheidungsregel lautet: Verwende ein Interface, wenn der Aufrufer eine Fähigkeit braucht, aber die konkrete Technik dahinter nicht kennen sollte. Das trifft oft auf Repositories, Analytics, Clock-Zugriffe, Standortanbieter, Authentifizierung, Dateizugriff und externe Dienste zu. Verwende kein Interface nur deshalb, weil eine Klasse existiert. Der Vertrag muss eine echte Abstraktion ausdrücken.
Achte außerdem auf die Benennung. TaskRepository ist meist besser als ITaskRepository. In Kotlin ist das Präfix I unüblich. Die Implementierung kann konkreter heißen, zum Beispiel RoomTaskRepository, NetworkTaskRepository oder OfflineFirstTaskRepository. So liest sich der Code fachlich und nicht wie eine technische Übung.
Eine typische Stolperfalle ist, Android-Typen zu früh in das Interface zu ziehen. Wenn dein Repository zum Beispiel Context, Cursor, Uri oder eine konkrete Room-Entity nach außen gibt, hängt jeder Aufrufer an diesen Details. Manchmal ist das korrekt, aber oft verschiebst du damit Infrastruktur in deine Fachlogik. Prüfe deshalb im Code-Review: Gehört dieser Typ wirklich zum Vertrag, oder ist er ein Implementierungsdetail?
Eine weitere Stolperfalle betrifft Default-Methoden in Interfaces. Kotlin erlaubt sie, und sie können nützlich sein. Trotzdem solltest du damit sparsam umgehen. Wenn ein Interface sehr viel Verhalten enthält, wird es schnell zu einer halben Basisklasse. Dann ist oft eine normale Klasse, eine Komposition aus mehreren kleineren Verträgen oder eine freie Funktion die bessere Wahl.
Bei Dependency Injection bindest du den Vertrag an eine Implementierung. Mit Hilt könnte ein Modul zum Beispiel sagen: Wenn irgendwo TaskRepository gebraucht wird, liefere RoomTaskRepository. Der Rest der App bleibt beim Interface. Für lokale Tests kannst du dagegen direkt den Fake übergeben. Für Instrumentation Tests kannst du je nach Aufbau eine Testbindung verwenden.
So prüfst du dein Verständnis praktisch: Nimm ein ViewModel, das direkt eine konkrete Datenquelle verwendet, und ziehe ein kleines Interface davor. Schreibe danach einen Test mit einer Fake-Implementierung. Wenn der Test ohne echte Datenbank, ohne Netzwerk und ohne Activity läuft, hast du die Grenze sinnvoll gesetzt. Wenn du beim Fake sehr viele leere Methoden implementieren musst, ist dein Interface wahrscheinlich zu breit.
Fazit
Interfaces sind in Kotlin und Android kein Dekor, sondern ein Werkzeug für klare Grenzen. Du definierst damit Verträge, machst Implementierungen austauschbar und senkst die Hürde für Tests. Prüfe beim nächsten Feature bewusst, welche Klasse wirklich Details kennen muss und welche nur eine Fähigkeit benötigt. Baue dann ein kleines Interface, verwende es im ViewModel oder Use Case und validiere die Entscheidung mit einem Fake-Test oder einem Code-Review.