How we build mobile apps: a Clean Architecture playbook for Expo
Most mobile codebases rot the same way: business logic leaks into screens, a screen reaches straight into the network layer, and two developers can't touch the app in the same week without a merge war. This is the playbook we use to avoid that — Clean Architecture, feature modules, and manual dependency injection, tuned for Expo + TypeScript.
TL;DR — Four layers with a one-way dependency rule: the Domain is framework-agnostic and everything points inward toward it. Features are self-contained modules. Dependencies are wired by hand in a composition root — no magic containers. The result is parallel-friendly, testable, and cheap to change.
Why an architecture playbook at all
We had five goals, and every rule below serves one of them:
| Goal | What it buys us |
|---|---|
| Parallel development | two developers, few merge conflicts |
| Maintainable boundaries | UI changes don't rewrite business logic |
| Replaceable infra | swap REST ↔ Firebase with a small blast radius |
| Testability | business rules tested without a simulator |
| Fast iteration | explicit, simple DI — no heavyweight frameworks |
Explicit non-goals keep us honest: no reflection-based DI containers, no cross-feature coupling, and no "global state for everything."
The four layers and the one rule that matters
The whole thing is four layers plus a composition root that wires them together. The dependency rule is the load-bearing idea: dependencies only ever point inward, toward the 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, components, view-model hooks.
- Domain — entities, value objects, use cases, and repository ports
(interfaces). This layer never imports
react,react-native, orexpo-*. - Data — repository implementations, remote/local datasources, DTO mappers.
- Core — http, storage, config, logging, errors: the cross-cutting infra.
Three things are simply forbidden, and a lint rule enforces them:
domain/importingreact,react-native, orexpo-*presentation/calling HTTP or storage directlyfeatures/Aimporting the internals offeatures/B
What happens when the UI needs data
A single request walks a predictable path. Presentation never talks to a datasource; it asks a use case, the use case asks a port, and the port is bound — at startup — to a concrete repository that does the I/O and maps the DTO into a domain entity.
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
Because the port is an interface, the datasource behind it can change from REST to Firebase to an offline cache without the Domain or Presentation noticing.
A folder structure two developers can share
The tree mirrors the layers exactly, so ownership is obvious from the path:
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/ # ...same pattern...
Every new feature is stamped from the same template, which is the real mechanism that keeps the codebase scalable:
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 can build features/auth/** while Dev B builds features/profile/**,
and the only shared ground — core/, composition/, shared/ — is small and
deliberately controlled.
Manual DI, no magic
Dependencies are wired by hand. composition/container.ts builds the
object graph once at startup and exposes it through a single useDeps() hook.
Composition only connects objects — it holds no business logic — and features
export use cases, never raw repositories.
// composition/container.ts — built once, provided via context.
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) },
};
}
No reflection, no decorators, no container library — just functions returning objects. It's boring, and boring is the point.
Choosing where state lives
State bugs come from state living in the wrong place. The decision is mechanical:
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 | Home | Rule |
|---|---|---|
| Server-owned | React Query | don't copy responses into global stores |
| UI / ephemeral | Zustand | toggles, wizards, transient flows |
| Persisted / offline | storage repository | expressed as a datasource, not ad-hoc |
| Secrets | secure store | never MMKV, never logs |
Errors as first-class domain concepts
Raw network errors never reach the UI. They're normalized once, in
core/errors, into typed domain errors that carry meaning:
| Layer | Shape | Example |
|---|---|---|
| Domain | typed, meaning-driven | InvalidCredentials |
| Data | raw errors normalized | HTTP 401 → InvalidCredentials |
| Presentation | friendly message + retry | "Wrong email or password" |
The rule: no alert(error.message) straight from an HTTP failure, ever.
Two developers, few conflicts
Ownership rotates each sprint to avoid knowledge silos, and PRs stay narrow —
one feature module at a time. A PR that touches core/ and several features
is a signal to split. Every PR clears this checklist:
- No
domain/→ React/Expo imports - UI does not call HTTP/storage directly
- A feature doesn't import another feature's internals
- DI wiring only in
composition/ - Tests for the use case + mapper
- No secrets or tokens in logs
The first five days
The kickoff is deliberately sequenced so the boundaries exist before the features do:
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
Build the container and DI provider first — it's what prevents "singleton sprawl" — then prove the loop with one vertical slice before scaling out.
What the AI agent may (and may not) do
We let an agent accelerate the boring parts — scaffolding features, implementing repositories from a contract, writing tests, reviewing PRs for boundary violations. But it works under hard constraints: preserve the boundary rules, avoid cross-feature coupling, prefer minimal diffs, and never invent an API contract without stating the assumption out loud.
Takeaways
- The dependency rule is the whole game: point everything inward at a framework-free Domain.
- Feature modules + manual DI make parallel work cheap and infra swaps local.
- Put state where it belongs, normalize errors once, and keep PRs to one module.
Architecture isn't the diagrams — it's the set of changes they make cheap. This one makes "swap the backend" and "add a feature without a merge war" both boring. That's the point.
More posts
Devlog: rebuilding preunec.de
We rebuilt preunec.de from the ground up. Here's what changed for you as a visitor — and a friendly peek under the hood at how it works.
ReadTurning a Website into Modules You Can Switch Off
How this site's sections became runtime-toggleable modules — and why that's worth the plumbing.
ReadWhat Makes a Good EU Project Dissemination Site
Lessons from building multilingual dissemination platforms for Erasmus+ consortia.
Read