6 min read
Project Architecture
Structure a React Native app in layers so business logic survives UI rewrites, and group files by feature so a screen's code lives in one place.
Principles
Layer by dependency direction, not by file type
UI depends on domain, domain depends on data — never the reverse. A `components/` folder holding every component in the app tells you nothing about what depends on what. Layers do, and they make the dangerous imports visible in review.
Group by feature, colocate ruthlessly
A feature owns its screens, hooks, state, and types in one folder. If deleting a feature means touching eight top-level directories, the structure is working against you. Only genuinely shared code graduates to `shared/`.
The domain layer must not import from React Native
Business rules that import `Dimensions` or `Platform` cannot be unit tested without a native environment, and cannot be reused if you ever add a web target. Keep the domain layer pure TypeScript — this is the single highest-leverage rule in the list.
One public entry point per feature
Export a feature's surface from its `index.ts`. Deep imports into `features/checkout/hooks/internal/useDraft` are how a refactor turns into a week. The barrel file is the contract.
Enforce boundaries with tooling, not good intentions
Conventions decay the moment a deadline appears. An ESLint `no-restricted-imports` rule that fails the build is the only version of this that survives contact with a real team.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Folder structure
Avoid
src/
components/ # 200 files, no grouping
screens/ # every screen in the app
hooks/ # every hook in the app
utils/ # the junk drawer
types.ts # 900 linesType-based folders. Adding one feature touches five directories, and nothing tells you which code belongs together.
Prefer
src/
features/
checkout/
ui/ # screens + components
model/ # hooks, state, domain logic
api/ # queries, mutations, mappers
index.ts # the feature's public surface
shared/
ui/ # design system primitives
lib/ # framework-agnostic helpers
app/ # navigation, providers, bootstrapFeature-first with layers inside. A feature is one folder, and its public surface is one file.
Keeping the domain layer pure
Avoid
// features/checkout/model/pricing.ts
import { Platform } from "react-native";
export function totalWithFees(cents: number) {
// Now this file needs a native runtime to test.
const fee = Platform.OS === "ios" ? 30 : 25;
return cents + fee;
}A pricing rule that imports React Native can only be tested in a native environment, and cannot be reused anywhere else.
Prefer
// features/checkout/model/pricing.ts — pure, trivially testable
export function totalWithFees(cents: number, feeCents: number) {
return cents + feeCents;
}
// features/checkout/ui/CheckoutScreen.tsx — platform lives at the edge
const fee = Platform.select({ ios: 30, android: 25 }) ?? 25;
const total = totalWithFees(subtotal, fee);Platform detail is injected at the UI edge. The rule is a pure function you can test in milliseconds.
Enforcing the boundary
Avoid
// No rule. The convention lives in a wiki page
// that was last updated eleven months ago.Undefended conventions are suggestions. The first deadline erases them.
Prefer
{
"rules": {
"no-restricted-imports": ["error", {
"patterns": [{
"group": ["**/features/*/!(index)*"],
"message": "Import from the feature's index.ts, not its internals."
}, {
"group": ["react-native"],
"importNames": ["Platform", "Dimensions"],
"message": "Keep native APIs out of model/. Inject from the UI layer."
}]
}]
}
}The boundary now fails CI. That is the only enforcement that lasts.
Review checklist
What I look for when reviewing a pull request that touches this area.
- Every feature is one folder with a single `index.ts` public surface.
- Nothing in `model/` imports from `react-native` or from another feature.
- Dependency direction is one-way: ui → model → api.
- Shared code earned its place in `shared/` by having two or more real consumers.
- An ESLint rule fails the build on deep feature imports.
- A new engineer can locate a screen's logic from the screen name alone.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/react-native-architecture/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: react-native-architecture
description: >-
Structure and review React Native project architecture. Feature-based
folders, N-layered separation, dependency direction, and import
boundaries. Use when creating features, moving files, or reviewing
structure.
argument-hint: "[feature name]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Project Architecture
Apply these rules when creating a new feature, relocating files, or reviewing a pull request that changes project structure. The dependency direction rule is not negotiable: a violation is a defect, not a style preference.
## Rules
1. **Layer by dependency direction, not by file type** — UI depends on domain, domain depends on data — never the reverse. A `components/` folder holding every component in the app tells you nothing about what depends on what. Layers do, and they make the dangerous imports visible in review.
2. **Group by feature, colocate ruthlessly** — A feature owns its screens, hooks, state, and types in one folder. If deleting a feature means touching eight top-level directories, the structure is working against you. Only genuinely shared code graduates to `shared/`.
3. **The domain layer must not import from React Native** — Business rules that import `Dimensions` or `Platform` cannot be unit tested without a native environment, and cannot be reused if you ever add a web target. Keep the domain layer pure TypeScript — this is the single highest-leverage rule in the list.
4. **One public entry point per feature** — Export a feature's surface from its `index.ts`. Deep imports into `features/checkout/hooks/internal/useDraft` are how a refactor turns into a week. The barrel file is the contract.
5. **Enforce boundaries with tooling, not good intentions** — Conventions decay the moment a deadline appears. An ESLint `no-restricted-imports` rule that fails the build is the only version of this that survives contact with a real team.
## Patterns
### Folder structure
**Avoid** — Type-based folders. Adding one feature touches five directories, and nothing tells you which code belongs together.
```bash
src/
components/ # 200 files, no grouping
screens/ # every screen in the app
hooks/ # every hook in the app
utils/ # the junk drawer
types.ts # 900 lines
```
**Prefer** — Feature-first with layers inside. A feature is one folder, and its public surface is one file.
```bash
src/
features/
checkout/
ui/ # screens + components
model/ # hooks, state, domain logic
api/ # queries, mutations, mappers
index.ts # the feature's public surface
shared/
ui/ # design system primitives
lib/ # framework-agnostic helpers
app/ # navigation, providers, bootstrap
```
### Keeping the domain layer pure
**Avoid** — A pricing rule that imports React Native can only be tested in a native environment, and cannot be reused anywhere else.
```ts
// features/checkout/model/pricing.ts
import { Platform } from "react-native";
export function totalWithFees(cents: number) {
// Now this file needs a native runtime to test.
const fee = Platform.OS === "ios" ? 30 : 25;
return cents + fee;
}
```
**Prefer** — Platform detail is injected at the UI edge. The rule is a pure function you can test in milliseconds.
```ts
// features/checkout/model/pricing.ts — pure, trivially testable
export function totalWithFees(cents: number, feeCents: number) {
return cents + feeCents;
}
// features/checkout/ui/CheckoutScreen.tsx — platform lives at the edge
const fee = Platform.select({ ios: 30, android: 25 }) ?? 25;
const total = totalWithFees(subtotal, fee);
```
### Enforcing the boundary
**Avoid** — Undefended conventions are suggestions. The first deadline erases them.
```json
// No rule. The convention lives in a wiki page
// that was last updated eleven months ago.
```
**Prefer** — The boundary now fails CI. That is the only enforcement that lasts.
```json
{
"rules": {
"no-restricted-imports": ["error", {
"patterns": [{
"group": ["**/features/*/!(index)*"],
"message": "Import from the feature's index.ts, not its internals."
}, {
"group": ["react-native"],
"importNames": ["Platform", "Dimensions"],
"message": "Keep native APIs out of model/. Inject from the UI layer."
}]
}]
}
}
```
## Review checklist
- [ ] Every feature is one folder with a single `index.ts` public surface.
- [ ] Nothing in `model/` imports from `react-native` or from another feature.
- [ ] Dependency direction is one-way: ui → model → api.
- [ ] Shared code earned its place in `shared/` by having two or more real consumers.
- [ ] An ESLint rule fails the build on deep feature imports.
- [ ] A new engineer can locate a screen's logic from the screen name alone.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim