Skip to content

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 staysupabase/ 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:

TierAuth?SEO?Lives inStack
landingnoyesapps/web/app/landingRR8 (DOM)
publicnoyesapps/web/app/publicRR8 (DOM)
adminyesnoapps/web/app/adminRR8 (DOM) + PWA
user / merchantyesnoapps/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 in packages/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 barrel index.ts as its public API — import the package, not deep paths.
  • Env: each app has a Zod-validated env.ts; raw import.meta.env / process.env / EXPO_PUBLIC_* access is banned.
  • i18n: all user-facing strings route through t() from @qrsetu/i18n from 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:

  1. Presence (automated): tools/check-readmes.js in CI — fails if any apps/*, packages/*, tooling/*, or features/* directory lacks a README.md. (Added with the workspace skeleton.)
  2. Freshness (human): a PR-checklist item + CODEOWNERS ("README updated?"). Accuracy can't be fully automated; the Definition of Done in CLAUDE.md makes it a merge requirement.

Sequencing (greenfield)

  1. Skeleton — workspace root, tooling/, empty packages/* with README + barrel, apps/mobile scaffold.
  2. Mobile first (Track B)apps/mobile (Expo + Router + NativeWind) is the first real app on the structure; seed packages/{schemas,data,tokens} with the first shared slice; prove Jest/RNTL + Maestro + EAS Update.
  3. Web (later)apps/web rebuilt on React Router v8 (landing/public/admin); the current src/ SPA is reference.
  4. Digital Menu (R2) — preserved, re-homed onto the E-commerce archetype.

Consequences

  • Up-front cost: more package.json/tsconfig/README overhead than a single core. 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-firstapps/{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 no apps/web/user — the merchant web/PWA is the apps/mobile RNW 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 in packages/ (written once, UI twice).
  • Rationale: matches the legacy mental model, gives 1:1 web↔mobile mapping, and makes a future native admin tier a zero-refactor drop-in. tools/check-readmes.js updated 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/schemas types across the Deno/npm boundary needs an import strategy (deferred; EFs keep their own types for now).
  • Portal homedocumentation/portal/ could move to apps/docs later for uniformity (low priority).