Skip to content

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 import expo-router, next/*, app-private @/, or another feature package
  • Cross-feature reuse → promote to @twomore/app if domain-aware, @twomore/ui if 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 public table the client reads/writes via supabase-js needs GRANT SELECT, INSERT, UPDATE, DELETE ON public.<table> TO authenticated; (add anon ONLY 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 new public table 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 the queryList(query, mapper, context) / querySingle(query, mapper) stream helpers from packages/app/src/adapters/supabase/base.adapter.ts — they fold the await → handleError → safeMap (list) and await → 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) keep assertFound — 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 (import Tables, TablesInsert, TablesUpdate from '../generated.types'). Mind the real table key (e.g. court_venues not venues, invites not club_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 in toDomain add ?? <DB default> (grep the migration's DEFAULT) 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 values as XRow['field']. JSONB columns arrive as Json | null (narrow/validate at read). For a FK-joined read, intersect: Tables<'sessions'> & { club?: …; venue?: … }. For a toRow that sets a subset of an Insert, use Pick<TablesInsert<'table'>, …>. Regenerate after schema changes via yarn 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 like FriendshipWithProfileRow = FriendshipRow & {…}. Never hand-roll a NEW Row interface.)

Edge functions

  • Changing supabase/functions/ → deploy with npx supabase functions deploy <name> --no-verify-jwt after 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 by useAllHydrated() 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.

Markdown remains the source of truth. Run yarn docs:check before handoff.