Keeps an Expo app in a provable shape: every file has one home in the src/ layout, dependencies point
one way along declared zones, src/core is pure, every side effect sits behind a port with an adapter
and an in-memory fake, one composition root creates the adapters, and nothing runs at import time. Two
zero-dependency checkers prove it on every change, on resolved imports, for iOS and Android alike.
Rules that must hold
- Every file has one home.
src/app(routes only),src/features/<feature>/(screen, model hook, components, pure logic, tests beside them),src/ui,src/theme,src/core,src/services/<concern>/,src/stores,src/data,src/api,src/i18n,src/config,src/hooks(shared cross-feature hooks),src/canvas(the canvas kit), andsrc/games/<game>/for the games plugin; asrc/**/test-only.tsgate beside its entry; a web stylesheet insrc/theme/;assets/<kind>/at the root for images, fonts and sounds; local Expo modules inmodules/<name>/;test/,__mocks__/,scripts/<area>/,plugins/,contracts/. Why: an agent finds code by path, and autils/orhelpers/folder becomes the place everything goes. (folders,placement) - Routes are thin leaves. Every file in
src/appdefault-exports a route (export default, orexport { SettingsScreen as default } from ...) that renders a screen fromsrc/features/(or only redirects); hooks and helpers live elsewhere, and tests in the feature (a route test issrc/features/<feature>/<feature>-navigation.test.tsx); a route other than a_layoutnever imports a store,src/data,src/apior a service and calls no hook but router hooks (expo-router) and the ones it imports fromsrc/features/(nouseState,useEffect,useQuery,useMemo, shared or local hooks); nothing but a test imports a route. Why: Expo Router bundles every file in that folder (verified: a test file there shipped in the production bundle), and logic in a route can be neither reused nor tested alone. (route-file,route-thin,route-leaf) - Dependencies point one way. routes → features → shared layers (
ui,theme,services,stores,data,api,i18n,config) →core. A shared layer never imports a feature, features never import each other, andsrc/uiis presentational (no stores, data, api, or services other than port types). A game never imports another game and faces onlysrc/core,src/ui,src/theme,src/i18n,src/canvas, port types, its own folder and the seam recorded ingameFacing. A deliberate exception is anallowedImportsentry with its reason. Why: a feature or a game can change or disappear without breaking another, and UI stays reusable with props. (layer-direction,feature-to-feature,ui-presentational,game-to-game,game-facing) src/coreis pure. No React, React Native, Expo, Skia, Zustand, navigation or i18n library, no Node built-in, nothing outsidesrc/core(game rules and levels:src/coreplus their own rules and levels). Time and randomness arrive as values. Why: pure code runs the same in Jest, Node tooling and on every device, and is the cheapest code to test. (core-pure)- Every side effect sits behind a port, and every SDK is classified. A vendor-neutral type in
src/services/<concern>/<concern>-port.ts(expected failures areResultvalues, never rejections); the only importer of the SDK is<library>-<concern>-adapter.ts(platform files*-adapter.ios.tsand*-adapter.android.tscount, and so do*-adapter.tsfiles insrc/data/; subpaths such asexpo-sqlite/kv-storeare the SDK); an in-memoryfake-<concern>.tswith its own test. Every SDK the app's runtime code imports is either invendorSdks(behind a port) or inportlessSdks(UI building blocks and read-only facts, with the reason): everyexpo-*,react-native-*,*-react-native(-*),@react-native-*/*and@scope/react-native*or@scope/*-react-native*package, any name in the contract'ssdkPackagePatterns, and (whennode_modulesexists) any package that ships native code. A local Expo module (modules/<name>/) is an SDK too: only its adapter imports it. Pure logic gets no port. Why: Jest never touches native code, a vendor swap touches one file, and a new SDK forces a decision instead of slipping into a screen. (vendor-sdk,unclassified-sdk,port-triple,banned-package) - One composition root creates the adapters; fakes stay in tests.
createDeviceServices()builds each adapter once; only the root layout imports it, calls it in auseStateinitializer and rendersServicesProvider; code reads the ports withuseServices()or receives them as factory arguments; only the composition root imports an adapter (the data layer's device openersrc/data/open-device-*.tsand app-startup-and-bootstrap's root-kit registrysrc/services/create-root-kits.tsare part of it). Tests pass fakes throughcreateTestServices(), neverjest.mock()of the app's own modules; runtime code never imports a fake orcreateTestServices(the test-only entry may). Context carries dependencies, never state that changes. Why: one file lists every outside dependency, a render never rebuilds the device, and a test controls exactly the ports it touches. (adapter-import,composition-root,fake-import) - Nothing runs at import time. No module-level database open, adapter or services creation, store
(a zustand
create, a vanillacreateStore, a project factory such ascreateTasksStore()),AudioContext, direction switch or reload. The only module-level store is an OS mirror: a vanilla store with a plain initial state, fed by a platform listener. Why: a reload, a direction change and Fast Refresh re-run modules on iOS and Android, so module-level work runs twice and tests share state. (import-time-work) - App code never imports Node-world code; Node code loads only neutral files.
app.config.ts,plugins/andscripts/run in Node: app code never imports them (not evenimport type) or a Node built-in (an npm polyfill the app declares, such asbuffer, is a package). Asrc/file that Node code imports stays neutral: no JSX, no React Native or Expo import, relative imports with the extension. Expo Router server code (every+apifile undersrc/app, the root+middleware.ts, contractserverFilessuch assrc/server/**) runs on the server, never in the native bundle: it may use Node built-ins, imports onlysrc/core,src/api,src/config,src/data, port types,expo-serverandexpo-router/server, and app code never imports it. Why: a type-only import dragsprocessandnode:fsinto the app program, Node ignores tsconfig paths and cannot load Expo modules (verified:expo configfails), and server code runs where React Native and the device do not exist (the native bundle leaves+apiand+middlewareout, verified in expo-router 57.0.24). (node-world-import,node-builtin,node-import,no-tooling-import,server-boundary) - Imports resolve and never loop. No
../outside Node code (use./sibling,@/or@/assets/), no import that resolves to nothing, no cycle among value imports (type-only imports are erased and do not count). Why: parent paths hide which zone a file reaches, and a cycle makes a valueundefineddepending on which file loads first. (parent-import,unresolved-import,import-cycle) require()is for assets and the test-only gate. An asset is required by its file (require('@/assets/images/empty-list.png'), through the alias of rule 11; an extension the app adds to Metro'sresolver.assetExtsgoes into the contract'sassetExtensions). Test-only code has one way in: asrc/**/test-only.tsgate beside its entry,process.env.EXPO_PUBLIC_APP_VARIANT === 'production' ? null : require('./test-only-entry'); nothing else imports the entry, code the entry reaches never imports the gate, and other bundled code reaches atestOnlyFoldersfolder only throughTEST_ONLY(a debug route rendersTEST_ONLY?.DebugScreen;import typeis fine). The forms app-variants-and-environment sanctions pass too: debug tooling behind__DEV__(Reactotron,devOnlyRequires, anywhere in anif (__DEV__)block) and anotherEXPO_PUBLIC_*inclusion flag at itsrequire(). That skill owns the comparison's literal and polarity. Why: one gate is one place to prove the strip, and a module reached any other way ships in production (verified on iOS and Android exports). (test-only-gate,import-cycle)- The entry is thin and the aliases exist.
package.jsonmainisexpo-router/entry(or anindex.tsthat only imports, ending inexpo-router/entry);tsconfig.jsonmaps@/*tosrc/*;src/app/_layout.tsxexists. A folder alias beside@/*lives in three places: the roottsconfig.json("@/assets/*": ["./assets/*"], the Expo template default;"@/modules/*": ["./modules/*"]),jest.config.jsmoduleNameMapper('^@/assets/(.*)$': '<rootDir>/assets/$1'before'^@/(.*)$'; with no@/mapper of its own, jest-expo mapstsconfig.jsonpathsin their order, so list"@/assets/*"before"@/*"there), and for importssrc/tsconfig.json. Why: startup work belongs in the root layout and the composition root, where it is ordered and testable; every rule above assumes the alias; and Jest takes the first mapper that matches, so without the ordernpm testfails ("Could not locate module @/assets/... mapped as /src/$1") while tsc, Metro and both checkers pass. (app-entry,app-files) - When multi-package, apps never import each other and packages never import an app. Workspaces
apps/*+packages/*, each package recorded in the contract (pure,world), packages consumed as TypeScript source, one React, every native module in every app at one version. Why: one change in a package reaches every app, and autolinking (iOS and Android) sees only an app's own dependencies. (workspaces,manifest,one-react,native-deps,app-to-app,package-direction) - Stop and ask before changing the architecture. A new top-level folder, package, SDK classification,
ban, game-facing seam or exception is a decision for the developer: it changes
contracts/architecture.jsonand the same list ineslint.config.mjs(aGate-Change:trailer). Why: these choices bind every later change.
Workflow
-
Name the task and read its reference before writing code:
Task Read where a file goes; a new folder, screen, feature, API route, local Expo module or kind of file references/src-layout-and-placement.md an import across folders, a boundary lint error, a new SDK, a game's imports, Node code that imports app code, server code, an asset alias, the test-only gate references/dependency-zones.md a new or changed service, adapter, fake, the composition root, testing with fakes references/ports-and-adapters.md contracts/architecture.json,rn-skills.config.json, an exception, a moved folderreferences/contract-and-config.md a monorepo (apps and packages) references/multi-package.md -
New app or first port. Copy
templates/src/services/(the composition root; the clock, error-log and direction-guard ports) tosrc/services/, andtemplates/contracts/tocontracts/. The ports importsrc/core/result.ts,src/core/dates/date-key.tsandsrc/i18n/languages.ts: copy them fromtemplates/src/only when the app does not have them yet (their owners are error-handling-typed-results, seeded-determinism-and-golden-vectors and i18n-strings-and-catalogs). Mount the root as examples/root-layout.tsx shows. -
New port. Follow the add-a-port recipe in ports-and-adapters.md: copy the four files of
templates/port/tosrc/services/<concern>/(fill every__PLACEHOLDER__; the clock folder is the finished example), write the fake and its test first, then the adapter; add the port toServices,createDeviceServicesandcreateTestServices; add the SDK tovendorSdksincontracts/architecture.jsonand toVENDOR_SDK_PATHSineslint.config.mjs. A model hook then reads it as examples/use-tasks-model.ts does. -
New SDK or file. Classify every new SDK (
expo-*,react-native-*,*-react-native, a package with native code) before the first import: a side effect goes tovendorSdks(step 3), a UI building block or a read-only fact toportlessSdkswith its reason. Place every file by the placement table (images, fonts and sounds inassets/<kind>/, API routes assrc/app/**/<name>+api.ts, a local Expo module inmodules/<name>/behind an adapter); a new kind of file gets a row. The first@/assets/(or@/modules/) import adds the alias in all three places of rule 11 (the SDK 57 template lists"@/*"first intsconfig.json; move"@/assets/*"above it). Let Prettier format filled templates. -
Stop and ask the developer before a new top-level folder, workspace package, SDK classification, ban,
gameFacingseam orallowedImportsexception (rule 13), then record the decision incontracts/architecture.json. -
Check (validation loop). When
node_modulesexist:npm run lint,npm run typecheckandnpm test; after adding an SDK alsonpx expo install --check,npx expo-doctorandnpm ls react react-native(one version of each), which catch SDK version drift. Always, from the repo root:node ${CLAUDE_SKILL_DIR}/scripts/check-layout.mjs .andnode ${CLAUDE_SKILL_DIR}/scripts/check-boundaries.mjs .. Fix everyFAILline (file, rule, fix) and rerun until both printRESULT: PASS.
| Template | Destination in the app repo |
|---|---|
templates/src/services/services-context.tsx, create-device-services.ts, create-test-services.ts (+ tests) |
src/services/ (the composition root) |
templates/src/services/clock/, error-log/, direction-guard/ |
src/services/clock/, error-log/, direction-guard/ as they are |
templates/port/port.ts, vendor-port-adapter.ts, fake-port.ts, fake-port.test.ts |
src/services/<concern>/<concern>-port.ts, <library>-<concern>-adapter.ts, fake-<concern>.ts, fake-<concern>.test.ts |
templates/contracts/architecture.json, architecture.schema.json |
contracts/ |
templates/src/core/result.ts, templates/src/core/dates/date-key.ts, templates/src/i18n/languages.ts |
the same paths, only when missing |
Definition of done
- Every new file sits where the placement table says; a new kind of file got a row, and a new top-level folder, package, SDK classification, ban, seam or exception was approved and recorded in the contract.
- Route files only render a feature screen (or redirect), calling only router hooks and the feature's; a feature's model
hook is the code that reads stores and services;
src/uireceives data through props;src/coreimports onlysrc/core; a game imports no other game and only what it faces. - Every new outside service has its port, adapter, fake and fake test, is a member of
Services, is created once increateDeviceServices()(called by the root layout'suseStateinitializer) and stands in throughcreateTestServices()in tests; every SDK the app imports is invendorSdksorportlessSdks; no runtime file imports a fake orcreateTestServices. - Assets are required from
assets/<kind>/through@/assets/, mapped intsconfig.jsonand ahead of@/for Jest; test-only code is required only by asrc/**/test-only.tsgate (debug tooling behind__DEV__and other inclusion flags as app-variants-and-environment allows), and atestOnlyFoldersfolder is reached only throughTEST_ONLY. - API routes and middleware import no app runtime module or UI; nothing bundled imports them.
- No module-level side effect; no Node-world import in app code; files Node code imports are neutral.
-
npm run lint,npm run typecheckandnpm testpass; after a new SDK,npx expo install --checkandnpx expo-doctorpass andnpm ls react react-nativeshows one version of each. -
node ${CLAUDE_SKILL_DIR}/scripts/check-layout.mjs .printsRESULT: PASS. -
node ${CLAUDE_SKILL_DIR}/scripts/check-boundaries.mjs .printsRESULT: PASS
Neither checker prints SKIP lines. Without contracts/architecture.json each prints one note: line and
applies the defaults; a multi-package repo needs the contract. In a repo where no app depends on expo,
check-layout prints NOT APPLICABLE: expo is not a dependency in package.json (a pass).
Anti-patterns
- A
utils/,helpers/orcomponents/folder undersrc/. Pure logic goes tosrc/coreor its feature, shared UI tosrc/ui, a hook beside the feature that uses it (src/hooks/only once two features share it). - A route that holds its screen (
useState,useEffect, aFlatListand afetchinsrc/app/explore.tsx, as the SDK 57 template ships it). Move it tosrc/features/<feature>/with its model hook; the route renders it. const services = createDeviceServices()in a component or hook. Each render builds a new device; the root layout'suseState(createDeviceServices)builds it once.- A helper, hook or test next to the routes. Expo Router makes it a route and bundles it; keep
src/appto route files, and test screens and routes insrc/features/(<feature>-navigation.test.tsx). - A screen that imports
expo-iap(orexpo-sqlite/kv-store, which ESLint's bare-name ban misses). Take the port fromuseServices(); only the adapter imports the SDK. - Importing an adapter "just this once" from a store or a model hook. It hard-wires the device into the code and the test; ask for the port and let the composition root decide.
jest.mock('@/services/...')for the app's own modules. Pass a fake throughcreateTestServices(); root__mocks__/exist only for vendor SDKs that crash on import.import typefromapp.config.ts,plugins/orscripts/"because it is only a type". It still pulls Node into the app program; move the type to a neutral file insrc/config/.- A debug route that re-exports the debug screen (
export { DebugScreen as default } from ...). Expo Router bundles it, so the debug folder ships; importTEST_ONLYand renderTEST_ONLY?.DebugScreen. - A
require()of test-only code outsidesrc/**/test-only.ts, behind an importedIS_TEST_BUILD. Metro keeps the module in production bundles; reach it through the gate'sTEST_ONLY, whose literal comparison app-variants-and-environment checks. import logo from './logo.png'or an image undersrc/. There is no module to import; keep it inassets/images/and writerequire('@/assets/images/logo.png').- An
expo-*import with no decision behind it. Classify it first (vendorSdkswith a port, orportlessSdkswith the reason); "it is only one call" is how SDKs end up in screens. - An API route that imports a store, a screen helper or
react-native. It runs on the server; share schemas throughsrc/api/and logic throughsrc/core/, and let the app call the route over HTTP. import { start } from '@/modules/live-activity'in a model hook. A local Expo module is native code: put it behind a port and import it only from the adapter.export const useTasksStore = create(...),export const listsStore = createTasksStore()orconst db = openDatabaseSync(...)at module level. Create stores and adapters in factories the root layout calls once.- A game importing a store,
useServices()or another game. The host hands values in through the seam; shared pieces move tosrc/coreorsrc/ui. - Feature A reaching into feature B's files to save a copy. Move the shared piece down
(
src/ui,src/core,src/stores) or navigate to B's route. - A port for pure code. Ports wrap what cannot run in Jest (SDKs, device APIs, time); a pure function
stays a function in
src/core.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/src-layout-and-placement.md | The tree, what each folder holds and imports, the placement table (assets, the test-only gate), route files, the add-a-feature and add-a-folder recipes | Workflow step 1 (placement) |
| references/dependency-zones.md | The zone graph, what each zone may import, games, what enforces each edge (ESLint and checker), classifying SDKs, Node world and neutral files, assets and require(), the test-only gate (verified forms), cycles |
Workflow step 1 (imports) |
| references/ports-and-adapters.md | Why ports and when not, the port/adapter/fake anatomy, Result, the composition root, injection and testing rules, platform differences, the add-a-port recipe |
Workflow steps 1 to 3 |
| references/contract-and-config.md | Every key of contracts/architecture.json and rn-skills.config.json, the defaults, exceptions with reasons |
Workflow steps 1 and 5 |
| references/multi-package.md | When multi-package: workspaces, manifests, TypeScript-source packages, one React, native modules, zones across workspaces | Workflow step 1 (monorepo) |
templates/src/services/services-context.tsx |
Services, ServicesProvider, useServices() (the composition-root files and their tests are synced from the library; this skill owns them) |
New app; a new port |
templates/src/services/services-context.test.tsx |
Its tests (provider, missing provider) | With the context |
templates/src/services/create-device-services.ts |
The composition root: every device adapter, once | New app; a new port |
templates/src/services/create-device-services.test.ts |
Its tests | With the composition root |
templates/src/services/create-test-services.ts |
Test services: the given fakes, and stand-ins that throw on first use | New app; a new port |
templates/src/services/create-test-services.test.ts |
Its tests | With it |
templates/src/services/clock/ |
ClockPort, the system adapter, the fake and their tests (synced from the library; do not edit here) |
New app; the finished example of a port |
templates/src/services/error-log/ |
ErrorLogPort, the in-memory adapter, the fake and their tests (synced from the library; do not edit here) |
New app |
templates/src/services/direction-guard/ |
DirectionGuardPort, its expo-sqlite/kv-store adapter, the fake and its test (synced from the library; rtl-and-direction owns it; do not edit here) |
New app |
templates/port/ |
port.ts (a vendor-neutral port type, placeholders), vendor-port-adapter.ts (the only SDK importer, errors mapped to values), fake-port.ts (the in-memory fake with a call counter) and fake-port.test.ts |
A new port |
templates/contracts/architecture.json |
The app's starting architecture contract | New app; an architecture decision |
templates/contracts/architecture.schema.json |
Its JSON schema (editor help and the key reference) | With the contract |
templates/src/core/result.ts |
Result, ok, err, attemptAsync the port templates use (synced from the library; error-handling-typed-results owns it) |
Only when the app has none |
templates/src/core/dates/date-key.ts |
Calendar-day keys the clock uses (synced from the library; seeded-determinism-and-golden-vectors owns it) | Only when the app has none |
templates/src/i18n/languages.ts |
The app's languages and the Direction type the guard uses (synced from the library; i18n-strings-and-catalogs owns it) |
Only when the app has none |
| examples/root-layout.tsx | The root layout mounting the composition root | Workflow step 2 |
| examples/use-tasks-model.ts | A feature model hook reading ports and a store | Workflow step 3 |
| examples/architecture-multi-package.json | A contract for a repo with apps and packages | Multi-package repos |
scripts/check-boundaries.mjs |
Resolves every import (TypeScript and JavaScript) and checks zones, thin routes (imports and hooks), games, purity, vendor and unclassified SDKs, bans (with the install policy's doNotAdd), local Expo modules, adapters and the services factories, fakes, Node world, server code, require() and the test-only gate (its entry and testOnlyFolders), ../, unresolved imports and cycles |
Workflow step 6 |
scripts/check-layout.mjs |
Checks folders, route files, port triples, the composition root and how the root layout mounts it (directly or through an imported startup helper), placement, import-time work (OS mirrors allowed; with or without semicolons), the entry, the aliases (tsconfig and Jest order, explicit or derived by jest-expo), and the multi-package manifests | Workflow step 6 |
scripts/lib/architecture.mjs |
Defaults + contract + layout, and the zone of every file | Only when changing a checker |
scripts/lib/import-graph.mjs |
Builds and resolves the import graph, Node-world reach, cycles | Only when changing a checker |
scripts/lib/source-scan.mjs |
Dependency-free lexer and import resolver (synced from the library; do not edit here) | Only when changing a checker |
scripts/check-lib.mjs |
Shared script helper (synced from the library; do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves both checkers pass the good fixtures and catch every planted bug | After changing a checker or the defaults |
assets/architecture-defaults.json |
The default folders, SDK name patterns, vendor and portless SDKs, bans, adapter, composition-root, game-facing, Node-world and server globs, the server packages, the local-modules folder, the gate's variant, the asset extensions | When a default changes (with eslint.config.mjs) |
assets/template-placeholders.json |
Values that fill the port templates in the template harness | When the port templates change |
assets/shared.json |
Declares the shared files this skill copies in | When adding a shared file |
tests/ |
The Tasks-app and multi-package bases and the planted-bug cases (overlay format) | When adding a rule to a checker |
Related skills
typescript-and-lint-rules- the ESLint zones, vendor and ban lists, and the two TypeScript programs.naming-conventions- names of the files, ports, adapters and fakes placed here.expo-project-bootstrap- creating the app with this layout from empty.error-handling-typed-results- theResulttype and failure kinds ports return.persistence-and-migrations- theSqlDriverport, repositories and a persistent error-log adapter.state-management-zustand- stores made by factories and created once.app-startup-and-bootstrap- the order of work in the root layout around the composition root.unit-and-component-tests-renderWithProvidersovercreateTestServices(); route tests withrenderRouterWithProviders.app-variants-and-environment- the gate's literal comparison, inclusion flags,devOnlyRequires, the bundle proof.dependency-management-contracts/dependency-policy.json, whosedoNotAddthis skill also bans.debug-menu-and-test-hooks- what the test-only entry exports.expo-prebuild-and-config-plugins-app.config.tsand local config plugins inplugins/.navigation-and-routing- route files, layouts and typed routes.game-session-host- the games plugin's seam between a game module and the app; it records the seam's files ingameFacing.accessibility-as-code- the OS mirror store (src/ui/a11y/system-a11y-store.ts) rule 7 allows.react-components-and-hooks- the shared cross-feature hooks insrc/hooks/.skia-canvas-rendering- the canvas kit insrc/canvas/.native-modules-and-new-architecture- writing the local Expo module thatmodules/<name>/holds.web-support- API routes, EAS Hosting and the web stylesheet.ota-updates- theUpdatesPortoverexpo-updates.