Alle Beiträge
architecturemobileexpoclean-architecture

Wie wir mobile Apps bauen: ein Clean-Architecture-Playbook für Expo

preunec team13. Juli 20266 Min. Lesezeit
Teilen

Die meisten mobilen Codebasen verrotten auf dieselbe Weise: Geschäftslogik sickert in Screens, ein Screen greift direkt auf die Netzwerkschicht zu, und zwei Entwickler können die App nicht in derselben Woche anfassen, ohne einen Merge-Krieg auszulösen. Das ist das Playbook, mit dem wir das vermeiden — Clean Architecture, Feature-Module und manuelles Dependency Injection, abgestimmt auf Expo + TypeScript.

Kurzfassung — Vier Schichten mit einer einseitigen Abhängigkeitsregel: die Domain ist framework-agnostisch, und alles zeigt nach innen auf sie. Features sind in sich geschlossene Module. Abhängigkeiten werden von Hand in einem Composition Root verdrahtet — keine magischen Container. Das Ergebnis ist parallel-freundlich, testbar und günstig zu ändern.

Warum überhaupt ein Architektur-Playbook

Wir hatten fünf Ziele, und jede Regel unten dient einem davon:

Ziel Was es uns bringt
Parallele Entwicklung zwei Entwickler, wenige Merge-Konflikte
Wartbare Grenzen UI-Änderungen schreiben keine Geschäftslogik neu
Austauschbare Infra REST ↔ Firebase mit kleinem Radius tauschen
Testbarkeit Geschäftsregeln ohne Simulator testbar
Schnelle Iteration explizites, einfaches DI — keine schweren Frameworks

Explizite Nicht-Ziele halten uns ehrlich: keine reflexionsbasierten DI-Container, keine Feature-übergreifende Kopplung und kein „globaler State für alles".

Die vier Schichten und die eine Regel, die zählt

Das Ganze sind vier Schichten plus ein Composition Root, der sie verdrahtet. Die Abhängigkeitsregel ist die tragende Idee: Abhängigkeiten zeigen immer nur nach innen, auf die Domain.

flowchart LR
  Composition["Composition Root (DI wiring)"]
  Presentation["Presentation"]
  Domain["Domain (framework-agnostic)"]
  Data["Data"]
  Core["Core / Infrastructure"]
  Presentation --> Domain
  Data --> Domain
  Data --> Core
  Composition --> Presentation
  Composition --> Data
  Composition --> Core
  • Presentation — Screens, Komponenten, View-Model-Hooks.
  • Domain — Entitäten, Value Objects, Use Cases und Repository-Ports (Interfaces). Diese Schicht importiert nie react, react-native oder expo-*.
  • Data — Repository-Implementierungen, Remote-/Local-Datasources, DTO-Mapper.
  • Core — http, storage, config, logging, errors: die querschnittliche Infra.

Drei Dinge sind schlicht verboten, und eine Lint-Regel erzwingt sie:

  • domain/ importiert react, react-native oder expo-*
  • presentation/ ruft HTTP oder Storage direkt auf
  • features/A importiert die Interna von features/B

Was passiert, wenn die UI Daten braucht

Eine einzelne Anfrage nimmt einen vorhersehbaren Weg. Presentation spricht nie mit einer Datasource; sie fragt einen Use Case, der Use Case fragt einen Port, und der Port ist — beim Start — an ein konkretes Repository gebunden, das die I/O erledigt und das DTO in eine Domain-Entität mappt.

sequenceDiagram
  participant UI as Screen (Presentation)
  participant UC as Use Case (Domain)
  participant Port as Repository Port (Domain)
  participant Repo as Repository Impl (Data)
  participant DS as Datasource
  UI->>UC: call(input)
  UC->>Port: request(params)
  Port->>Repo: bound via DI
  Repo->>DS: fetch DTO
  DS-->>Repo: DTO
  Repo-->>UC: Domain entity (mapped)
  UC-->>UI: result

Weil der Port ein Interface ist, kann die Datasource dahinter von REST zu Firebase zu einem Offline-Cache wechseln, ohne dass Domain oder Presentation es merken.

Eine Ordnerstruktur, die zwei Entwickler teilen können

Der Baum spiegelt die Schichten exakt wider, sodass die Zuständigkeit aus dem Pfad ersichtlich ist:

src/
  app/                 # expo-router routes
  composition/         # DI wiring only  →  container.ts, modules/*.module.ts
  core/                # http · storage · logging · errors · config
  shared/              # UI kit + generic utils + theme
  features/
    auth/
      presentation/    # screens · components · hooks
      domain/          # entities · ports · usecases
      data/            # datasources · repositories · mappers
    profile/           # ...gleiches Muster...

Jedes neue Feature wird aus derselben Vorlage gestanzt — das ist der eigentliche Mechanismus, der die Codebasis skalierbar hält:

flowchart TD
  X["features/x"] --> D["domain"]
  X --> DT["data"]
  X --> P["presentation"]
  D --> D1["ports"]
  D --> D2["usecases"]
  D --> D3["entities"]
  DT --> T1["datasources"]
  DT --> T2["repositories"]
  DT --> T3["mappers"]
  P --> P1["screens"]
  P --> P2["components"]
  P --> P3["hooks"]

Dev A kann features/auth/** bauen, während Dev B features/profile/** baut, und der einzige gemeinsame Boden — core/, composition/, shared/ — ist klein und bewusst kontrolliert.

Manuelles DI, keine Magie

Abhängigkeiten werden von Hand verdrahtet. composition/container.ts baut den Objektgraphen einmal beim Start und stellt ihn über einen einzigen useDeps()-Hook bereit. Composition verbindet nur Objekte — sie enthält keine Geschäftslogik — und Features exportieren Use Cases, nie rohe Repositories.

// composition/container.ts — einmal gebaut, über Context bereitgestellt.
export function buildContainer() {
  const http = new HttpClient(config.apiBaseUrl);
  const logger = new Logger();

  const authRepo = new AuthRepositoryImpl(new AuthRemoteDatasource(http));
  return {
    auth: { signIn: makeSignInUseCase(authRepo, logger) },
  };
}

Keine Reflexion, keine Decorators, keine Container-Bibliothek — nur Funktionen, die Objekte zurückgeben. Es ist langweilig, und langweilig ist der Punkt.

Wo State wohnt

State-Bugs entstehen, wenn State am falschen Ort lebt. Die Entscheidung ist mechanisch:

flowchart TD
  Q{"What kind of state?"}
  Q -->|Server-owned| RQ["React Query"]
  Q -->|UI / ephemeral| Z["Zustand"]
  Q -->|Persisted / offline| S["Storage datasource + repository"]
  Q -->|Secrets| SS["Secure store only"]
State Zuhause Regel
Server-eigen React Query Antworten nicht in globale Stores kopieren
UI / flüchtig Zustand Toggles, Wizards, transiente Abläufe
Persistiert / offline Storage-Repository als Datasource, nicht ad hoc
Secrets Secure Store nie MMKV, nie Logs

Fehler als erstklassige Domain-Konzepte

Rohe Netzwerkfehler erreichen nie die UI. Sie werden einmal, in core/errors, in typisierte Domain-Fehler normalisiert, die Bedeutung tragen:

Schicht Form Beispiel
Domain typisiert, bedeutungsgetrieben InvalidCredentials
Data rohe Fehler normalisiert HTTP 401 → InvalidCredentials
Presentation freundliche Meldung + Retry „Falsche E-Mail oder Passwort"

Die Regel: nie alert(error.message) direkt aus einem HTTP-Fehler.

Zwei Entwickler, wenige Konflikte

Die Zuständigkeit rotiert jeden Sprint, um Wissenssilos zu vermeiden, und PRs bleiben schmal — ein Feature-Modul pro PR. Ein PR, der core/ und mehrere Features berührt, ist ein Signal zum Aufteilen. Jeder PR erfüllt diese Checkliste:

  • Keine domain/ → React/Expo-Imports
  • UI ruft HTTP/Storage nicht direkt auf
  • Ein Feature importiert nicht die Interna eines anderen
  • DI-Verdrahtung nur in composition/
  • Tests für Use Case + Mapper
  • Keine Secrets oder Tokens in Logs

Die ersten fünf Tage

Der Start ist bewusst so sequenziert, dass die Grenzen vor den Features existieren:

timeline
  title First five working days
  Day 1 Foundation : Repo and TS strict : core http client : DI container
  Day 2 App shell : expo-router groups : theme baseline : error boundary
  Day 3 Auth slice : port and use case : datasource and mapper : screen and hook
  Day 4 Profile slice : React Query caching policy
  Day 5 Hardening : error normalization : CI boundary lint rules

Baue Container und DI-Provider zuerst — das verhindert „Singleton-Wildwuchs" — und beweise dann die Schleife mit einer vertikalen Scheibe, bevor du ausskalierst.

Was der KI-Agent darf (und nicht darf)

Wir lassen einen Agenten die langweiligen Teile beschleunigen — Features gerüstet, Repositories aus einem Contract implementiert, Tests geschrieben, PRs auf Grenzverletzungen geprüft. Aber er arbeitet unter harten Auflagen: Grenzregeln wahren, Feature-übergreifende Kopplung vermeiden, minimale Diffs bevorzugen und nie einen API-Contract erfinden, ohne die Annahme laut auszusprechen.

Fazit

  • Die Abhängigkeitsregel ist das ganze Spiel: alles nach innen auf eine framework-freie Domain richten.
  • Feature-Module + manuelles DI machen Parallelarbeit günstig und Infra-Wechsel lokal.
  • State an den richtigen Ort, Fehler einmal normalisieren, PRs auf ein Modul.

Architektur sind nicht die Diagramme — es ist die Menge an Änderungen, die sie günstig machen. Diese macht „Backend tauschen" und „Feature ohne Merge-Krieg hinzufügen" beide langweilig. Genau das ist der Punkt.