API Thinking in Android
API Thinking hilft dir, stabile Grenzen im Code zu gestalten. Du lernst, öffentliche Funktionen als Verträge zu behandeln.
API Thinking bedeutet, dass du öffentliche Funktionen, Interfaces, Datenmodelle und Modulgrenzen nicht nur als Code betrachtest, sondern als Absprachen mit allen Aufrufern. In Android-Projekten betrifft das kleine Helferfunktionen genauso wie Repository-Interfaces, Use Cases, ViewModel-Methoden, Compose-Parameter und Datenquellen. Je klarer diese Absprachen sind, desto stabiler bleibt deine App, wenn Features wachsen, Teams größer werden oder technische Details ausgetauscht werden.
Was ist das?
Eine API ist nicht nur eine Web-Schnittstelle. In deinem eigenen Android-Code ist jede öffentlich erreichbare Funktion eine kleine API. Sobald anderer Code sie aufruft, entsteht ein Vertrag: Welche Parameter sind erlaubt? Was kommt zurück? Darf die Funktion fehlschlagen? Ist sie schnell genug für den UI-Pfad? Muss sie aus einer Coroutine aufgerufen werden? Bleibt das Verhalten über Releases hinweg gleich?
API Thinking ist die Gewohnheit, diese Fragen vor dem Veröffentlichen einer Grenze zu stellen. Du entwirfst nicht nur, damit der Code heute kompiliert. Du entwirfst so, dass andere Aufrufer die Funktion korrekt nutzen können, ohne interne Details kennen zu müssen. Das ist ein wichtiger Schritt vom Anfänger zum professionelleren Android-Entwickler: Du schreibst nicht mehr nur Anweisungen, sondern zuverlässige Bausteine.
Im Android-Kontext taucht API Thinking an vielen Stellen auf. Eine Repository-Schnittstelle im Data Layer ist ein Vertrag zwischen ViewModel und Datenquellen. Eine @Composable-Funktion ist ein Vertrag zwischen UI-Aufrufer und UI-Komponente. Ein öffentliches Datenmodell ist ein Vertrag zwischen Schichten, Tests und eventuell Persistenz oder Netzwerkcode. Selbst ein Modul in Gradle hat eine API-Fläche: Alles, was andere Module importieren können, wird schwieriger zu ändern.
Das Problem, das API Thinking löst, ist nicht nur technischer Art. Ohne klare Verträge entstehen versteckte Kopplungen. Ein ViewModel weiß plötzlich, welche Datenbanktabelle im Repository steckt. Eine Composable erwartet, dass eine Liste nie leer ist, obwohl das nirgends erkennbar ist. Ein Helper gibt bei Fehlern null zurück, aber ein Aufrufer behandelt null als leeren Erfolg. Solche Stellen funktionieren oft in der ersten Version. Später werden sie teuer, weil jede Änderung Nebenwirkungen auslöst.
Wie funktioniert es?
Das mentale Modell ist: Jede API hat Anbieter und Aufrufer. Der Anbieter besitzt die Implementierung. Der Aufrufer besitzt die Nutzung. Dazwischen liegt der Vertrag. Gute APIs machen diesen Vertrag sichtbar, begrenzt und stabil.
Sichtbar heißt: Namen, Typen und Rückgabewerte drücken Absicht aus. Eine Funktion loadUser() sagt weniger als observeCurrentUser() oder refreshUserProfile(). Der Unterschied ist relevant. observeCurrentUser() klingt nach einem laufenden Datenstrom, zum Beispiel Flow<User?>. refreshUserProfile() klingt nach einer Aktion, die Netzwerk oder Cache aktualisiert und scheitern kann. Wenn der Name das Verhalten trifft, muss der Aufrufer weniger raten.
Begrenzt heißt: Die API gibt nur preis, was Aufrufer wissen müssen. Ein Repository sollte etwa nicht seine konkrete Room-DAO-Klasse nach außen geben, wenn das ViewModel nur Profildaten braucht. Die Android-Architektur-Dokumentation empfiehlt eine klare Data Layer-Struktur, bei der Repositories Datenquellen kapseln und eine passende API für andere Schichten anbieten. Genau hier wirkt API Thinking: Du verhinderst, dass Details aus Datenbank, Netzwerk oder Cache in die UI-Schicht wandern.
Stabil heißt: Du änderst öffentliche Verträge mit Vorsicht. Eine private Funktion kannst du umbauen, solange Tests und Verhalten stimmen. Eine öffentliche Funktion hat Aufrufer. Änderst du ihren Rückgabetyp, ihre Fehlerlogik oder ihre Thread-Annahmen, musst du alle Aufrufer verstehen. In einer App mit mehreren Modulen kann eine kleine Änderung sonst zu vielen Anpassungen führen.
In Kotlin helfen dir Typen, Verträge zu formulieren. Nullable-Typen zeigen, dass ein Wert fehlen kann. Result<T> oder eigene sealed Interfaces können Erfolg und Fehler explizit machen. Flow<T> zeigt, dass Werte über Zeit kommen. suspend zeigt, dass die Funktion in einer Coroutine läuft und unterbrechen kann. Diese Signale sind Teil deiner API. Du solltest sie nicht zufällig wählen.
Auch Compose verlangt API Thinking. Eine Composable sollte möglichst klar sagen, welche Daten sie rendert und welche Ereignisse sie nach außen meldet. Performance-Hinweise in Compose drehen sich oft um stabile Eingaben, unnötige Neuberechnungen und klare Zuständigkeiten. Wenn eine Composable zu viele veränderliche Objekte oder unklare Callback-Verträge bekommt, wird sie schwerer zu testen und kann häufiger neu zusammengesetzt werden als nötig. API Thinking bedeutet hier: Die UI-Komponente erhält einen gut definierten Zustand und sendet klar benannte Events zurück.
Ein weiterer Punkt ist Fehlerverhalten. Viele Anfänger schreiben APIs, die bei Fehlern still scheitern: leere Listen, null, allgemeine Exceptions oder Logs ohne Rückgabe. Das wirkt kurzfristig bequem, verschiebt das Problem aber zu den Aufrufern. Eine gute API zwingt den Aufrufer nicht zum Rätselraten. Sie macht erkennbare Fehlerfälle modellierbar.
In der Praxis
Stell dir vor, du baust einen Profilbereich. Das ViewModel braucht aktuelle Profildaten, aber es soll nicht wissen, ob diese Daten aus Netzwerk, Room oder Cache kommen. Eine schlechte API könnte so aussehen:
class UserRepository(
val api: UserApi,
val dao: UserDao
) {
suspend fun getUser(id: String): UserEntity? {
val cached = dao.findById(id)
if (cached != null) return cached
val response = api.fetchUser(id)
dao.insert(response.toEntity())
return dao.findById(id)
}
}
Dieser Code kann funktionieren, hat aber eine schwache API-Fläche. Das Repository gibt UserEntity zurück, also ein Datenbankdetail. Der Aufrufer muss wissen, dass null verschiedene Bedeutungen haben kann: nicht gefunden, Netzwerkfehler, leerer Cache oder ein anderer Fehler. Außerdem sind api und dao öffentlich erreichbar. Andere Klassen könnten daran vorbei arbeiten und damit die Repository-Grenze umgehen.
Eine stärker gedachte API trennt Vertrag und Implementierung:
interface UserRepository {
fun observeUser(userId: UserId): Flow<UserProfile?>
suspend fun refreshUser(userId: UserId): RefreshResult
}
data class UserId(val value: String)
data class UserProfile(
val id: UserId,
val displayName: String,
val avatarUrl: String?
)
sealed interface RefreshResult {
data object Success : RefreshResult
data object NotFound : RefreshResult
data class NetworkError(val message: String) : RefreshResult
}
class DefaultUserRepository(
private val api: UserApi,
private val dao: UserDao
) : UserRepository {
override fun observeUser(userId: UserId): Flow<UserProfile?> {
return dao.observeById(userId.value)
.map { entity -> entity?.toProfile() }
}
override suspend fun refreshUser(userId: UserId): RefreshResult {
return try {
val dto = api.fetchUser(userId.value)
if (dto == null) {
RefreshResult.NotFound
} else {
dao.upsert(dto.toEntity())
RefreshResult.Success
}
} catch (e: IOException) {
RefreshResult.NetworkError(
message = e.message ?: "Netzwerkfehler"
)
}
}
}
Hier sieht der Aufrufer mehr Absicht und weniger Interna. observeUser() ist klar ein Datenstrom. refreshUser() ist klar eine Aktion. UserProfile ist ein UI-nahes Domänenmodell, keine Room-Entity. Fehler werden als RefreshResult sichtbar. api und dao bleiben privat. Damit schützt das Repository seine Grenze.
Im ViewModel kann die Nutzung dann lesbarer werden:
class ProfileViewModel(
private val userRepository: UserRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val userId = UserId(
checkNotNull(savedStateHandle["userId"])
)
val profile: StateFlow<UserProfile?> =
userRepository.observeUser(userId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = null
)
fun refresh() {
viewModelScope.launch {
when (val result = userRepository.refreshUser(userId)) {
RefreshResult.Success -> Unit
RefreshResult.NotFound -> {
// UI-State für "nicht gefunden" setzen
}
is RefreshResult.NetworkError -> {
// Fehlermeldung im UI-State setzen
}
}
}
}
}
Die praktische Entscheidungsregel lautet: Bevor du eine Funktion oder Klasse öffentlich machst, schreibe in einem Satz auf, was ein Aufrufer wissen darf und was nicht. Wenn dieser Satz Datenbanknamen, Netzwerkdetails oder Reihenfolge interner Schritte enthält, ist deine API wahrscheinlich zu breit. Wenn der Satz Verhalten, Eingaben, Ausgaben und Fehlerfälle beschreibt, bist du näher an einem stabilen Vertrag.
Eine zweite Regel: Verwende den spezifischsten Rückgabetyp, der die Nutzung korrekt ausdrückt. Wenn der Aufrufer mehrere Zustände unterscheiden muss, verstecke sie nicht in Boolean oder null. Ein Boolean von refresh() sagt kaum etwas. War false ein Netzwerkfehler, ein nicht gefundener Nutzer oder eine abgebrochene Anfrage? Ein eigener Ergebnistyp kostet etwas Code, spart aber Missverständnisse.
Bei Compose gilt eine ähnliche Regel. Eine Composable sollte nicht selbst im Repository lesen, wenn sie eigentlich nur einen Zustand darstellen soll. Besser ist eine API aus State und Events:
@Composable
fun ProfileScreen(
profile: UserProfile?,
isRefreshing: Boolean,
errorMessage: String?,
onRefreshClick: () -> Unit
) {
// UI rendert Zustand und meldet Nutzeraktionen nach außen.
}
Diese API macht die Komponente testbarer und stabiler. Sie kennt keine konkrete Datenquelle und kein ViewModel-Detail. Gleichzeitig sehen Aufrufer sofort, welche Zustände unterstützt werden. Wenn du später die Datenquelle änderst, bleibt die UI-API eher stabil.
Eine typische Stolperfalle ist die zu frühe Veröffentlichung von Bequemlichkeit. Du machst eine Klasse public, weil ein Test oder ein anderes Modul sie gerade braucht. Du gibst eine Mutable-Liste zurück, weil du sie intern ohnehin hast. Du reichst eine DAO weiter, weil das schneller geht. Solche Entscheidungen vergrößern die Vertragsfläche. Später musst du diese Details weiter unterstützen oder viele Aufrufer umbauen.
Eine weitere Stolperfalle ist Überdesign. API Thinking bedeutet nicht, für jede kleine private Funktion ein Interface zu bauen. Private Implementierungsdetails dürfen direkt und pragmatisch bleiben. Entscheidend sind Grenzen, an denen Aufrufer entstehen: Modulgrenzen, Schichtgrenzen, öffentliche Klassen, wiederverwendete UI-Komponenten und Funktionen, die von mehreren Stellen genutzt werden. Dort lohnt sich sorgfältiges Design.
Du kannst dein Verständnis im Alltag prüfen. Öffne eine bestehende Klasse und markiere alles, was von außen erreichbar ist. Frage dich dann: Würde ein neuer Junior-Dev diese API korrekt nutzen können, ohne die Implementierung zu lesen? Sind Fehlerfälle sichtbar? Sind Namen präzise? Gibt es Parameter, die nur interne Workarounds verraten? In Code-Reviews kannst du genau diese Fragen stellen. Tests helfen ebenfalls: Wenn du eine API nur testen kannst, indem du interne Details vorbereitest, ist die Grenze vielleicht unscharf.
Fazit
API Thinking trainiert dich darin, Android-Code als System aus verlässlichen Verträgen zu sehen. Du schützt Aufrufer vor unnötigen Details, machst Verhalten mit Kotlin-Typen sichtbar und hältst Modul- sowie Schichtgrenzen stabil. Prüfe diese Idee aktiv an deinem nächsten Repository, ViewModel oder Compose-Screen: Schreibe den Vertrag in einem Satz auf, ergänze einen Test für einen Fehlerfall und bitte im Code-Review gezielt um Feedback zur öffentlichen API-Fläche.