All topics

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

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

Type-based folders. Adding one feature touches five directories, and nothing tells you which code belongs together.

Prefer

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

Feature-first with layers inside. A feature is one folder, and its public surface is one file.

Keeping the domain layer pure

Avoid

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;
}

A pricing rule that imports React Native can only be tested in a native environment, and cannot be reused anywhere else.

Prefer

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);

Platform detail is injected at the UI edge. The rule is a pure function you can test in milliseconds.

Enforcing the boundary

Avoid

json
// 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

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."
      }]
    }]
  }
}

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.

Download
.claude/skills/react-native-architecture/SKILL.md

Drop 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