Skip to content

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:

LayerOwnsMust not own
apps/mobileExpo Router file routes, tab stacks, native-only shell wiringBusiness logic, reusable screen logic, repositories
apps/webNext.js App Router pages, web shims, webpack aliases, metadataBusiness logic, reusable screen logic, repositories
packages/features/*Feature screens and feature-local presentational helpersPlatform routers, app-private imports, other feature packages
packages/appPublic client API, domain/application/ports/adapters, hooks, routes, i18n, shared domain-aware componentsPlatform app shells
packages/uiPure Tamagui primitives and layout primitivesDomain entities, feature decisions, data fetching

The target mental model:

text
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 exports

Public Client API

Feature packages should import shared app behavior from @twomore/app, not from app source aliases or sibling features.

Use:

ts
import {
  appRouter,
  routes,
  useAppRouter,
  useRouteParams,
  useAuth,
  useClub,
  useJoinClub,
  SessionCard,
} from '@twomore/app';

Do not use from feature packages:

ts
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 is centralized behind the app facade:

  • routes builds canonical route strings.
  • appRouter.push/replace/back/canGoBack supports 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.ts is the only allowed expo-router import inside shared app code.
  • Web still aliases expo-router to apps/web/lib/expo-router-shim.ts in apps/web/next.config.ts, but feature packages should not import expo-router directly.
  • App shell route files may use platform router APIs because they are platform shell code. Keep those files thin.

When adding a route:

  1. Add or reuse a routes.* helper in packages/app/src/navigation/routes.ts.
  2. Mount the feature screen from both apps/mobile/app/** and apps/web/app/** as needed.
  3. Keep route files limited to param extraction, redirects, and screen mounting.
  4. Use appRouter or useAppRouter inside 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/app for domain types, hooks, mutations, i18n, routes, navigation, and shared domain-aware components.
  • @twomore/ui for 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-router
  • next/*
  • @/ 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:

  • SessionCard
  • SessionHeader
  • MatchupItem
  • MatchScoreboard
  • PlayerIdentityCard
  • PlayerIdentityChip
  • VenueCard
  • VenueRow
  • VenueLink
  • QueryBoundary
  • NetworkBanner
  • EloTierBadge
  • TrustTierBadge
  • TierInfoSheet
  • DevPanel

@twomore/ui remains domain-free and owns reusable primitives such as:

  • MainTabShell
  • DetailShell
  • SegmentedTabs
  • FeedList
  • GroupedFeedList
  • SectionBlock
  • BottomCtaBand
  • Card, 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:

ts
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, or ports imports 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-router outside the navigation adapter allowlist.

Run:

bash
yarn workspace @twomore/app test --runInBand src/config/__tests__/architecture-archunit.test.ts
yarn check

Transitional Notes

  • Some feature tsconfig.json files still contain an @/* -> ../../app/src/* path alias. This is currently needed so TypeScript can resolve @twomore/app source 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/matches redirects to /records/history; /ranking redirects to /records.
  • Low-level names such as leaderboard may 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:

  1. If the code is pure UI with no domain knowledge, put it in @twomore/ui.
  2. If it knows about entities, i18n, routes, hooks, or app data, put it in @twomore/app.
  3. If only one feature needs it, keep it feature-local.
  4. If two features need it, promote it to @twomore/app instead of importing across feature packages.
  5. If a screen needs navigation, use routes plus appRouter or useAppRouter.
  6. If a screen needs data, add or reuse a query/mutation hook in @twomore/app.
  7. Run yarn check before pushing.

Validation Baseline

For architecture-sensitive work, use this baseline:

bash
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-export

Before 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

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