Skip to content
Docs

Command palette

Jump to a page or view, or find a todo by name.

Changelog

Mobile features mirror the backend shape

The repository's architectural decision, rationale and consequences.

Markdown

Status: accepted · Date: 2026-10-17 · Amends: ADR-0011 · Related: ADR-0002, 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) 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

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.

FolderHolds
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.
RoleFileRule
Screen<f>[.<topic>].screen.tsxcomposes Screen from @/design, hooks and components; gets route params as props
Hooks<f>[.<topic>].hooks.tsstate, effects, useQuery/useMutation, authClient; no JSX
Queries, search<f>.queries.ts, <f>.search.tsquery options; route-param parsing
Component<f>.<name>.tsxpresentation with @/design
Model<f>.<topic>.tspure 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 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

  • 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

  • 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).
  • 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.
Edit on GitHubDocumentation is in English.