# Mobile features mirror the backend shape

The repository's architectural decision, rationale and consequences.

# Mobile features mirror the backend shape

**Status:** accepted · **Date:** 2026-10-17 · **Amends:** [ADR-0011](0011-backend-feature-shape.md) · **Related:** [ADR-0002](0002-no-barrels-on-server-capable-modules.md), ADR-0012

**Enforcement:** in `pnpm test`, `scripts/mobile-feature-boundaries.test.mjs` (rules M1–M10 below) and `scripts/import-cycles.test.mjs` (no cycles in `apps/mobile/src`); `packages/api/src/exports.test.ts` (no `./routers/*` subpath); `max-lines` in `apps/mobile/src/.oxlintrc.json`; `expo export --platform ios|android --source-maps` and `scripts/verify-optional-client-removal.mjs` for Metro resolution.

`apps/mobile/src` had grown into `components/`, `hooks/` and `lib/`, a layer-first layout. One feature (todo, notifications, billing) was spread over three folders, ten routes read data through `orpc`, `authClient` or react-query directly, and two routes were over 280 lines. The backend ([ADR-0011](0011-backend-feature-shape.md)) and the web app moved to one folder per feature with a fixed role per file name; the native app read differently from both. The app also still imported four legacy API subpaths, `./routers/{todo,integrations,billing,notifications}.schema`, that ADR-0011 kept only for it.

## Decision [#decision]

**The same feature shape as the backend and the web, with `screen` for `page`.** `apps/mobile/src` has four layers, and imports point down: `app` → `features` → `platform` + `design`.

| Folder                        | Holds                                                                                                                                                                                                                                                                 |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/app/`                    | expo-router routes only, the same 27 files. A route returns its screen; it reads `useLocalSearchParams` when it needs to and keeps the root layout's module-scope effects and `ErrorBoundary` export.                                                                 |
| `src/features/<f>/`           | one flat folder per feature, every file named `<f>.<role>.ts(x)`. Names match the backend and web (`todo`, `notifications`, `billing`, `integrations`, `app-config`, `organizations`, `settings`, `auth`, `app-shell`, `dashboard`) plus `app-lock` and `onboarding`. |
| `src/platform/<area>/`        | cross-cutting code without domain: `rpc`, `auth`, `i18n`, `feedback`, `review`, `theme`, `runtime`, `errors`, `format`, `app` (root providers).                                                                                                                       |
| `src/design/`, `src/locales/` | unchanged.                                                                                                                                                                                                                                                            |

| Role            | File                                        | Rule                                                                                |
| --------------- | ------------------------------------------- | ----------------------------------------------------------------------------------- |
| Screen          | `<f>[.<topic>].screen.tsx`                  | composes `Screen` from `@/design`, hooks and components; gets route params as props |
| Hooks           | `<f>[.<topic>].hooks.ts`                    | state, effects, `useQuery`/`useMutation`, `authClient`; no JSX                      |
| Queries, search | `<f>.queries.ts`, `<f>.search.ts`           | query options; route-param parsing                                                  |
| Component       | `<f>.<name>.tsx`                            | presentation with `@/design`                                                        |
| Model           | `<f>.<topic>.ts`                            | pure logic and stores                                                               |
| Variant         | `<f>.<name>.{ios,android,native,web}.ts(x)` | next to its base, same stem                                                         |

Screens and components never value-import `@tanstack/react-query`, `@/platform/rpc/orpc` or `@/platform/auth/auth-client`; data access lives in hooks. A feature reaches another only through its `hooks`, `queries` or `search` file, by `@/features/<g>/…`, unless the edge is declared in the boundary test with its reason. Eight such edges exist today, all to a model of `todo`, `app-lock`, `auth` or `onboarding` (for example, sign-out clears the todo cache, and a todo push checks the todo Organization scope).

**Three platform → feature edges are accepted.** `platform/auth/auth-client.ts` and `platform/rpc/orpc.ts` import `features/todo/todo.cache-store.ts` and `todo.cache.ts`: sign-out clears the private offline todo cache synchronously, and the global error toast skips the offline and scope-change todo errors. Inverting them (features registering their private caches with the platform) would change the initialization order of a privacy path, so it is left for a separate change. The boundary test lists them, and the list only shrinks.

**One barrel stays: `src/design/index.ts`.** It is the design system's public surface, client-only, with about 50 consumers, the aggregate [ADR-0002](0002-no-barrels-on-server-capable-modules.md) allows. No other `index.ts` and no `export *` exist in `src/`. The former i18n directory import is now the named file `platform/i18n/i18n.ts`.

**Direction is a test, not lint.** `apps/mobile/src/.oxlintrc.json` extends the client boundary of ADR-0002. An oxlint override of `no-restricted-imports` replaces the inherited rule options instead of merging them, so a direction rule there would switch the boundary off for the files it matches. The rules are checked by `scripts/mobile-feature-boundaries.test.mjs`:

- M1 layers, and `src/app/` holds exactly the route list;
- M2 cross-feature edges;
- M3 platform variants;
- M4 no data access in screens and components;
- M5 `import()`/`require()` only in `index.ts` and the lazy purchases SDK;
- M6 no barrel;
- M7 every `require`, `import()`, `vi.mock` and `typeof import` string resolves;
- M8 flat, prefixed feature folders;
- M9 canonical API subpaths;
- M10 Vitest's `include` covers every test file.

Cycles (M11) join the existing `scripts/import-cycles.test.mjs`: `import/no-cycle` in the mobile lint config made `pnpm lint` about 60 % slower (paired median +0.59 s over seven runs). The lint config adds only `max-lines` warnings: 250 code lines in features, platform and design, 100 in routes.

**Metro rules.** A file's last segment before the extension is `ios`, `android`, `native` or `web` only when it is a variant of a base with the same stem in the same folder, so a topic is never named like a platform (`notifications.push-native.ts`, not `notifications.native.ts`). A variant moves with its base. Nothing but routes goes in `src/app/`. `index.ts` keeps its evaluation order: the notifications push bootstrap, then `expo-router/entry`. No `metro.config.js` or `babel.config.js`; the tsconfig `@/*` path is enough.

**The legacy API aliases are gone.** The app imports `@template-cloudflare/api/<feature>/contract`, `/client`, `/rpc-scope` and `/push-messages` only, and `packages/api` exports no `./routers/*` subpath. This replaces the ADR-0011 consequence "the mobile aliases stay until a mobile change moves the app to the canonical specifiers".

## Why not the alternatives [#why-not-the-alternatives]

- **Keep `components/`, `hooks/`, `lib/`.** A feature could not be read or removed as a unit, and the removal scripts kept name filters over `src/lib`.
- **Sub-folders per feature (`features/todo/components/`).** Deeper paths without clearer boundaries; the role suffix already says what a file is, as in ADR-0011.
- **Direction rules in oxlint.** They would replace the client boundary they extend (above).
- **Move `src/design/` into `platform/`, or split `locales/` per feature.** The design system is already one clear unit, and the locale parity test reads the three catalogs whole.

## Consequences [#consequences]

- Paths changed in one move: 130 files by `git mv`, then 35 new screens, hooks and section files. The iOS and Android sourcemaps of `expo export` listed the same modules under the new names after the moves, and only the new files after the screen extraction. The JS bundle grew by about 11.5 KB raw and 2 KB gzip, the module wrappers of the new files ([measurement](../measurements/2026-10-17-mobile-structure.md)).
- The removal recipes (`remove-task-clients`, `remove-mcp`, `verify-optional-client-removal`, `optional-features.json`) target the feature files; `features/todo` and `features/notifications` are removed as folders.
- Structural checks run without a device: `tsc`, Vitest, oxlint, the boundary and cycle tests, `expo export` for both platforms with sourcemaps and `expo config --type public`. A device smoke test is still recommended before a release, outside these gates.
- New mobile features follow `.agents/skills/domain-feature/references/mobile.md`.

