Skip to content
My Guidelines
Guides
In this guide (25 items)

Mobile Architecture (React Native)

Mobile Architecture — Hexagonal in React Native

BINDING RULES FOR ALL AI AGENTS: The layer placement rules below are MANDATORY. domain/ and application/ MUST NOT import React, React Native, Expo, or any I/O library. Every outbound dependency (storage, HTTP, push, clock, randomness) MUST go through a port. Violations of dependency direction are NEVER acceptable. Cross-cutting concerns (caching, retry, analytics, logging) MUST be implemented as decorator adapters, not sprinkled into use cases.

Decisions: every architecturally significant choice (port shape, adapter technology, state management library, navigation strategy, offline/sync topology, module boundary change) MUST be captured as an ADR per adr.md before merging. Default template = MADR. Default location = docs/adr/.

Project-agnostic: this guide describes patterns, not a specific app. Samples use a placeholder Expense domain. Replace with your actual domain.

Companion commands: /mobile-init-project (scaffold), /mobile-scaffold-feature (full vertical slice), /mobile-add-usecase, /mobile-add-adapter (port + adapter + boundary parsing).

This is the mobile counterpart of hexagonal-architecture.md. The rules are the same; only the language and the adapter technologies change. For the JVM equivalents of these patterns see hexagonal-architecture.md; for TypeScript conventions see typescript-code-style.md.


1. When to use this architecture

Hexagonal is not free. Apply the decision matrix before adopting it.

App profileArchitecture
Real business rules, offline-first, calculations, sync, money, schedulingHexagonal (this guide)
Thin CRUD over an API, content/showcase app, ≤10 screensFeature-Sliced Design — see §12
Mobile + web sharing logic, or ≥2 teamsHexagonal inside a Turborepo monorepo — see §11

The deciding question is: does this app own rules that would still be true if the UI disappeared? If yes, those rules deserve a domain/ that never imports React.

Record the answer as an ADR. Do not re-litigate it per feature.


2. Layer rules (MEMORIZE)

LayerMay importMUST NOT import
domainNothing but other domain filesReact, React Native, Expo, fetch, storage, any npm runtime dep
applicationdomain onlyReact, React Native, Expo, any concrete I/O
infrastructureapplication, domainui
uiapplication, domain, sharedinfrastructure (except the DI container entry)
sharedNothing app-specificAny of the above

Dependencies always point inward: ui → application → domain. Adapters in infrastructure point inward too — they implement interfaces that application owns.

Zod is the one allowed exception in domain: schemas that express domain invariants may live there. Everything else parses at the boundary (§7).


3. Folder layout

src/
├── domain/ Pure TypeScript. Zero framework.
│ ├── expense/
│ │ ├── Expense.ts entity / value objects
│ │ ├── Money.ts value object with invariants
│ │ └── ExpenseError.ts domain error union
│ └── shared/
│ ├── Result.ts Result<T, E>
│ └── brand.ts branded primitive types
│
├── application/ Use cases + ports. Zero React.
│ ├── expense/
│ │ ├── createExpense.ts use case
│ │ ├── listExpenses.ts
│ │ └── port/
│ │ ├── ExpenseRepository.ts outbound port
│ │ └── ExpenseApi.ts
│ └── port/
│ ├── Clock.ts inbound-of-time; never call Date.now() directly
│ ├── IdGenerator.ts
│ ├── Analytics.ts
│ └── Logger.ts
│
├── infrastructure/ Adapters. Libraries live here and ONLY here.
│ ├── storage/
│ │ └── MmkvExpenseRepository.ts
│ ├── http/
│ │ ├── client.ts
│ │ └── HttpExpenseApi.ts
│ ├── secure/
│ │ └── SecureStoreTokenStore.ts
│ ├── decorator/
│ │ ├── CachedExpenseRepository.ts
│ │ └── LoggedExpenseRepository.ts
│ └── di/
│ └── container.ts the ONLY place that wires concretes
│
├── ui/ React Native lives here and ONLY here.
│ ├── feature/
│ │ └── expense/
│ │ ├── ExpenseListScreen.tsx
│ │ ├── ExpenseListScreen.test.tsx
│ │ └── component/
│ ├── hook/
│ │ └── useCreateExpense.ts bridges React ↔ use case
│ ├── design/ tokens + primitives — see mobile-ui.md
│ └── navigation/
│
└── app/ Expo Router route files. Thin. Re-export screens.

Feature-first inside each layer, not layer-first inside each feature. A developer touching “expense” opens four folders, but each one has a single, obvious responsibility. The alternative — components/, hooks/, utils/, services/ — is the anti-pattern this architecture exists to prevent.

Co-locate tests next to the file under test. Expense.ts and Expense.test.ts are siblings.


4. Domain layer

Pure TypeScript. If a file in domain/ needs a mock to be tested, it is in the wrong layer.

Entities and value objects

Immutable. Construct through a factory that validates, never through a bare object literal.

domain/expense/Money.ts
import type { Result } from '../shared/Result'
import { ok, err } from '../shared/Result'
const SCALE = 2
export type Money = Readonly<{
readonly _tag: 'Money'
amountMinor: number // integer minor units. NEVER a float.
currency: Currency
}>
export type MoneyError = { kind: 'NotAnInteger' } | { kind: 'Negative' }
export function money(amountMinor: number, currency: Currency): Result<Money, MoneyError> {
if (!Number.isInteger(amountMinor)) return err({ kind: 'NotAnInteger' })
if (amountMinor < 0) return err({ kind: 'Negative' })
return ok({ _tag: 'Money', amountMinor, currency })
}
export function add(a: Money, b: Money): Result<Money, CurrencyMismatch> {
if (a.currency !== b.currency) return err({ kind: 'CurrencyMismatch', a: a.currency, b: b.currency })
return money(a.amountMinor + b.amountMinor, a.currency)
}

Never represent money as a float. 0.1 + 0.2 !== 0.3 in IEEE 754. Store minor units as integers, or use a decimal library — never number for a decimal amount.

Branded types over primitives

domain/shared/brand.ts
declare const brand: unique symbol
export type Brand<T, B extends string> = T & { readonly [brand]: B }
// domain/expense/Expense.ts
export type ExpenseId = Brand<string, 'ExpenseId'>
export type UserId = Brand<string, 'UserId'>

Now createExpense(userId, expenseId) fails to compile if the arguments are swapped. With bare string it silently ships.

Domain errors are values, not exceptions

domain/expense/ExpenseError.ts
export type ExpenseError =
| { kind: 'AmountTooLarge'; max: number }
| { kind: 'CategoryUnknown'; category: string }
| { kind: 'DateInFuture'; date: string }

A discriminated union forces the caller to handle every case, and switch exhaustiveness is checked at compile time. A throw inside a hook is invisible until production. See typescript-code-style.md §Errors.

Forbidden in domain/

  • ❌ import ... from 'react' / 'react-native' / 'expo-*'
  • ❌ Date.now(), new Date(), Math.random() — inject Clock and IdGenerator instead. Non-determinism makes tests flaky and time-zone bugs invisible.
  • ❌ fetch, AsyncStorage, MMKV, any I/O
  • ❌ console.log — inject Logger
  • ❌ throw for expected business outcomes

5. Application layer

Use cases

A use case is a plain function. It takes its dependencies as an argument, orchestrates the domain, and returns a Result. No classes needed, no decorators, no annotations.

application/expense/createExpense.ts
import type { Result } from '../../domain/shared/Result'
import type { Expense, ExpenseError } from '../../domain/expense/Expense'
import { newExpense } from '../../domain/expense/Expense'
import type { ExpenseRepository } from './port/ExpenseRepository'
import type { Clock } from '../port/Clock'
import type { IdGenerator } from '../port/IdGenerator'
export type CreateExpenseCommand = Readonly<{
amountMinor: number
currency: Currency
category: string
note?: string
}>
export type CreateExpenseDeps = Readonly<{
repository: ExpenseRepository
clock: Clock
ids: IdGenerator
}>
export function createExpense(deps: CreateExpenseDeps) {
return async (command: CreateExpenseCommand): Promise<Result<Expense, ExpenseError>> => {
const expense = newExpense({
id: deps.ids.next(),
occurredAt: deps.clock.now(),
...command,
})
if (!expense.ok) return expense
return deps.repository.save(expense.value)
}
}

Rules:

  • Max 3 parameters — use a command object (see coding-style rules). deps and command is two.
  • One use case per file. The file name is the use case name.
  • No React. No useState. No hooks. A use case must be callable from a background task or a test with no renderer.
  • No try/catch around business outcomes — return Result.

Ports

Ports are interfaces owned by application/, implemented by infrastructure/.

application/expense/port/ExpenseRepository.ts
export interface ExpenseRepository {
save(expense: Expense): Promise<Result<Expense, PersistenceError>>
findById(id: ExpenseId): Promise<Expense | null>
findByPeriod(period: Period): Promise<readonly Expense[]>
}

Naming:

  • Outbound ports get the role name: ExpenseRepository, ExpenseApi, TokenStore, PushNotifier.
  • Never name a port after its technology. MmkvExpenseRepository is an adapter; ExpenseRepository is the port. If the port name contains Mmkv, Http, Sqlite or Firebase, the abstraction has already leaked.

Always port these, even though they look trivial:

PortWhy
ClockDeterministic tests, correct time zones
IdGeneratorDeterministic tests
LoggerNo console.log in domain; structured logging in release
AnalyticsSwap vendor without touching use cases
ConnectivityOffline logic is a business decision, not a UI one

6. Infrastructure layer

Adapters implement ports. This is the only layer allowed to import libraries.

infrastructure/storage/MmkvExpenseRepository.ts
import type { MMKV } from 'react-native-mmkv'
import type { ExpenseRepository } from '../../application/expense/port/ExpenseRepository'
import { expenseSchema } from './schema'
// MMKV v4: the class is a type; instances come from createMMKV(). v3's `new MMKV()`
// and `.delete()` are gone — the methods are `remove()` and `clearAll()`.
export function mmkvExpenseRepository(storage: MMKV): ExpenseRepository {
return {
async save(expense) { /* ... */ },
async findById(id) {
const raw = storage.getString(`expense:${id}`)
if (!raw) return null
const parsed = expenseSchema.safeParse(JSON.parse(raw))
return parsed.success ? parsed.data : null // corrupt row → treat as missing
},
async findByPeriod(period) { /* ... */ },
}
}

Persisted data is untrusted input. It was written by an older version of your app, possibly with a different shape. Parse it on read (§7). A schema migration you forgot becomes a crash loop that only a reinstall fixes — and users uninstall instead.

Cross-cutting concerns are decorators

Never put caching, retry, logging or metrics inside a use case. Wrap the port.

infrastructure/decorator/CachedExpenseRepository.ts
export function cached(inner: ExpenseRepository, cache: Cache): ExpenseRepository {
return {
...inner,
async findById(id) {
const hit = cache.get(id)
if (hit) return hit
const found = await inner.findById(id)
if (found) cache.set(id, found)
return found
},
}
}

Compose in the container: cached(logged(mmkvExpenseRepository(storage)), cache). Same Chain of Responsibility rule as hexagonal-architecture.md.

The DI container

One file. Plain functions, no DI framework.

infrastructure/di/container.ts
export function buildContainer(env: Env) {
const storage = createMMKV({ id: 'app' })
const clock = systemClock()
const ids = uuidGenerator()
const logger = env.isProduction ? sentryLogger() : consoleLogger()
const expenseRepository = cached(logged(mmkvExpenseRepository(storage), logger), memoryCache())
return {
createExpense: createExpense({ repository: expenseRepository, clock, ids }),
listExpenses: listExpenses({ repository: expenseRepository }),
} as const
}
export type Container = ReturnType<typeof buildContainer>

Exposed to React through a single Context at the root:

ui/di/ContainerProvider.tsx
const ContainerContext = createContext<Container | null>(null)
export function useContainer(): Container {
const c = useContext(ContainerContext)
if (!c) throw new Error('useContainer must be used inside ContainerProvider')
return c
}

This is the only place ui touches infrastructure. Tests swap the whole container for fakes in one line.


7. The boundary: parse, don’t validate

Everything crossing into the app is untrusted, regardless of what its TypeScript type claims. TypeScript does not exist at runtime.

BoundaryThreat
HTTP responseAPI changed, or an attacker-controlled proxy
Persisted storageWritten by an older app version
Deep link / universal linkFully attacker-controlled — any app can fire one
Push notification payloadServer-controlled, still validate
Clipboard, QR scan, file importUser- or attacker-supplied

Parse with Zod at the adapter, return domain types inward:

infrastructure/http/HttpExpenseApi.ts
const responseSchema = z.object({
id: z.string().uuid(),
amount_minor: z.number().int(),
currency: z.enum(['COP', 'USD', 'EUR']),
})
export function httpExpenseApi(client: HttpClient): ExpenseApi {
return {
async fetchById(id) {
const raw = await client.get(`/expenses/${id}`)
const parsed = responseSchema.safeParse(raw)
if (!parsed.success) return err({ kind: 'ContractViolation', issues: parsed.error.issues })
return ok(toDomain(parsed.data))
},
}
}

Never let a wire-format type (amount_minor, snake_case) leak past the adapter. Map to domain types at the edge. Same rule as the three-layer mapping chain in ports-and-commands.md.


8. UI layer

React Native lives only here. The UI’s job is rendering and gesture handling — nothing else.

Hooks bridge React and use cases

ui/hook/useCreateExpense.ts
export function useCreateExpense() {
const { createExpense } = useContainer()
const queryClient = useQueryClient()
return useMutation({
mutationFn: createExpense,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['expenses'] }),
})
}

The hook adapts; it does not decide. If you find business logic in a hook, it belongs in a use case.

Screens

export function ExpenseListScreen() {
const { data, isLoading, error } = useExpenses()
if (isLoading) return <ScreenSkeleton />
if (error) return <ErrorState error={error} onRetry={...} />
if (data.length === 0) return <EmptyState />
return <FlashList data={data} renderItem={...} />
}

Every screen handles four states: loading, empty, error, content. A screen that only renders the happy path is incomplete, not “to be done later”. See mobile-ui.md.

Forbidden in ui/

  • ❌ fetch or storage calls directly in a component
  • ❌ Business rules in a useEffect
  • ❌ Importing from infrastructure/ (except the container provider)
  • ❌ Server state in Zustand — see react-native.md §State

9. Enforcing the boundaries

Discipline does not survive deadlines. Enforce mechanically — this is the RN equivalent of ArchUnit.

eslint-plugin-boundaries or import/no-restricted-paths:

// .eslintrc.json (excerpt)
{
"rules": {
"import/no-restricted-paths": ["error", {
"zones": [
{ "target": "./src/domain", "from": "./src/application" },
{ "target": "./src/domain", "from": "./src/infrastructure" },
{ "target": "./src/domain", "from": "./src/ui" },
{ "target": "./src/application", "from": "./src/infrastructure" },
{ "target": "./src/application", "from": "./src/ui" },
{ "target": "./src/ui", "from": "./src/infrastructure",
"except": ["./src/infrastructure/di"] }
]
}],
"no-restricted-imports": ["error", {
"patterns": [
{ "group": ["react", "react-native", "expo*"],
"message": "Framework imports are banned in domain/ and application/." }
]
}]
},
"overrides": [
{ "files": ["src/domain/**", "src/application/**"],
"rules": { "no-restricted-imports": "error" } }
]
}

Wire it into CI. A boundary violation must fail the build, not produce a warning nobody reads.


10. Offline and sync

Offline is a domain concern, not a networking detail. Decide and record it as an ADR.

StrategyWhen
Online-onlyData is worthless stale (live prices, availability). Show an explicit offline state.
Cache-then-networkReads tolerate staleness. TanStack Query handles it with staleTime.
Offline-first with outboxWrites must survive no connectivity.

The outbox is the same pattern as the backend one in messaging.md, scaled down: a local queue of pending mutations, drained when connectivity returns.

application/sync/port/MutationOutbox.ts
export interface MutationOutbox {
enqueue(mutation: PendingMutation): Promise<void>
pending(): Promise<readonly PendingMutation[]>
markSynced(id: MutationId): Promise<void>
}

Non-negotiables when you adopt it:

  • Every mutation carries a client-generated idempotency key. Retries after a timeout are certain; duplicates must not be.
  • Conflict resolution is an explicit, documented decision — last-write-wins, server-wins, or user-resolves. “We’ll figure it out later” means silent data loss.
  • The queue is bounded. An unbounded outbox on a device that has been offline for a month fills storage.

11. Monorepo

Only when there are ≥2 apps or ≥2 teams. Otherwise it is pure overhead.

apps/
mobile/ Expo app
web/ (optional)
packages/
domain/ pure TS — reusable across platforms verbatim
application/ use cases + ports — reusable
ui-native/ RN design system
config-eslint/ shared configs

Turborepo + pnpm workspaces. The payoff of hexagonal shows up here: domain/ and application/ have zero platform dependencies, so they move to a shared package with no changes at all.


12. Feature-Sliced Design — the alternative

For thin-CRUD apps, use FSD instead. Do not mix the two in one codebase.

src/
├── app/ providers, router, global styles
├── pages/ full screens
├── widgets/ composed reusable blocks
├── features/ user actions
├── entities/ business models
└── shared/ ui kit, api client, lib, config

The one rule that makes FSD work: a layer may only import from layers below it, and slices on the same layer may not import each other. Enforce with eslint-plugin-boundaries, same as §9.

If the app later grows real business rules, the migration path is: extract entities/ logic into a domain/, then introduce ports. FSD is not a dead end.


13. Anti-patterns

❌ Anti-pattern✅ Instead
fetch inside a componentAdapter behind a port, called via a use case
Date.now() in domainInject Clock
Server response cached in ZustandTanStack Query for server state, Zustand for client state
Port named FirebaseAuthPortPort AuthGateway, adapter FirebaseAuthGateway
Business rules in useEffectUse case
any at the API boundaryZod schema, then domain types
Folders components/ hooks/ utils/ services/Feature-first within each layer
throw for expected outcomesResult<T, DomainError>
Tokens in AsyncStorage/MMKVexpo-secure-store — see mobile-security.md
Screen renders only the happy pathLoading, empty, error, content — all four
Boundary rules “by convention”ESLint zones failing CI

14. Pre-merge checklist

  • domain/ and application/ import no framework and no I/O
  • Every new outbound dependency has a port; the adapter implements it
  • No port is named after its technology
  • All external input parsed with a schema at the adapter
  • Domain errors are a discriminated union, not thrown exceptions
  • Clock / IdGenerator injected wherever time or ids are used
  • Cross-cutting concerns are decorators, not inlined
  • New concretes wired only in infrastructure/di/container.ts
  • ESLint boundary rules pass
  • Domain and use cases near 100% covered — see mobile-testing.md
  • Architecturally significant decision captured as an ADR