Shared Client Architecture
Status: Active Last updated: 2026-06-27 Reference implementation: 8b2e6329 (docs: record session-host payment authority (00303) + manual-confirm debuggability)
This guide describes how TwoMore shares business logic between mobile and web while keeping platform-specific routing, build tooling, and shell concerns isolated.
Current Shape
TwoMore is a universal app, but it is not one undifferentiated codebase. The split is intentional:
| Layer | Owns | Must not own |
|---|---|---|
apps/mobile | Expo Router file routes, tab stacks, native-only shell wiring | Business logic, reusable screen logic, repositories |
apps/web | Next.js App Router pages, web shims, webpack aliases, metadata | Business logic, reusable screen logic, repositories |
packages/features/* | Feature screens and feature-local presentational helpers | Platform routers, app-private imports, other feature packages |
packages/app | Public client API, domain/application/ports/adapters, hooks, routes, i18n, shared domain-aware components | Platform app shells |
packages/ui | Pure Tamagui primitives and layout primitives | Domain entities, feature decisions, data fetching |
The target mental model:
apps/mobile routes ┐
├─ mount feature screens
apps/web pages ┘
packages/features/* ── imports only ──> @twomore/app + @twomore/ui
@twomore/app ──> presentation hooks/components
├─> domain/application/ports/adapters
├─> routes + appRouter facade
└─> i18n/config/domain exportsPublic Client API
Feature packages should import shared app behavior from @twomore/app, not from app source aliases or sibling features.
Use:
import {
appRouter,
routes,
useAppRouter,
useRouteParams,
useAuth,
useClub,
useJoinClub,
SessionCard,
} from '@twomore/app';Do not use from feature packages:
import { router } from 'expo-router';
import { useRouter } from 'next/navigation';
import { clubs } from '@/registry';
import { SessionCard } from '@twomore/sessions';
import { SessionHeader } from '@twomore/home';If a feature needs shared data or a mutation that is not exported yet, add a hook or curated export in packages/app/src/index.ts. Do not export low-level repository registries just to unblock a screen.
Navigation
Navigation is centralized behind the app facade:
routesbuilds canonical route strings.appRouter.push/replace/back/canGoBacksupports event handlers and non-hook code.useAppRouter()supports React components.useRouteParams<T>()reads route params without exposing the underlying platform router.
Implementation:
packages/app/src/navigation/app-router.tsis the only allowedexpo-routerimport inside shared app code.- Web still aliases
expo-routertoapps/web/lib/expo-router-shim.tsinapps/web/next.config.ts, but feature packages should not importexpo-routerdirectly. - App shell route files may use platform router APIs because they are platform shell code. Keep those files thin.
When adding a route:
- Add or reuse a
routes.*helper inpackages/app/src/navigation/routes.ts. - Mount the feature screen from both
apps/mobile/app/**andapps/web/app/**as needed. - Keep route files limited to param extraction, redirects, and screen mounting.
- Use
appRouteroruseAppRouterinside shared feature code.
Feature Package Rules
Feature packages are intentionally router-agnostic and sibling-agnostic.
Allowed imports from packages/features/*/src:
- React and React Native primitives when needed for rendering.
@twomore/appfor domain types, hooks, mutations, i18n, routes, navigation, and shared domain-aware components.@twomore/uifor design-system primitives.- Feature-local relative imports.
- External UI libraries already used by the feature, such as
@tamagui/lucide-icons.
Forbidden imports from packages/features/*/src:
expo-routernext/*@/app-private source aliases- Another feature package such as
@twomore/home,@twomore/sessions, or@twomore/records - Direct adapters, registries, or repository instances
Feature package package.json files must not depend on other feature packages.
Shared Components
Domain-aware shared components live in @twomore/app, not @twomore/ui, because they know about app entities, routes, i18n, or hooks.
Examples of shared app components:
SessionCardSessionHeaderMatchupItemMatchScoreboardPlayerIdentityCardPlayerIdentityChipVenueCardVenueRowVenueLinkQueryBoundaryNetworkBannerEloTierBadgeTrustTierBadgeTierInfoSheetDevPanel
@twomore/ui remains domain-free and owns reusable primitives such as:
MainTabShellDetailShellSegmentedTabsFeedListGroupedFeedListSectionBlockBottomCtaBandCard,Button,Badge,Text, layout stacks
Temporary compatibility re-exports still exist in older feature paths for low-churn migration, but new code should import shared session display components from @twomore/app.
Data Flow
Feature screens should use hooks and mutations, not repositories:
const { data: club } = useClub(clubId);
const joinClub = useJoinClub({
onSuccess: (member) => appRouter.replace(routes.club(member.clubId)),
});Rules:
- Server state belongs to TanStack Query hooks in
packages/app/src/presentation/hooks/queries. - Writes belong to mutation hooks in
packages/app/src/presentation/hooks/mutations. - Multi-repository orchestration belongs in
packages/app/src/application/usecases. - Repositories stay behind app hooks, use cases, or adapters.
- Zustand is for client-only state such as theme, wizard drafts, and UI preferences.
Architecture Fitness Tests
The boundary rules are enforced by packages/app/src/config/__tests__/architecture-archunit.test.ts.
The tests fail when:
domain,application, orportsimports UI/platform packages.- Feature source imports
expo-router,next/*, or app-private@/paths. - Feature source imports another feature package.
- A feature package depends on another feature package.
- Shared app code imports
expo-routeroutside the navigation adapter allowlist.
Run:
yarn workspace @twomore/app test --runInBand src/config/__tests__/architecture-archunit.test.ts
yarn checkTransitional Notes
- Some feature
tsconfig.jsonfiles still contain an@/* -> ../../app/src/*path alias. This is currently needed so TypeScript can resolve@twomore/appsource internals before we add declaration emit or project references. Feature source code is still forbidden from importing@/; the architecture test enforces that. - Legacy route names remain where they are compatibility surfaces. Example:
/activity/matchesredirects to/records/history;/rankingredirects to/records. - Low-level names such as
leaderboardmay remain when they literally mean rank comparison. Top-level user-facing navigation should use기록/Records.
Adding New Shared Functionality
Use this checklist before adding code:
- If the code is pure UI with no domain knowledge, put it in
@twomore/ui. - If it knows about entities, i18n, routes, hooks, or app data, put it in
@twomore/app. - If only one feature needs it, keep it feature-local.
- If two features need it, promote it to
@twomore/appinstead of importing across feature packages. - If a screen needs navigation, use
routesplusappRouteroruseAppRouter. - If a screen needs data, add or reuse a query/mutation hook in
@twomore/app. - Run
yarn checkbefore pushing.
Validation Baseline
For architecture-sensitive work, use this baseline:
yarn workspace @twomore/app typecheck
yarn typecheck
yarn check
yarn workspace @twomore/web build
yarn workspace @twomore/mobile preflight
yarn workspace @twomore/mobile expo export --platform ios --output-dir /tmp/twomore-ios-export
yarn workspace @twomore/mobile expo export --platform android --output-dir /tmp/twomore-android-exportBefore publishing an OTA update, also smoke-check key web routes:
/home/clubs/clubs/discover/activity/activity/pickups/records/records/leaderboard/records/history/ranking->/records/leaderboard->/records/leaderboard