Appearance
ADR-0012 · Monorepo structure, package boundaries & documentation standard
Status: 🟢 Accepted — greenfield frontend rebuild; structure + conventions finalized 2026-07-20 · Depends on: ADR-0011 (two-stack frontend), ADR-0009 (archetype platform), ADR-0007 (entitlements) · Related: Tech Stack
The one-line thesis
QRSETU is one monorepo (npm workspaces): two apps — apps/web (React Router v8, DOM) and apps/mobile (Expo/RN) — over a set of bounded, one-way-dependent packages (schemas → domain → data, plus tokens, utils, i18n, analytics, observability) and shared tooling/. The frontend is rebuilt greenfield onto this structure; the Supabase backend is unchanged. Every folder ships a README.md, kept in sync with its code.
Context
The frontend has no production constraint — it can be treated as a true greenfield rebuild — so this is the moment to establish an enterprise-grade structure that minimises future refactoring. Two hard facts bound it:
- The backend is real. The Supabase schema, RLS, Edge Functions, and migrations are captured and carry live data (
qr-setu-prod). They are UI-agnostic and stay —supabase/is untouched by this ADR. - Digital Menu is the one production feature. It is preserved as-is and re-homed onto the E-commerce archetype in R2 (ADR-0009); it is not rebuilt in this structure now.
The current src/ Vite SPA becomes reference, not something we migrate file-by-file.
Decision
1. One monorepo, npm workspaces
One git repo containing several independently-buildable packages linked by npm workspaces (built-in — no new tool, zero-burn). Not polyrepo (would force publishing @qrsetu/* on every change — untenable solo). Turborepo (free, OSS) is added later only if build times justify caching. Expo has first-class monorepo support (Metro watchFolders + nodeModulesPaths), so the mobile side is a supported path, not a workaround.
2. Directory structure
qrsetu/
├─ apps/
│ ├─ web/ Stack 1 — React Router v8 (SSR, DOM, shadcn)
│ │ ├─ app/{landing,public,admin}/ the 3 web tiers as route groups
│ │ ├─ features/{name}/ feature UI (loaders/actions, components, __tests__, README)
│ │ ├─ ui/ shadcn primitives + per-tier overrides
│ │ ├─ env.ts Zod-validated VITE_* env
│ │ └─ README.md
│ └─ mobile/ Stack 2 — Expo/React Native (merchant product = "user" tier)
│ ├─ app/ Expo Router routes
│ ├─ features/{name}/ feature screens/components (RN + NativeWind), __tests__, README
│ ├─ ui/ RN primitives (gluestack candidate)
│ ├─ env.ts Zod-validated EXPO_PUBLIC_* env
│ ├─ .maestro/ E2E flows
│ └─ README.md
├─ packages/ runtime libraries (shipped code)
│ ├─ schemas/ types + Zod + enums + constants (data contracts) [no deps]
│ ├─ domain/ entitlement/RBAC/vertical/archetype resolvers, rules [→ schemas]
│ ├─ data/ Supabase client, RPC/EF services, TanStack Query hooks, EDGE_FN maps, query keys [→ schemas, domain]
│ ├─ analytics/ typed event vocabulary + tracker (ADR-0010) [→ schemas]
│ ├─ observability/ logger + error-boundary contract (Sentry-ready) [no deps]
│ ├─ i18n/ locale catalogs + t() (English R1, regional-ready) [no deps]
│ ├─ utils/ ₹ currency, dates, slugs, pure helpers [no deps]
│ └─ tokens/ design tokens → Tailwind config + NativeWind theme [no deps]
├─ tooling/ dev-time configs (not shipped)
│ ├─ typescript-config/ base / react-library / expo / rr8 tsconfigs
│ ├─ eslint-config/ shared flat config + guardrail rules
│ └─ tailwind-config/ shared preset (consumes tokens)
├─ supabase/ unchanged — migrations, functions, tests, config
└─ documentation/portal/ unchanged (VitePress)3. Package boundaries — a one-way, acyclic dependency graph
Rule [ENFORCED]: apps → packages → schemas; nothing in packages/ imports from apps/; no cycles. schemas, utils, tokens, i18n, observability are leaves. This is the invariant that keeps the codebase navigable as it grows to 6 domains × 5 archetypes. (Enforced by an ESLint boundaries rule in tooling/eslint-config.)
4. Where the four tiers live
The tiers were only ever "one SPA's folders." They split across the two apps:
| Tier | Auth? | SEO? | Lives in | Stack |
|---|---|---|---|---|
| landing | no | yes | apps/web/app/landing | RR8 (DOM) |
| public | no | yes | apps/web/app/public | RR8 (DOM) |
| admin | yes | no | apps/web/app/admin | RR8 (DOM) + PWA |
| user / merchant | yes | no | apps/mobile (the whole app) | Expo/RN |
A feature spans the split: e.g. BioLink's public page → apps/web/app/public, its merchant editor → apps/mobile/features/biolink, and its model + Zod + service + query hooks → packages/. Data logic is written once (in packages); only UI is written twice (shadcn/DOM vs RN/NativeWind), governed by shared packages/tokens.
5. Conventions [ENFORCED]
- Feature anatomy (both apps):
features/{name}/{components, hooks, <routes|screens>, __tests__, README.md}. A feature is presentation + app glue only — data logic belongs inpackages/data, not the feature. - State: server state → TanStack Query (
packages/data); client/UI state → Zustand, per-app. Never mix. - Imports: packages are
@qrsetu/*(workspace, never published to npm); in-app alias@/. Every package/module exposes a barrelindex.tsas its public API — import the package, not deep paths. - Env: each app has a Zod-validated
env.ts; rawimport.meta.env/process.env/EXPO_PUBLIC_*access is banned. - i18n: all user-facing strings route through
t()from@qrsetu/i18nfrom day one; R1 ships English (Hindi optional), regional languages added later as catalogs — no string refactor. - Naming, data-access, migrations, design-system invariants: unchanged — see
CLAUDE.md.
6. Documentation standard — README.md everywhere [ENFORCED, non-negotiable]
Every app, package, tooling module, and feature directory carries a README.md from this template:
markdown
# <name>
**Purpose** — what this is and why it exists (1–3 sentences).
**Responsibilities** — what it owns; and explicitly what it does NOT own (boundaries).
**Structure** — key files/subdirs and what each holds.
**Usage** — how to import/use it, with a short example.
**Dependencies** — what it depends on; who depends on it.
**Conventions & notes** — gotchas, invariants, links to ADRs.
> Living doc: update this in the same PR that changes this folder. A stale README is a bug.Enforcement, two layers:
- Presence (automated):
tools/check-readmes.jsin CI — fails if anyapps/*,packages/*,tooling/*, orfeatures/*directory lacks aREADME.md. (Added with the workspace skeleton.) - Freshness (human): a PR-checklist item + CODEOWNERS ("README updated?"). Accuracy can't be fully automated; the Definition of Done in
CLAUDE.mdmakes it a merge requirement.
Sequencing (greenfield)
- Skeleton — workspace root,
tooling/, emptypackages/*with README + barrel,apps/mobilescaffold. - Mobile first (Track B) —
apps/mobile(Expo + Router + NativeWind) is the first real app on the structure; seedpackages/{schemas,data,tokens}with the first shared slice; prove Jest/RNTL + Maestro + EAS Update. - Web (later) —
apps/webrebuilt on React Router v8 (landing/public/admin); the currentsrc/SPA is reference. - Digital Menu (R2) — preserved, re-homed onto the E-commerce archetype.
Consequences
- Up-front cost: more
package.json/tsconfig/README overhead than a singlecore. Accepted — the enforced boundaries are precisely what prevents the spaghetti we're escaping, and splitting later is the refactor we're avoiding. - Two UI implementations remain (the accepted cost of ADR-0011); the shared token package + component-parity checklist keep them in lockstep.
- Backend continuity:
supabase/is untouched; no data migration risk from this restructure.
Amendments
A1 · Tier-first feature layout inside each app (2026-07-22)
The original §2/§4 sketch put features flat on mobile (apps/mobile/features/{name}) and expressed tiers only as web route-groups. Building the first real feature (merchant onboarding / WelcomeStory) surfaced that this loses the explicit tier axis the legacy SPA had (src/tiers/{landing,public,admin,user}/features/{name}) and makes an app that hosts more than one tier awkward. Amendment: both apps organize tier-first — apps/{app}/src/tiers/{tier}/features/{name}/{screens|routes, components, hooks, __tests__, README.md} — with a tier-agnostic src/ui/ (primitives) and src/stores/ (Zustand) alongside src/tiers/.
- Tier → app:
apps/web/src/tiers/{landing,public,admin};apps/mobile/src/tiers/{user, admin(placeholder)}. There is deliberately noapps/web/user— the merchant web/PWA is theapps/mobileRNW export (ADR-0011). - Parity: twinned domains pair
apps/web/src/tiers/public/features/{name}(viewer) ⇄apps/mobile/src/tiers/user/features/{name}(editor); non-UI logic shared inpackages/(written once, UI twice). - Rationale: matches the legacy mental model, gives 1:1 web↔mobile mapping, and makes a future native
admintier a zero-refactor drop-in.tools/check-readmes.jsupdated to gate READMEs across the tier-first tree. - Tracked: QRS-004 (tier-first layout) and QRS-145 (README-everywhere skeleton for both apps).
Open questions
- Turborepo — adopt when build/test times justify caching (deferred; npm workspaces first).
- EF ↔ package type-sharing — Edge Functions run on Deno; sharing
@qrsetu/schemastypes across the Deno/npm boundary needs an import strategy (deferred; EFs keep their own types for now). - Portal home —
documentation/portal/could move toapps/docslater for uniformity (low priority).