Mobile Architecture Basics
10 examples to get you started with mobile architecture - 7 basic and 3 intermediate.
Search across all documentation pages
10 examples to get you started with mobile architecture - 7 basic and 3 intermediate.
Scaffold a production-shaped Expo app with an explicit SDK 57 pin. The default@sdk-57 template ships Expo Router, TypeScript, and the recommended folder layout to extend.
npx create-expo-app@latest MyApp --template default@sdk-57
cd MyApp
npm installAdd a src/ tree beside app/ for feature boundaries:
MyApp/
├── app/ # Expo Router - routes only
├── src/
│ ├── shared/ # UI primitives, API client, config
│ ├── entities/ # domain models (User, Order)
│ └── features/ # product slices (auth, orders, settings)
├── app.config.ts
└── package.jsonConfirm the SDK pin before structuring features:
{
"dependencies": {
"expo": "~57.0.4",
"react": "19.2.3",
"react-native": "0.86.0"
}
}Tooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3.
Mobile apps benefit from a simple inward dependency flow: presentation (screens, components) calls domain (entities, use cases), which calls data (API adapters, storage).
┌─────────────────────────────────────────┐
│ app/ + features/*/screens, components │ ← presentation
├─────────────────────────────────────────┤
│ entities/ + features/*/model, hooks │ ← domain
├─────────────────────────────────────────┤
│ shared/api, shared/storage, adapters │ ← data / infra
└─────────────────────────────────────────┘
dependency direction: ↓ inward// src/features/orders/screens/OrdersScreen.tsx - presentation orchestrates
import { useOrders } from "../hooks/useOrders";
import { OrderList } from "../components/OrderList";
export function OrdersScreen() {
const { orders, loading, error, refresh } = useOrders();
return <OrderList orders={orders} loading={loading} error={error} onRefresh={refresh} />;
}fetch directly - it calls hooks or use cases that hide IOOrder, User) live in entities/ so multiple features share one shapeordersApi.ts, secureStorage.ts) sit in shared/ or features/*/api/OrderRow) makes unit tests painfulRelated: Feature-Sliced Design for RN - formal slice layers adapted for Expo | Clean Architecture on Mobile - entities and use cases without ceremony
Expo Router maps filenames to URLs. Route files should re-export feature screens - not own business logic.
// app/(tabs)/orders/index.tsx - one line when possible
export { OrdersScreen as default } from "@/features/orders";// src/features/orders/index.ts - public API for the feature
export { OrdersScreen } from "./screens/OrdersScreen";
export type { Order } from "./model/types";app/ is navigation infrastructure - params parsing and layout nesting belong here at mostfeatures/orders/index.ts is the only import path other features should use@/features/orders/components/OrderRow couple consumers to internal refactorsRelated: ../project-setup/folder-structure-for-features/folder-structure-for-features.md - feature slice layout in growing codebases
shared/ holds code with no product opinion. Feature folders hold product-specific UI and workflows.
src/
├── shared/
│ ├── ui/ # Button, Screen, TextField - design-system primitives
│ ├── api/ # createApiClient(), error types
│ ├── config/ # env readers (no secrets in source)
│ └── lib/ # formatCurrency, date helpers
└── features/
└── checkout/
├── components/ # PaymentSummary - checkout-specific
└── hooks/ # useCheckout - checkout workflow// src/shared/ui/Button.tsx - primitive, no domain knowledge
import { Pressable, StyleSheet, Text, type PressableProps } from "react-native";
type ButtonProps = PressableProps & { label: string };
export function Button({ label, style, ...rest }: ButtonProps) {
return (
<Pressable style={[styles.base, style]} {...rest}>
<Text style={styles.label}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
base: { paddingVertical: 12, paddingHorizontal: 20, borderRadius: 10, backgroundColor: "#2563eb" },
label: { fontSize: 16, fontWeight: "600", color: "#fff" },
});shared/ when two unrelated features need it and it has no product semanticsfeatures/checkout/ even if only one screen uses it todayshared/ui should not import from features/ - dependency flows one waycomponents/ junk drawer - unowned folders become god-module magnetsRelated: Feature-Sliced Design for RN -
sharedlayer rules and segment naming
Entities describe nouns in your domain - plain types and pure functions with zero React imports.
// src/entities/order/model/types.ts
export type OrderStatus = "pending" | "shipped" | "delivered" | "cancelled";
export type Order = {
id: string;
title: string;
totalCents: number;
status: OrderStatus;
placedAt: string;
};// src/entities/order/lib/formatOrderTotal.ts
import type { Order } from "../model/types";
export function formatOrderTotal(order: Order): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(order.totalCents / 100);
}features/orders and features/tracking both use OrderuseState, no fetch, no StyleSheet in entities/ - keeps them testable without a renderermodel/, lib/, api/ when the entity grows past ~200 linesRelated: Clean Architecture on Mobile - when to add use cases around entities
Every feature folder exposes an index.ts barrel. Cross-feature imports go through that file only.
// src/features/orders/index.ts
export { OrdersScreen } from "./screens/OrdersScreen";
export { OrderRow } from "./components/OrderRow";
export { useOrders } from "./hooks/useOrders";
export type { OrdersFilter } from "./model/types";// src/features/dashboard/screens/DashboardScreen.tsx - imports public API only
import { OrderRow } from "@/features/orders";
import type { Order } from "@/entities/order";normalizeRawOrder) unless another feature truly needs themno-restricted-imports can enforce @/features/*/index and block @/features/*/components/*Related: Refactoring Checklist - audit items for boundary violations before release
Network and storage IO live behind adapters so screens swap implementations (mock, staging, prod) without edits.
// src/shared/api/createApiClient.ts
type ApiClient = {
get<T>(path: string): Promise<T>;
post<T>(path: string, body: unknown): Promise<T>;
};
export function createApiClient(baseUrl: string): ApiClient {
return {
async get<T>(path: string) {
const response = await fetch(`${baseUrl}${path}`);
if (!response.ok) throw new Error(`GET ${path} failed: ${response.status}`);
return response.json() as Promise<T>;
},
async post<T>(path: string, body: unknown) {
const response = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`POST ${path} failed: ${response.status}`);
return response.json() as Promise<T>;
},
};
}// src/features/orders/api/ordersApi.ts
import type { Order } from "@/entities/order";
import type { ApiClient } from "@/shared/api/createApiClient";
export function createOrdersApi(client: ApiClient) {
return {
list: () => client.get<Order[]>("/orders"),
byId: (id: string) => client.get<Order>(`/orders/${id}`),
};
}createOrdersApi) accept dependencies - easy to inject mocks in testsfetch URLs should not appear in screen componentsordersApi, authApi) beats a single god api.tsRelated: Dependency Injection in RN - wiring adapters without a DI framework
Runtime configuration flows through a single reader - not scattered process.env calls in feature code.
// src/shared/config/env.ts
import Constants from "expo-constants";
type AppConfig = {
apiBaseUrl: string;
appVariant: "development" | "staging" | "production";
};
export function getAppConfig(): AppConfig {
const extra = Constants.expoConfig?.extra as Partial<AppConfig> | undefined;
return {
apiBaseUrl: extra?.apiBaseUrl ?? "https://api.staging.example.com",
appVariant: extra?.appVariant ?? "development",
};
}// app.config.ts (excerpt)
export default {
extra: {
apiBaseUrl: process.env.EXPO_PUBLIC_API_URL,
appVariant: process.env.APP_VARIANT ?? "development",
},
};EXPO_PUBLIC_* vars are embedded in the JS bundle - never put secrets theregetAppConfig() is the only module that reads Constants.expoConfig?.extra for app settingsgetAppConfig() or receive config via context - they do not read env directlyenv per channel - see project-setup environment guidesRelated: Modular Monolith vs Multi-App - one binary with variants vs separate apps
Features should not import each other's internals. Use shared entities, events, or orchestration at the app layer.
// src/features/cart/hooks/useCart.ts - cart owns its state
import { create } from "zustand";
type CartItem = { productId: string; qty: number };
type CartStore = {
items: CartItem[];
add: (item: CartItem) => void;
clear: () => void;
};
export const useCart = create<CartStore>((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
clear: () => set({ items: [] }),
}));// app/(tabs)/catalog/[id].tsx - route orchestrates navigation + cart, not features importing features
import { useLocalSearchParams, router } from "expo-router";
import { ProductDetailScreen } from "@/features/catalog";
import { useCart } from "@/features/cart";
export default function ProductRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const add = useCart((s) => s.add);
return (
<ProductDetailScreen
productId={id ?? ""}
onAddToCart={(productId) => {
add({ productId, qty: 1 });
router.push("/(tabs)/cart");
}}
/>
);
}features/catalog ignorant of cart navigationonAddToCart) into screens instead of importing sibling feature storesProduct, Order) live in entities/ - both features import thoseshared/events module - not direct store importsRelated: ADR: State Management Selection - when global stores are justified
Architecture pays off when each layer tests without booting the full app.
// src/features/orders/hooks/useOrders.test.ts
import { renderHook, waitFor } from "@testing-library/react-native";
import { useOrders } from "./useOrders";
import type { Order } from "@/entities/order";
const mockOrders: Order[] = [
{ id: "1", title: "Widget", totalCents: 999, status: "pending", placedAt: "2026-01-01" },
];
jest.mock("../api/ordersApi", () => ({
ordersApi: { list: jest.fn(() => Promise.resolve(mockOrders)) },
}));
it("loads orders on mount", async () => {
const { result } = renderHook(() => useOrders());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.orders).toHaveLength(1);
});// src/entities/order/lib/formatOrderTotal.test.ts - pure, no renderer
import { formatOrderTotal } from "./formatOrderTotal";
it("formats cents as USD", () => {
expect(
formatOrderTotal({ id: "1", title: "A", totalCents: 2500, status: "pending", placedAt: "" })
).toBe("$25.00");
});fetch globallyRelated: Dependency Injection in RN - injectable services for test doubles
Stay in a single repo until two apps or CI isolation force a workspace package.
monorepo/
├── apps/
│ └── mobile/ # Expo app - imports @acme/orders
├── packages/
│ ├── orders/ # entity + API types shared with web admin
│ └── ui/ # design system
└── pnpm-workspace.yaml// apps/mobile/package.json
{
"dependencies": {
"@acme/orders": "workspace:*",
"expo": "~57.0.4",
"react": "19.2.3",
"react-native": "0.86.0"
}
}// packages/orders/src/index.ts
export type { Order, OrderStatus } from "./model/types";
export { formatOrderTotal } from "./lib/formatOrderTotal";npx expo-doctor after adding workspace deps - Metro must resolve the symlinksrc/entities/ is free until a second consumer existsRelated: ../project-setup/shared-packages-and-metro-resolution/shared-packages-and-metro-resolution.md - Metro and workspace packages
Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026