Architecture Conventions
Status: Active Last reviewed: 2026-06-30
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 layers - Writing to
packages/features/*→ import shared logic from@twomore/app; never importexpo-router,next/*, app-private@/, or another feature package - Cross-feature reuse → promote to
@twomore/appif domain-aware,@twomore/uiif domain-free
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> --no-verify-jwtafter push. OTA only deploys the client app, NOT edge functions
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.