En esta guía (25 elementos)
Mobile Architecture (React Native)
Mobile Architecture — Hexagonal in React Native
BINDING RULES FOR ALL AI AGENTS: The layer placement rules below are MANDATORY.
domain/andapplication/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
Expensedomain. 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 profile | Architecture |
|---|---|
| Real business rules, offline-first, calculations, sync, money, scheduling | Hexagonal (this guide) |
| Thin CRUD over an API, content/showcase app, ≤10 screens | Feature-Sliced Design — see §12 |
| Mobile + web sharing logic, or ≥2 teams | Hexagonal 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)
| Layer | May import | MUST NOT import |
|---|---|---|
domain | Nothing but other domain files | React, React Native, Expo, fetch, storage, any npm runtime dep |
application | domain only | React, React Native, Expo, any concrete I/O |
infrastructure | application, domain | ui |
ui | application, domain, shared | infrastructure (except the DI container entry) |
shared | Nothing app-specific | Any 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.
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
declare const brand: unique symbolexport type Brand<T, B extends string> = T & { readonly [brand]: B }
// domain/expense/Expense.tsexport 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
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()— injectClockandIdGeneratorinstead. Non-determinism makes tests flaky and time-zone bugs invisible. - ❌
fetch,AsyncStorage,MMKV, any I/O - ❌
console.log— injectLogger - ❌
throwfor 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.
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).
depsandcommandis 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/.
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.
MmkvExpenseRepositoryis an adapter;ExpenseRepositoryis the port. If the port name containsMmkv,Http,SqliteorFirebase, the abstraction has already leaked.
Always port these, even though they look trivial:
| Port | Why |
|---|---|
Clock | Deterministic tests, correct time zones |
IdGenerator | Deterministic tests |
Logger | No console.log in domain; structured logging in release |
Analytics | Swap vendor without touching use cases |
Connectivity | Offline logic is a business decision, not a UI one |
6. Infrastructure layer
Adapters implement ports. This is the only layer allowed to import libraries.
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.
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.
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:
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.
| Boundary | Threat |
|---|---|
| HTTP response | API changed, or an attacker-controlled proxy |
| Persisted storage | Written by an older app version |
| Deep link / universal link | Fully attacker-controlled — any app can fire one |
| Push notification payload | Server-controlled, still validate |
| Clipboard, QR scan, file import | User- or attacker-supplied |
Parse with Zod at the adapter, return domain types inward:
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
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/
- ❌
fetchor 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.
| Strategy | When |
|---|---|
| Online-only | Data is worthless stale (live prices, availability). Show an explicit offline state. |
| Cache-then-network | Reads tolerate staleness. TanStack Query handles it with staleTime. |
| Offline-first with outbox | Writes 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.
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 configsTurborepo + 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, configThe 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 component | Adapter behind a port, called via a use case |
Date.now() in domain | Inject Clock |
| Server response cached in Zustand | TanStack Query for server state, Zustand for client state |
Port named FirebaseAuthPort | Port AuthGateway, adapter FirebaseAuthGateway |
Business rules in useEffect | Use case |
any at the API boundary | Zod schema, then domain types |
Folders components/ hooks/ utils/ services/ | Feature-first within each layer |
throw for expected outcomes | Result<T, DomainError> |
| Tokens in AsyncStorage/MMKV | expo-secure-store — see mobile-security.md |
| Screen renders only the happy path | Loading, empty, error, content — all four |
| Boundary rules “by convention” | ESLint zones failing CI |
14. Pre-merge checklist
-
domain/andapplication/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/IdGeneratorinjected 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