Architecture Conventions
Status: Active Last reviewed: 2026-09-23
How TwoMore enforces layer boundaries, wire-up discipline, and store hygiene across the monorepo. The rules below are the canonical conventions for any code touching the hex-architecture layers (
domain/,features/*, adapters, mappers, edge functions, and Zustand stores). The machine-enforced subset lives in AGENTS.md (ARCH-1..ARCH-9); these conventions add the why and the how. If anything here conflicts with AGENTS.md, AGENTS.md wins.
Layer boundaries
Writing to
packages/app/src/domain/→ ZERO imports from outer layersWriting to
packages/features/*→ import shared logic from@twomore/app; never importexpo-router,next/*, app-private@/, or another feature packageCross-feature reuse → promote to
@twomore/appif domain-aware,@twomore/uiif domain-freeOne parser per route, beside its builder (F12, 2026-09-29). A route's URL is built by
routes.*and read by its parser innavigation/route-params.ts(parseJoinClubParams,parseCreateSessionParams); both platform wrappers (apps/mobile/app/*.tsx,apps/web/app/*/page.tsx) spread the parser's result into the screen and never map params by hand, and no caller appends query strings to a built URL. Hand-mapped wrappers drifted: web dropped the originating club and every venue preset.route-params.test.tsround-trips each builder through its parser and fails if a wrapper stops calling it.Presentation never imports adapters directly (hex/4).
packages/app/src/presentation/**reaches Supabase only via@/registry(ARCH-8) — and a value that LOOKS adapter-shaped but is actually a cross-cutting classifier (an idempotency-key check, a typed failure vocabulary) doesn't earn an adapter import either. It lives inpackages/app/src/lib/(canonical:lib/idempotency.ts) ordomain/errors/(canonical:domain/errors/venue-owner-claim-failure.ts), and presentation imports the classifier from there. Enforced by the ArchUnitTShex/4fitness function.The
@twomore/appbarrel exports hooks, use cases and pure modules — never a raw client or a registry port (F16, 2026-09-29).getSupabase,regularMeets,paymentandedgeFunctionsleft the barrel; features and app shells use a hook (useExportUserData,useAuth().signInWithEmail) or a narrow named function (resetOnboardinginlib/dev-tools.ts). ArchUnit derives the feature-package list frompackages/features/*with a canary (a hand list had covered 6 of 8).Where a rule, a use case and an adapter live (engineering pass wave 5, 2026-11-13). A pure rule (counting, ranking, windows, thresholds, a sort) lives in
packages/app/src/domain/rules/with its test beside it inrules/__tests__/, takes its clock and its words as arguments, and imports nothing outward; the words (t()) and the clock stay inpresentation, which calls the rule (player-stats,club-admin-attention,tier-bandskeep their words and re-export the moved names). The olddomain/utils/folder is gone: its modules are rules. A read or write that composes ports into one operation is a use case inapplication/usecases/(load-match-history.usecase.ts: its ports and word functions come in asdeps); the hook builds the deps from the registry. The date arithmetic is one module,domain/rules/local-date.ts(config/date-utils.tskeeps only display formatters).lib/never importspresentationor the registry: it exposes a registration function and the upper layer registers (setClientErrorSink,setLinkOpenFailureHandler).The registry is six groups plus the platform.
registry.tsis the composition root and lazy proxy; the wiring lives inregistry-{clubs,sessions,matches,people,venues,platform}.ts, each a table ofport: () => adapterentries typed by itsPortsinterface, built byregistry-build.tsin a fixed order after the sign-in service (the only construction-time dependency between entries: two adapters take it; every other factory defaults to the one memoisedgetSupabase()and no adapter imports the registry). Adapters stayrequire()d inside each entry on purpose (lazy, and the script tests stub them by specifier). A new port is one entry in its group and one name inregistry.ts's exports;registry-wiring.test.tsis the contract.A route's id is validated at the door.
[xxxId]route wrappers read it throughparseUuidParamand show the family's not-found state for a malformed one without mounting the screen;route-uuid-wrappers.test.tsscans every wrapper and fails on one that does not.
Write operations & tables
- New write operation → Decision tree: single table + own row = direct, otherwise = RPC
- New table → Same migration: CREATE TABLE + ENABLE RLS + policies + trigger protection + explicit data-API GRANT. Any
publictable the client reads/writes via supabase-js needsGRANT SELECT, INSERT, UPDATE, DELETE ON public.<table> TO authenticated;(addanonONLY if unauthenticated reads are intended — almost never) in the SAME migration. Do NOT rely on the Supabase platform default-privilege auto-grant — Supabase is removing the "public schema auto-exposed to the data API" default, so a newpublictable without an explicit GRANT is invisible to PostgREST / GraphQL / supabase-js EVEN WITH RLS policies (RLS gates rows; the table GRANT is what exposes the table to the data API at all). Tables touched ONLY via SECURITY DEFINER RPCs (never directly by the client) should have NO table grant — keep them off the data API. (Existing tables created before this change are grandfathered with their current grants; the discipline is for every new table going forward. Run the Supabase Security Advisor to audit which tables are currently exposed.)
Adapters & mappers
- New adapter → Explicit mapper in
mappers/,handleError(), column projection, LIMIT. For the find-list / find-single read shapes use thequeryList(query, mapper, context)/querySingle(query, mapper)stream helpers frompackages/app/src/adapters/supabase/base.adapter.ts— they fold theawait → handleError → safeMap(list) andawait → handleError → toDomain | null(maybeSingle) plumbing into one call while keeping the column projection + filters at the call site (Protocol K resilience preserved). Single-row reads that MUST exist (insert/update returning a row) keepassertFound— failure there is real and should throw, not return null. Writes / RPCs / count-head queries / join-shaped selects / pagination chains / Map-building reducers stay hand-rolled (the two helpers don't model those shapes). - Mapper Row types → source from
generated.types.ts(single source of truth for the live DB schema), never hand-rolled interfaces:export type XRow = Tables<'table'>for reads,TablesInsert<'table'>/TablesUpdate<'table'>for write shapes (importTables,TablesInsert,TablesUpdatefrom'../generated.types'). Mind the real table key (e.g.court_venuesnotvenues,invitesnotclub_invites). The generated types are STRICTER than hand-rolled ones — they expose true nullability + DB enums; the migration recipe (canonical:club.mapper.ts): swap the interface for the alias, then intoDomainadd?? <DB default>(grep the migration'sDEFAULT) for fields that are nullable in the schema but non-null in the domain entity, and in the mapper's test add now-required generated fields (idempotency_key,updated_at, …) to the mock builder + cast intentionally-invalid enum test valuesas XRow['field']. JSONB columns arrive asJson | null(narrow/validate at read). For a FK-joined read, intersect:Tables<'sessions'> & { club?: …; venue?: … }. For atoRowthat sets a subset of an Insert, usePick<TablesInsert<'table'>, …>. Regenerate after schema changes viayarn check:supabase-types. (Status 2026-05-26: COMPLETE — all 34 mappers source Row types from generated.types.ts; zero hand-rolled Row interfaces remain. The only hand-rolled shapes left are FK-join composites likeFriendshipWithProfileRow=FriendshipRow & {…}. Never hand-roll a NEW Row interface.)
Edge functions
- Changing
supabase/functions/→ deploy withnpx supabase functions deploy <name>after push (no--no-verify-jwt— see EDGE-AUTH-DECLARED below). OTA only deploys the client app, NOT edge functions. - EDGE-AUTH-DECLARED (owner ruling, 2026-09-23) — every edge function's gateway JWT check is a DECLARED fact in
supabase/config.toml, never a deploy-time flag. Each[functions.<name>]block statesverify_jwt = true|falseexplicitly, matching the live project (verify viasupabase functions list -o json);scripts/check-edge-function-auth.mjs(with its own canary test, run bycheck:static) fails on any function missing a declaration. Before this, 16 of 18 functions' JWT setting lived ONLY in the deployed project's dashboard state, and the previously-documented deploy command passed--no-verify-jwton every deploy — following it as written would have switched gateway auth OFF on user-facing functions (export-user-data,send-push,search-venues, …) the next time any of them was redeployed for an unrelated reason. Deploying without the flag once had already brokenstatic-map's intentionally-anonymous access — the fix is the declaration inconfig.toml, not the flag.
Fitness-function integrity
ARCH-BLIND (found 2026-09-23) — a fitness-function suite that stops seeing edges passes silently; it needs its own canaries. The ArchUnitTS suite (packages/app/src/config/__tests__/architecture-archunit.test.ts) had been checking NOTHING for a period: archunit 2.1 never followed tsconfig's extends, so every import resolution threw and was silently skipped (zero graph edges), and its content rules read FileInfo.content relative to the process CWD, which is empty when the suite runs from packages/app — every ARCH / hex / ui rule "passed" only because it had nothing left to check. Fixed by upgrading to archunit@^2.5.4 (follows extends) and reading file content from the repo root, plus two permanent canary tests (canary: the dependency graph resolves relative and @/ alias imports, canary: content rules read real source text) that fail the whole suite the moment it goes blind again. Seeing again, the suite immediately found real violations: the ARCH-2 deep-import rule was counting the public index.ts itself as a violator (498 false hits, rule fixed), two ARCH-4 leaks (TabAliasRedirect, the nav-perf hook — both now routed through app-router.ts), and the hex/4 violations documented above. A NEW fitness function needs a canary before it's trusted; this is the general lesson, not a one-off fix.
The one gate
GATE-ONE (owner ruling, 2026-09-23) — yarn check is THE gate for done, for the pre-push hook, and for CI; there is no second, looser gate anywhere in the pipeline. It runs docs, check:static (every static gate — cycles, sizes, policy-parity gates, i18n gates, clones, check:edge-function-auth, check:knip, …), typecheck, test, lint:strict, lint:agents-md, and verify:no-stale-claims. Before this ruling, the pre-push hook lacked the static gates and yarn check itself lacked lint, so a push could go green while yarn check run by hand was red — which is exactly how commit 3c18d142 shipped. AGENTS.md's AOR-10 single writer (one agent session writes a tree at a time; handoff = pushed + OTA'd + yarn check green) closes the other half of the same incident class: two writers on one tree can each see a locally-green gate that the other's uncommitted changes have already invalidated.
- The gate's typecheck runs TypeScript 7 (2026-09-30).
yarn typecheck=scripts/typecheck.mjs: the native compiler (typescript-7, an alias beside TypeScript 6) over every workspace whose owntypecheckscript runstsc(derived from the manifests). 6 min 44 s → 34 s. TypeScript 6 stays thetypescriptpackage because typescript-eslint requires < 6.1;yarn typecheck:ts6runs the old per-workspace path when parity is in doubt.typecheck-runner.test.mjscanaries both the compiler (a planted error must fail) and the target derivation.
Stores & hydration
- Providers whose props depend on persisted Zustand state → gate the root render on
_hasHydrated. Otherwise the app re-renders end-to-end after AsyncStorage rehydrates. - Every Zustand persist store MUST have both
partialize(omit action fns from serialization) and_hasHydrated(gate-able byuseAllHydrated()for global render-gate stores).
See also
- AGENTS.md — ARCH-1..ARCH-9 (machine-enforced architecture constraints).
- Shared Client Architecture — mobile/web sharing model and package boundaries.
- CLAUDE.md — orchestration core + the pointer index back to this doc.