Android Coden
Android 9 min lesen

SOLID pragmatisch anwenden

SOLID hilft dir, Android-Code so zu strukturieren, dass Änderungen kontrollierbar bleiben und Tests leichter werden.

SOLID hilft dir, Android-Code so zu schreiben, dass neue Anforderungen nicht sofort alte Bereiche beschädigen. Pragmatisch angewendet bedeutet das: Du nutzt die Prinzipien als Orientierung für Wartbarkeit, Abhängigkeiten und Veränderbarkeit, aber du baust keine künstliche Architektur, nur um ein Akronym zu erfüllen.

Was ist das?

SOLID ist eine Sammlung von fünf Design-Prinzipien für objektorientierten Code: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation und Dependency Inversion. In Kotlin und moderner Android-Entwicklung wirken diese Ideen oft weniger wie klassische Klassenregeln und mehr wie Fragen an deinen Code: Hat diese Komponente zu viele Gründe, sich zu ändern? Hängt sie von einem Detail ab, das im Test oder beim Refactoring stört? Muss eine kleine UI-Änderung durch ViewModel, Repository, Netzwerkcode und Datenbank wandern?

SOLID-Pragmatismus heißt, dass du diese Fragen ernst nimmst, ohne jedes Projekt mit Interfaces, Factories und Schichten zu überladen. Ein Anfängerfehler ist, SOLID als Checkliste zu lesen: Jede Klasse braucht genau eine Aufgabe, jede Abhängigkeit braucht ein Interface, jede Änderung muss über Erweiterung statt Anpassung laufen. Das klingt sauber, führt aber in kleinen Android-Features schnell zu unnötigem Code. Dann wird die App nicht wartbarer, sondern schwerer zu verstehen.

Das passendere mentale Modell lautet: Struktur ist ein Werkzeug gegen teure Änderungen. Wenn eine Änderung wahrscheinlich ist, sollte dein Code dafür vorbereitet sein. Wenn eine Änderung sehr unwahrscheinlich ist, kann eine direkte, einfache Lösung besser sein. In Android betrifft das besonders UI-Zustand, Datenquellen, Navigation, Nebenläufigkeit, Tests und Release-Qualität. Compose-Funktionen, ViewModels, Use Cases, Repositories und Datenklassen sind keine Pflichtstationen in jedem Feature. Sie sind Werkzeuge, mit denen du Verantwortlichkeiten trennst, wenn die Trennung einen echten Nutzen bringt.

Für Junior-Devs ist wichtig: SOLID ist kein Ersatz für Lesen, Debuggen und Tests. Du erkennst wartbaren Code daran, dass du eine konkrete Änderung an einer klaren Stelle vornehmen kannst, dass Fehler lokal bleiben und dass automatisierte Tests das erwartete Verhalten prüfen. Genau deshalb passt SOLID zur Android-Qualitätspraxis: Gute Architektur macht Tests leichter, und Tests zeigen dir, ob deine Architektur nur ordentlich aussieht oder tatsächlich Änderungen aushält.

Wie funktioniert es?

Pragmatisches SOLID beginnt mit Verantwortlichkeiten. Eine Klasse, Funktion oder Composable sollte nicht alles gleichzeitig tun. Ein ViewModel sollte UI-Zustand vorbereiten und Aktionen koordinieren, aber keine Retrofit-Details kennen. Ein Repository sollte Daten beschaffen und speichern, aber keine Compose-Snackbar auslösen. Ein Composable sollte Zustand anzeigen und Nutzeraktionen weitergeben, aber keine Geschäftsregel verstecken, die du eigentlich testen willst.

Single Responsibility bedeutet dabei nicht, dass jede Datei winzig sein muss. Es bedeutet: Es gibt einen klaren Grund, warum sich dieser Code ändert. Wenn eine ProfileScreen-Composable sich wegen Farben, Texten und Layout ändert, ist das normal. Wenn sie sich zusätzlich wegen Authentifizierung, Cache-Strategie und Fehler-Mapping ändert, ist sie überladen. Dann wird jede Änderung riskanter, weil mehrere Konzepte vermischt sind.

Open/Closed ist in Android oft eine Frage der Erweiterbarkeit ohne Streuschaden. Du musst nicht jede mögliche Zukunft einplanen. Aber wenn du bereits siehst, dass eine App mehrere Datenquellen, Zahlungsarten, Sortierungen oder Validierungsregeln bekommt, kann eine kleine Abstraktion sinnvoll sein. In Kotlin kann das ein Interface sein, manchmal reicht aber auch eine Funktion, ein sealed interface oder eine klar benannte Datenstruktur. Das Ziel ist nicht, Änderungen zu vermeiden. Das Ziel ist, Änderungen an einer passenden Stelle zu konzentrieren.

Liskov Substitution ist im Alltag seltener sichtbar, aber wichtig bei Interfaces und Vererbung. Wenn ein Typ als UserRepository verwendet wird, sollten alle Implementierungen denselben Vertrag erfüllen. Eine Fake-Implementierung für Tests darf nicht still andere Regeln haben als die echte Implementierung. Wenn die echte Version bei fehlendem Netzwerk einen Fehler liefert, der Fake aber immer Erfolg zurückgibt, testest du vielleicht ein Verhalten, das die App im Alltag nicht hat. Das ist keine akademische Kleinigkeit, sondern ein echtes Qualitätsproblem.

Interface Segregation erinnert dich daran, Abhängigkeiten klein zu halten. Ein ViewModel braucht vielleicht nur loadProfile() und saveName(). Es muss nicht ein riesiges Repository-Interface kennen, das zusätzlich Synchronisation, Löschung, Import und Debug-Funktionen anbietet. Kleine Schnittstellen machen Tests und Code-Reviews leichter, weil du schneller erkennst, welche Fähigkeiten eine Komponente wirklich nutzt.

Dependency Inversion ist im Android-Kontext besonders nützlich. Höhere Ebenen wie UI und Feature-Logik sollten nicht direkt von niedrigen Details wie Retrofit-Services, Room-DAOs oder konkreten Datei-APIs abhängen. Sie sollten von stabileren Verträgen abhängen. Das kann über Konstruktorparameter, Dependency Injection mit Hilt oder manuelle Übergabe passieren. Wichtig ist nicht das Tool, sondern die Richtung: Dein ViewModel sollte austauschbare Abhängigkeiten bekommen, damit du es isoliert testen und verändern kannst.

Dabei bleibt Pragmatismus zentral. Ein Interface für jede einzelne Klasse ist kein Zeichen von Qualität. Wenn es genau eine Implementierung gibt, keine Tests davon profitieren und keine realistische Austauschbarkeit geplant ist, erzeugt das Interface vielleicht nur zusätzliche Navigation im Code. Umgekehrt ist eine direkte Abhängigkeit auf einen Netzwerkservice im ViewModel häufig ein Warnsignal, weil sie Tests erschwert und UI-Logik mit Infrastruktur vermischt. Die Entscheidung hängt davon ab, ob die Abstraktion eine echte Änderung oder Prüfung erleichtert.

In der Praxis

Stell dir ein Compose-Feature vor: Du zeigst ein Benutzerprofil an, lädst die Daten aus einer API und erlaubst das Aktualisieren des Anzeigenamens. Eine zu direkte Lösung kann am Anfang verlockend sein: Das ViewModel erstellt den API-Service selbst, ruft Retrofit auf, mapped Fehlertexte und hält den UI-Zustand. Das funktioniert, bis du einen Test schreiben, eine lokale Cache-Schicht ergänzen oder Fehlermeldungen vereinheitlichen möchtest.

Eine pragmatischere Struktur trennt die Teile, die verschiedene Änderungsgründe haben:

data class ProfileUiState(
    val name: String = "",
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

interface ProfileRepository {
    suspend fun loadProfile(): Result<UserProfile>
    suspend fun updateName(name: String): Result<UserProfile>
}

class ProfileViewModel(
    private val repository: ProfileRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(ProfileUiState(isLoading = true))
    val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()

    fun load() {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)

            repository.loadProfile()
                .onSuccess { profile ->
                    _uiState.value = ProfileUiState(name = profile.name)
                }
                .onFailure {
                    _uiState.value = ProfileUiState(
                        isLoading = false,
                        errorMessage = "Profil konnte nicht geladen werden."
                    )
                }
        }
    }

    fun saveName(name: String) {
        viewModelScope.launch {
            repository.updateName(name)
                .onSuccess { profile ->
                    _uiState.value = _uiState.value.copy(name = profile.name)
                }
                .onFailure {
                    _uiState.value = _uiState.value.copy(
                        errorMessage = "Name konnte nicht gespeichert werden."
                    )
                }
        }
    }
}

Diese Lösung ist nicht maximal abstrakt, aber sie ist änderungsfreundlich. Das ViewModel kennt nur den Vertrag ProfileRepository. Es weiß nicht, ob die Daten aus Retrofit, Room, DataStore oder einem Fake im Test kommen. Das Repository kann später Cache-Logik erhalten, ohne dass die Compose-Oberfläche angepasst werden muss. Gleichzeitig ist das Interface klein und auf das Feature zugeschnitten. Du hast also eine Abhängigkeit gedreht, ohne das Projekt mit unnötigen Schichten zu füllen.

Ein Test kann jetzt das ViewModel mit einer Fake-Implementierung prüfen. Genau hier verbindet sich SOLID mit Android-Testing: Du testest nicht, ob das Interface schön aussieht, sondern ob das Verhalten bei Erfolg und Fehler korrekt ist.

class FakeProfileRepository : ProfileRepository {
    var result: Result<UserProfile> = Result.success(UserProfile(name = "Mina"))

    override suspend fun loadProfile(): Result<UserProfile> = result

    override suspend fun updateName(name: String): Result<UserProfile> =
        Result.success(UserProfile(name = name))
}

Eine praktische Entscheidungsregel: Ziehe eine Abstraktion ein, wenn du mindestens einen konkreten Nutzen benennen kannst. Beispiele sind isolierte Tests, Austausch einer Datenquelle, klare Trennung von UI und Infrastruktur oder Reduktion von Änderungsrisiko. Wenn du den Nutzen nur mit „SOLID verlangt das“ begründest, ist die Abstraktion wahrscheinlich zu früh.

Eine typische Stolperfalle ist „Interface-Theater“. Dabei bekommt jede Klasse ein Interface, obwohl die App dadurch nicht testbarer, verständlicher oder flexibler wird. Du erkennst das an Dateien wie UserManager, UserManagerImpl, UserManagerFactory und UserManagerProvider, obwohl es nur eine einfache Operation gibt. Der Code wirkt professionell, aber jede Änderung braucht mehr Klicks und mehr Kontext. Für Lernende ist das besonders gefährlich, weil die Menge an Architektur mit Qualität verwechselt wird.

Die andere Stolperfalle ist das Gegenteil: Alles bleibt direkt gekoppelt, weil es am Anfang schneller geht. Dann ruft ein Composable eine Repository-Methode direkt auf, das Repository kennt Android-Context an zu vielen Stellen, und Fehlertexte liegen verstreut in Netzwerk-Mapping, UI und Tests. Sobald du eine Qualitätsprüfung, einen instrumentierten Test oder eine CI-Pipeline einführst, fallen diese Kopplungen auf. Android-Qualität entsteht nicht nur durch stabile Screens, sondern auch durch Code, der zuverlässig geprüft und verändert werden kann.

In Code-Reviews kannst du SOLID pragmatisch mit wenigen Fragen prüfen. Welche Änderung wäre an diesem Feature wahrscheinlich? Wo müsste ich dafür Code anfassen? Kann ich das Verhalten ohne echtes Netzwerk testen? Kennt diese Komponente Details, die sie nicht kennen sollte? Ist die Abstraktion klein genug, dass ihr Vertrag klar bleibt? Diese Fragen sind oft nützlicher als eine lange Diskussion darüber, ob ein Prinzip formal perfekt erfüllt ist.

Auch Compose verändert diese Regeln nicht grundsätzlich. Composables sollten möglichst zustandsgetrieben sein: Sie bekommen uiState und Callbacks, zeigen Oberfläche und melden Nutzeraktionen zurück. Die Entscheidung, wie ein Profil geladen wird, gehört nicht in die Composable. Das ist keine starre Schichtenlehre, sondern schützt dich vor schwer testbarer UI und vor Seiteneffekten, die beim Recomposition-Verhalten verwirren können.

Bei Coroutines und Flow gilt dasselbe. Ein Flow<ProfileUiState> kann eine saubere Grenze sein, wenn der UI-Zustand laufend beobachtet wird. Aber du solltest nicht jede einfache Einmal-Aktion in mehrere Flow-Schichten zerlegen, nur weil es moderner aussieht. Prüfe immer, ob die Struktur die Änderung vereinfacht. Wenn eine Suspend-Funktion den Fall klar beschreibt, ist sie oft die bessere Wahl.

Für CI ist SOLID-Pragmatismus ebenfalls relevant. Eine Pipeline mit Unit-Tests, UI-Tests oder statischen Checks bringt wenig, wenn dein Code nur mit echtem Netzwerk, echter Datenbank und vollständiger App-Navigation prüfbar ist. Kleine, klare Abhängigkeiten ermöglichen schnelle Tests. Schnelle Tests werden häufiger ausgeführt. Häufig ausgeführte Tests geben dir früher Rückmeldung, bevor ein Refactoring zur Überraschung wird.

Fazit

SOLID pragmatisch anzuwenden bedeutet, deinen Android-Code auf Veränderung vorzubereiten, ohne Architektur zum Selbstzweck zu machen. Du trennst Verantwortlichkeiten, hältst Abhängigkeiten bewusst klein und nutzt Schnittstellen dort, wo sie Tests, Austauschbarkeit oder Verständlichkeit verbessern. Prüfe das Gelernte an einem bestehenden Feature: Verschiebe eine direkte Netzwerkabhängigkeit hinter einen kleinen Vertrag, schreibe einen Fake für einen ViewModel-Test und bitte im Code-Review gezielt um Feedback zu Änderungsgründen und Kopplungen. So lernst du, SOLID nicht auswendig zu zitieren, sondern in wartbare Android-Praxis zu übersetzen.

Quellen (3)
Redaktion

Geschrieben von

Redaktion

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