Wie wir mobile Apps bauen: ein Clean-Architecture-Playbook für Expo
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-nativeoderexpo-*. - 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/importiertreact,react-nativeoderexpo-*presentation/ruft HTTP oder Storage direkt auffeatures/Aimportiert die Interna vonfeatures/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.
Weitere Beiträge
Devlog: preunec.de neu gebaut
Wir haben preunec.de von Grund auf neu gebaut. Hier steht, was sich für Sie als Besucherin ändert — und ein verständlicher Blick hinter die Kulissen, wie es funktioniert.
WeiterlesenEine Website in abschaltbare Module zerlegen
Wie die Bereiche dieser Website zu zur Laufzeit schaltbaren Modulen wurden — und warum sich der Aufwand lohnt.
WeiterlesenWas eine gute Dissemination-Website für EU-Projekte ausmacht
Erkenntnisse aus dem Bau mehrsprachiger Dissemination-Plattformen für Erasmus+-Konsortien.
Weiterlesen