A production-ready, feature-modular, clean-architecture design for large React Native apps supporting multi-team development, offline-first capabilities, and maintainable scaling over years.
UI (features/) → domain/ → data/ → infrastructure/
↓ ↑
shared/ (interfaces only)
The golden rule: Dependencies point inward. domain/ knows nothing about data/, infrastructure/, or features/. The data/ layer implements domain interfaces. features/ depend on domain/ (use cases, entities) — never directly on data/.
src/
├── app/
│ ├── navigation/
│ │ ├── RootNavigator.tsx
│ │ ├── AuthStack.tsx
│ │ ├── MainTabs.tsx
│ │ ├── ModalStack.tsx
│ │ └── linking.ts # Deep link configuration
│ ├── providers/
│ │ ├── AppProviders.tsx # Composes all providers in correct order
│ │ ├── AuthProvider.tsx
│ │ ├── ThemeProvider.tsx
│ │ ├── QueryProvider.tsx # React Query client + config
│ │ └── ErrorBoundaryProvider.tsx
│ ├── store/
│ │ ├── rootStore.ts
│ │ └── hydration.ts # Store rehydration from persisted state
│ ├── config/
│ │ ├── env.ts # Environment-specific config (dev/staging/prod)
│ │ ├── featureFlags.ts # Feature flag definitions + remote config
│ │ └── appConfig.ts # App-wide constants (timeouts, retry limits, etc.)
│ └── bootstrap.tsx # App initialization sequence
│
├── features/
│ ├── auth/
│ │ ├── screens/
│ │ │ ├── LoginScreen.tsx
│ │ │ └── __tests__/
│ │ │ └── LoginScreen.test.tsx
│ │ ├── components/
│ │ │ └── AuthForm.tsx
│ │ ├── hooks/
│ │ │ └── useLogin.ts
│ │ ├── viewmodels/
│ │ │ ├── LoginViewModel.ts
│ │ │ └── __tests__/
│ │ │ └── LoginViewModel.test.ts
│ │ ├── navigation/
│ │ │ └── AuthNavigator.tsx
│ │ └── index.ts # Barrel export — public API of the module
│ │
│ ├── users/
│ │ ├── screens/
│ │ │ └── UserListScreen.tsx
│ │ ├── components/
│ │ │ └── UserCard.tsx
│ │ ├── hooks/
│ │ │ └── useUsers.ts
│ │ ├── viewmodels/
│ │ │ └── UserListViewModel.ts
│ │ ├── navigation/
│ │ │ └── UsersNavigator.tsx
│ │ └── index.ts
│ │
│ └── messages/
│ ├── screens/
│ │ └── ChatScreen.tsx
│ ├── components/
│ │ └── MessageBubble.tsx
│ ├── hooks/
│ │ └── useMessages.ts
│ ├── viewmodels/
│ │ └── ChatViewModel.ts
│ └── index.ts
│
├── domain/
│ ├── entities/
│ │ └── User.ts
│ ├── repositories/
│ │ └── UserRepository.ts # Interface only — no implementation
│ ├── services/
│ │ └── AuthService.ts # Interface only
│ ├── usecases/
│ │ ├── GetUsersUseCase.ts
│ │ └── __tests__/
│ │ └── GetUsersUseCase.test.ts
│ └── errors/
│ ├── AppError.ts # Base error class
│ ├── NetworkError.ts
│ ├── AuthError.ts
│ └── ValidationError.ts
│
├── data/
│ ├── api/
│ │ └── UserAPI.ts
│ ├── dtos/
│ │ └── UserDTO.ts
│ ├── mappers/
│ │ └── UserMapper.ts
│ ├── repositories/
│ │ ├── UserRepositoryImpl.ts
│ │ └── __tests__/
│ │ └── UserRepositoryImpl.test.ts
│ └── datasources/
│ ├── remote/
│ │ └── UserRemoteDataSource.ts
│ ├── cache/
│ │ └── UserCacheDataSource.ts
│ └── local/
│ └── UserLocalDataSource.ts
│
├── infrastructure/
│ ├── http/
│ │ ├── httpClient.ts # Axios/Ky instance with base config
│ │ ├── interceptors.ts # Auth token injection, refresh, error mapping
│ │ └── retryPolicy.ts # Exponential backoff, circuit breaker
│ ├── storage/
│ │ ├── MMKVStorage.ts # Key-value store (MMKV)
│ │ └── SecureStorage.ts # Keychain/Keystore for tokens & secrets
│ ├── analytics/
│ │ ├── AnalyticsService.ts
│ │ └── AnalyticsProvider.ts # Abstraction over Firebase/Mixpanel/etc.
│ ├── logging/
│ │ └── Logger.ts # Structured logging with log levels
│ ├── crashlytics/
│ │ └── CrashlyticsService.ts
│ ├── push/
│ │ ├── PushNotificationService.ts
│ │ └── NotificationHandler.ts
│ ├── realtime/
│ │ ├── WebSocketClient.ts
│ │ └── SocketEventBus.ts # Event-driven messaging for real-time features
│ ├── background/
│ │ └── BackgroundTaskManager.ts # Background fetch, task scheduling
│ ├── permissions/
│ │ └── PermissionManager.ts # Unified permission request/check API
│ ├── native/
│ │ └── NativeModuleBridge.ts # Typed wrappers around native modules
│ ├── codepush/
│ │ └── CodePushService.ts # OTA update management
│ └── di/
│ └── container.ts # DI container (tsyringe, inversify, or manual)
│
├── shared/
│ ├── components/
│ │ ├── Button.tsx
│ │ ├── Modal.tsx
│ │ ├── FormInput.tsx
│ │ ├── ErrorFallback.tsx # Error boundary fallback UI
│ │ ├── Skeleton.tsx # Loading skeleton
│ │ └── EmptyState.tsx
│ ├── hooks/
│ │ ├── useDebounce.ts
│ │ ├── useNetworkStatus.ts # Online/offline detection
│ │ ├── useAppState.ts # Foreground/background detection
│ │ └── usePermission.ts
│ ├── utils/
│ │ ├── formatDate.ts
│ │ ├── formatCurrency.ts
│ │ ├── validation.ts # Zod/Yup schema helpers
│ │ └── platform.ts # Platform-specific utilities
│ ├── constants/
│ │ └── Colors.ts
│ ├── types/
│ │ ├── navigation.d.ts # Typed navigation params
│ │ └── index.d.ts
│ ├── i18n/
│ │ ├── i18n.ts # i18next setup
│ │ ├── en.json
│ │ └── ar.json
│ └── theme/
│ ├── tokens.ts # Design tokens (spacing, radii, typography scales)
│ ├── typography.ts
│ └── shadows.ts
│
├── e2e/
│ ├── auth.e2e.ts # Detox / Maestro E2E tests
│ └── helpers/
│ └── testUtils.ts
│
└── App.tsx
Entry point and wiring layer. Owns the global provider tree, root navigation, environment config, feature flags, and the bootstrap sequence. This is the only layer that "knows about everything" — it composes features/, domain/, data/, and infrastructure/ together.
Vertical slices of the application, each containing its own screens, components, hooks, viewmodels, and navigation. Features communicate only through domain-level contracts (use cases, entities, events) — never by importing from each other directly. Each feature exposes a public API via index.ts barrel exports.
The innermost layer. Contains entities (plain objects/classes, no framework dependencies), repository interfaces, service interfaces, use cases, and domain-specific error types. This layer has zero external dependencies — no React, no Axios, no storage libraries. It is pure TypeScript and trivially unit-testable.
Implements domain repository interfaces. Owns DTOs (API response shapes), mappers (DTO-to-entity transformation), and data sources (remote, local, cache). Repository implementations here orchestrate between data sources and apply caching/sync strategies.
Wraps all third-party SDKs and platform APIs behind stable interfaces: HTTP clients, secure storage, analytics, crash reporting, push notifications, WebSockets, background tasks, permissions, native modules, and OTA updates. Swapping a vendor (e.g., Firebase to Mixpanel) means changing only this layer.
Reusable UI components, hooks, utilities, constants, types, internationalization, and design tokens. Anything that two or more features need but that doesn't contain business logic belongs here.
All cross-layer dependencies are injected via container.ts. Features receive use cases, use cases receive repository interfaces, repository implementations receive data sources. This enables easy mocking and testing.
API responses are never used directly in the domain or UI. UserDTO (API shape) is mapped to User (domain entity) via UserMapper. This decouples the API contract from the internal model — API changes don't cascade through the app.
Each business operation is a single-responsibility use case class (e.g., GetUsersUseCase, LoginUseCase). Use cases orchestrate repositories and domain services, keeping business rules out of ViewModels and hooks.
domain/repositories/ defines interfaces. data/repositories/ provides implementations. This lets you swap from REST to GraphQL, or add an offline-first caching layer, without touching business logic.
ViewModels encapsulate presentation logic (loading states, error mapping, data transformation for UI). Hooks (useLogin, useUsers) connect ViewModels to React lifecycle. Screens remain thin — they render UI and delegate to hooks.
Features are self-contained. Cross-feature communication happens through:
- Domain events (event bus pattern) for loose coupling
- Shared navigation params for screen-to-screen transitions
- Domain entities/use cases as the shared contract
Features never import from each other's internal folders.
Remote DataSource → Cache DataSource → Local DataSource
↓ ↓ ↓
Network API In-memory/MMKV SQLite/WatermelonDB
- Read path: Check cache first, fall back to remote, persist to local.
- Write path: Write to local immediately (optimistic), sync to remote, reconcile on conflict.
- Sync engine: Background task manager triggers periodic sync when connectivity resumes.
- Domain errors (
AppErrorsubclasses) represent typed, recoverable failures. - Infrastructure interceptors catch HTTP/network errors and map them to domain error types.
- React Error Boundaries (via
ErrorBoundaryProvider) catch rendering crashes with a fallback UI. - Global error handler in
bootstrap.tsxcatches unhandled JS exceptions and promise rejections, logs them, and reports to Crashlytics.
| Concern | Tool | Location |
|---|---|---|
| Server/async state | React Query (TanStack Query) | Feature hooks |
| Client/UI state | Zustand | app/store/ + feature-local stores |
| Form state | React Hook Form + Zod | Feature components |
| Navigation state | React Navigation | app/navigation/ |
| Persisted state | MMKV + Zustand persist middleware | infrastructure/storage/ |
- React Query for automatic caching, deduplication, background refetching, and stale-while-revalidate.
- FlashList over FlatList for large lists (60fps scrolling at scale).
- React.memo / useMemo / useCallback applied judiciously — profile first, memoize second.
- Hermes engine enabled for faster startup and lower memory.
- Lazy loading of feature modules via
React.lazy+Suspensewhere applicable. - Image optimization with
react-native-fast-imageand progressive loading. - Bundle analysis and tree-shaking to minimize JS bundle size.
- Reanimated 3 for 60fps animations on the UI thread.
- Tokens stored in Keychain/Keystore via
SecureStorage, never in AsyncStorage or MMKV. - Certificate pinning configured in the HTTP client for production builds.
- Biometric authentication gating for sensitive operations.
- ProGuard/R8 (Android) and bitcode (iOS) for code obfuscation.
- Runtime jailbreak/root detection with appropriate degradation.
- No sensitive data in Redux/Zustand devtools in production.
| Layer | Test Type | Tools | What to Test |
|---|---|---|---|
domain/usecases/ |
Unit | Jest | Business logic in isolation, mocked repos |
data/repositories/ |
Unit | Jest | Data orchestration, mapper correctness |
features/viewmodels/ |
Unit | Jest | Presentation logic, state transitions |
features/screens/ |
Component | RNTL | Render output, user interactions |
infrastructure/ |
Integration | Jest | HTTP interceptors, storage adapters |
e2e/ |
E2E | Detox / Maestro | Critical user journeys |
Colocate tests with source (__tests__/ folders) for discoverability. Aim for high coverage on domain/ and data/, pragmatic coverage on features/.
- RootNavigator owns the top-level auth/unauth split and modal stack.
- Each feature owns its own navigator (stack/tab) registered into the root.
- Deep linking configured via
linking.tswith universal links (iOS) and app links (Android). - Typed navigation using declaration merging on
RootParamListfor compile-time safety.
- i18next + react-i18next for runtime translations.
- Translation files colocated in
shared/i18n/with per-locale JSON files. - RTL layout support for Arabic and other RTL languages.
- Dynamic locale switching without app restart.
- Structured logging with severity levels (
debug,info,warn,error) routed to console in dev and to a remote logging service in production. - Analytics abstracted behind
AnalyticsProviderso the feature layer tracks events without knowing the vendor. - Crashlytics for crash reporting with breadcrumbs from the logger.
- Performance monitoring (startup time, screen render time, API latency) via Firebase Performance or custom instrumentation.
- Fastlane for automated iOS/Android builds and store submissions.
- CodePush / EAS Update for OTA JS bundle updates without store review.
- Per-environment builds driven by
app/config/env.ts(dev, staging, production). - Pre-commit hooks (Husky + lint-staged) for linting and formatting.
- PR-level checks: TypeScript compilation, ESLint, Jest, Detox smoke tests.
- Feature ownership: Each team owns one or more
features/modules with clear barrel-export boundaries. - Dependency rule enforcement: Use ESLint plugin (
eslint-plugin-boundariesordependency-cruiser) to prevent illegal cross-layer or cross-feature imports. - Shared component library:
shared/components/follows atomic design principles; changes go through design system review. - API contract testing: Pact or similar contract tests between mobile and backend teams.
- Feature flags:
featureFlags.tsintegrates with a remote config service (LaunchDarkly, Firebase Remote Config) for safe rollouts and A/B testing.
UserListScreen
└── useUsers() hook
└── UserListViewModel
└── GetUsersUseCase.execute()
└── UserRepository.getUsers() ← domain interface
└── UserRepositoryImpl.getUsers() ← data implementation
├── UserCacheDataSource.get()
│ └── (cache hit → return mapped entities)
└── UserRemoteDataSource.fetch()
└── UserAPI.getUsers()
└── httpClient.get('/users')
└── interceptors (auth token, retry, error mapping)
└── UserMapper.toDomain(dto)
└── UserCacheDataSource.set(entities)
// domain/entities/User.ts
export interface User {
readonly id: string;
readonly email: string;
readonly displayName: string;
readonly avatarUrl: string | null;
readonly role: UserRole;
readonly createdAt: Date;
}
export type UserRole = 'admin' | 'member' | 'guest';// domain/usecases/GetUsersUseCase.ts
import type { User } from '../entities/User';
import type { UserRepository } from '../repositories/UserRepository';
export class GetUsersUseCase {
constructor(private readonly userRepository: UserRepository) {}
async execute(page: number, limit: number): Promise<User[]> {
return this.userRepository.getUsers(page, limit);
}
}// domain/repositories/UserRepository.ts — interface in domain layer
import type { User } from '../entities/User';
export interface UserRepository {
getUsers(page: number, limit: number): Promise<User[]>;
getUserById(id: string): Promise<User>;
}// data/repositories/UserRepositoryImpl.ts — implementation in data layer
import type { UserRepository } from '../../domain/repositories/UserRepository';
import type { User } from '../../domain/entities/User';
import type { UserRemoteDataSource } from '../datasources/remote/UserRemoteDataSource';
import type { UserCacheDataSource } from '../datasources/cache/UserCacheDataSource';
import { UserMapper } from '../mappers/UserMapper';
export class UserRepositoryImpl implements UserRepository {
constructor(
private readonly remote: UserRemoteDataSource,
private readonly cache: UserCacheDataSource,
) {}
async getUsers(page: number, limit: number): Promise<User[]> {
const cached = await this.cache.getUsers(page);
if (cached) return cached;
const dtos = await this.remote.fetchUsers(page, limit);
const users = dtos.map(UserMapper.toDomain);
await this.cache.setUsers(page, users);
return users;
}
async getUserById(id: string): Promise<User> {
const dto = await this.remote.fetchUserById(id);
return UserMapper.toDomain(dto);
}
}// features/users/viewmodels/UserListViewModel.ts
import type { User } from '../../../domain/entities/User';
import type { GetUsersUseCase } from '../../../domain/usecases/GetUsersUseCase';
export class UserListViewModel {
constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
async loadUsers(page: number): Promise<{
users: User[];
error: string | null;
}> {
try {
const users = await this.getUsersUseCase.execute(page, 20);
return { users, error: null };
} catch (e) {
return { users: [], error: this.mapError(e) };
}
}
private mapError(error: unknown): string {
if (error instanceof NetworkError) return 'No internet connection.';
if (error instanceof AuthError) return 'Session expired. Please log in again.';
return 'Something went wrong. Please try again.';
}
}// features/users/hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query';
import { container } from '../../../infrastructure/di/container';
export function useUsers(page: number) {
const viewModel = container.resolve(UserListViewModel);
return useQuery({
queryKey: ['users', page],
queryFn: () => viewModel.loadUsers(page),
staleTime: 5 * 60 * 1000,
});
}// infrastructure/di/container.ts
import { UserAPI } from '../../data/api/UserAPI';
import { UserRemoteDataSource } from '../../data/datasources/remote/UserRemoteDataSource';
import { UserCacheDataSource } from '../../data/datasources/cache/UserCacheDataSource';
import { UserRepositoryImpl } from '../../data/repositories/UserRepositoryImpl';
import { GetUsersUseCase } from '../../domain/usecases/GetUsersUseCase';
import { httpClient } from '../http/httpClient';
const userApi = new UserAPI(httpClient);
const userRemote = new UserRemoteDataSource(userApi);
const userCache = new UserCacheDataSource();
const userRepository = new UserRepositoryImpl(userRemote, userCache);
export const container = {
getUsersUseCase: () => new GetUsersUseCase(userRepository),
// Register additional use cases, services, etc.
};| Concern | Library |
|---|---|
| Navigation | React Navigation 7+ |
| Server state | TanStack Query (React Query) v5 |
| Client state | Zustand |
| Forms | React Hook Form + Zod |
| HTTP | Axios or Ky |
| Storage (KV) | react-native-mmkv |
| Secure storage | react-native-keychain |
| Lists | @shopify/flash-list |
| Animations | react-native-reanimated 3 |
| i18n | i18next + react-i18next |
| Testing | Jest + React Native Testing Library |
| E2E | Detox or Maestro |
| OTA updates | CodePush / EAS Update |
| Crash reporting | Firebase Crashlytics / Sentry |
| Analytics | Segment / Firebase Analytics |
| Image loading | react-native-fast-image |
| Styling | Nativewind (Tailwind) or StyleSheet |
This architecture supports multi-team development, large feature sets, offline-first capabilities, full testability, and maintainable scaling over years — while keeping each layer independently replaceable and every boundary enforceable via tooling.