All posts
architecturemobileexpoclean-architecture

How we build mobile apps: a Clean Architecture playbook for Expo

preunec teamJuly 13, 20266 min read
Share

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, or expo-*.
  • 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/ importing react, react-native, or expo-*
  • presentation/ calling HTTP or storage directly
  • features/A importing the internals of features/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.