Bridge Training
Contributing

Architecture

Package naming and dependency rules, target graph, the training core layout, the player kit, the API wire, the apps, and the database and i18n strategies.

This page describes the architecture of the Bridge Training monorepo and the principles behind it. Package names are those of the target state: until a rename lands, the former name is given in parentheses.

Packages

Naming rule

A package is named by what it is, never by its technical layer. The word core is not used: the bare package is the pure one.

FormContentExamples
<subject>Pure TypeScript, no React or DOMbridge, training, rich-text, shared
uiNeutral design system (Radix + Tailwind), no business subjectui
ui-<subject>React component library for a subjectui-bridge, ui-training, ui-rich-text
<subject>-assetsStatic files consumed at build timebridge-assets
capabilityEverything else: the name of what the package doesdatabase, queue, emails, player, bridge-dds, api-mock

No package is called *-core, *-react, domain or dds.

Inventory

PackageFormer nameRole
bridgebridge-coreGame rules, engine, parsers, deal validation, notation (cards, bids, play)
trainingdomainProduct model: hexagonal core (domain / application)
rich-textrich-text (model, lib part)Rich-text model: canonical types, HTML conversion, diff, localized texts
sharedPure cross-cutting utilities (logger, dates and durations, errors, arrays)
uiNeutral design system
ui-bridgebridge-reactComponents, hooks and contexts for bridge display and input
ui-trainingtraining-reactProduct-model components, driven by ViewModels
ui-rich-textrich-text (Plate part)Plate.js editor, components and plugins
shared-reactCross-cutting React hooks and helpers (Strings Provider, error boundaries)
playerbridge-playerPlayer runtime: engine, phases, generic controllers, narrator, chrome
bridge-ddsddsDouble-dummy solver (WASM + worker)
bridge-assetsassetsCard faces and decks, copied into apps at build time
api-contractThe API wire: XxxApi types, DTOs, errors, zod schemas, domain → DTO mappers, route tables, headers, DTO factories
api-clientAPI fetch clients, HTTP client and error rehydration
api-mockAPI mocks at the HTTP boundary, by named scenario
database, queue, emails, payment, auth, …Infrastructure adapters and third-party integrations
configShared presets: vitest, tsconfig, static-copy helper
e2e-harnessE2E harness: the api booted as an isolated fixture (startApiForE2e, Playwright presets). Apps start the api for their tests, never the other way round

Content rule

  • <subject> = pure TypeScript. Any logic without React goes into the subject's package, even when it only serves a form or editing: deal validation and notation live in bridge (sub-exports bridge/validation, bridge/format), usable server-side.
  • ui-* = React / DOM only: components, hooks, contexts, Strings Provider, ViewModels, DOM utilities. A ui-* holds no business rule that would stand without React.
  • A ui-* family, not a single package. Merging ui, ui-bridge and ui-training would put zustand, motion and the player engine in the type and test graph of every app, invalidate the design system's cache on every bridge component change, and reduce the ViewModel boundary to a per-folder lint. The common setup (tsconfig, vitest) goes through the config presets.
  • Merge the mechanism, not the packages. branding and bridge-assets share the static-copy helper from config but stay separate: bridge-assets is a build-time node context whose tens of MB stay out of the hash inputs of a tested package.

Dependency rule

Primitives imported, product model injected.

  • bridge, rich-text and shared are system libraries: no I/O, imported directly by anyone, the core and the UI included.
  • training is the product model: infrastructure adapters reach it through its ports; the UI receives it through ViewModels built in the apps.
  • bridge-dds has I/O (WASM, worker). If a server rule needs it, it goes through a DoubleDummySolver port in application/ports, never through a direct import.

Forbidden:

ForbiddenReasonGuard
domain ↛ application ↛ infraInside training, the domain knows nothing of use cases; the core knows no adapter (database, queue, payment, …)pnpm check
ui-training ↛ trainingA shared component describes its data with a ViewModel, not with the product model (see the repository's docs/patterns/view-model.md)pnpm check
ui-bridge ↛ playerui-bridge is a display library; the player is what assembles itpnpm check
ui-bridge ↛ trainingSame reason as for ui-trainingpnpm check
ui-rich-text ↛ trainingSame reason as for ui-trainingpnpm check
<subject> ↛ ReactA pure package imports neither React nor a ui-*convention (review)
api ↛ api-client, api-mock ↛ api-clientThe server and the mocks implement the wire; only consumers go through the fetch clientspackage.json (review)

Target graph

apps (app, learn, admin, api, web, player, docs)
 │
 ├─► player ──────────► ui-bridge ──► ui
 │     │                    │
 │     └──────────────────► bridge
 │
 ├─► ui-training ─────► ui-bridge, ui
 ├─► ui-rich-text ────► ui
 │
 ├─► database, queue, ─► training ──► bridge, rich-text, shared
 │   emails, payment…   (ports)
 │
 ├─► bridge-dds ──────► bridge
 └─► bridge-assets (build)

ui-* ──► bridge, rich-text, shared, shared-react   (primitives: allowed)
ui-* ─✗─ training                                  (model: forbidden)

Arrows always point from the caller to the callee. Only the apps assemble training, the infrastructure and the UI; they own the DTO → ViewModel mappers.

Layout of the training core

Layer first, module second: the only rule to lint is that domain never imports application, and the modules (boards, sessions, collections, entitlements, …) are too intertwined to be the first boundary.

packages/training/src/
├── domain/
│   └── <module>/            # entities, value objects, errors, pure rules
├── application/
│   ├── <module>/            # services and use cases, input DTOs, query specs
│   │   └── ports/           # the module's repositories
│   └── ports/               # cross-cutting ports (queue, event bus, email sending, billing…)
└── testing/                 # port fakes for consumers' tests
  • domain: entities, value objects, errors, pure rules (no repository or port), domain events the day there are any. No events/ folder while the only events are the event-bus port's notifications.
  • application: ports, orchestration services, input DTOs, query specs (Where, Sort, Projection, Patch, per-entity queries).
  • Nothing else at the root of src/; every file belongs to a module.
  • Internal imports by direct relative path, never through the package's root barrel.
  • Entry points per layer and module, one path per file: @workspace/training/domain/<module>, @workspace/training/application/<module> (the module's services, DTOs, specs and repositories), @workspace/training/application/ports, @workspace/training/testing, @workspace/training/factories. The @workspace/training root only exposes the cross-cutting part of domain/shared (errors, ErrorCode, ServiceContext, languages, localized fields).

The player as a kit

player is a runtime, not a black box: engine, phases, generic phase controllers, narrator and chrome. The feel of the public player (cadence, intro, ads, i18n, analytics) stays in apps/player and is injected.

A player variant (editor preview, player embedded in another app) is another assembly of player + ui-bridge, never an option added to a closed player. The direction is player → ui-bridge → ui, bridge.

The API wire

api-contract is the single description of the wire, shared by its three implementations: the Hono server, the fetch clients and the mocks.

apps/app, apps/learn ─→ api-client ─→ api-contract
apps/api ───────────────→ api-contract
api-mock ───────────────→ api-contract
  • api-contract carries the whole wire: XxxApi types and per-endpoint declared errors, DTOs, zod schemas for bodies and responses, domain → DTO mappers (toXxxDto), route tables ({param} patterns, RouteDef, RouteTable, PathParams, buildPath) and header names (X-BT-Impersonation, X-BT-Session). Header names and protocol identifiers are contract constants, never injected configuration. It also exposes the DTO factories (/factories, see The /factories and /testing exports).
  • apps/api derives its OpenAPI schemas from the contract's (.openapi(name), server refinements by extension) and serves what the contract's mappers return. It does not depend on api-client.
  • Routes are derived, never retyped. A Hono createRoute takes its method and path from the contract's table through contractRoute(table.name) (apps/api/src/lib/contract-route.ts), so the module carries the full pattern (/deal-collections/{id}) and mounts at /; a middleware that covers the whole module uses routeBasePath(table) followed by /*. Because the module mounts at /, a middleware it registers must be scoped to a path (routeBasePath(table) + /*, or a route's getRoutingPath()), never use("*"): Hono re-registers it at /* on the parent and it would run for every module mounted after. Two table entries may describe one Hono route (a list and its paginated variant share GET /deal-collections): the coverage test apps/api/__tests__/lib/route-contract.test.ts checks both directions, every entry of every table is served and every route served under a table's base is one of its entries, not a one-to-one mapping.
  • api-client only keeps the fetch clients, the HTTP client and error rehydration.
  • Paths live in the tables only: the Hono router mounts every module at / and derives its middleware globs from routeBasePath; a plain Hono route outside createRoute (the player's proxy) renders a pattern with renderPattern, the contract knowing nothing of Hono's :param syntax. A contract test checks that every route of the tables is served by the router.

Dev mode without a backend

api-mock intercepts the API at the HTTP boundary: the contract's route tables, named scenarios with an actor, seeded faker data (the contract's DTO factories), in-memory collections. It does not reimplement the core: an endpoint added to the contract without a mock fails to compile.

Apps

AppRole
appTeacher app (Vite SPA + TanStack Router) on app.bridge-training.com
learnLearner space (Vite SPA + TanStack Router) on learn.bridge-training.com: sign-in, learner plan, subscription. Future learner hub (history, progress)
playerPublic player (Vite + Hono SSR host)
apiHTTP API (Hono)
webMarketing site (Next, static export)
adminBack-office (Next)
docsAPI and product documentation (Next)
emailsPreview of the email templates of @workspace/emails (React Email dev server), on demo data from the factories

Teacher and learner are not two audiences of the account: the same account can open app and learn. What sets them apart is the plan grid each app sells (teacher, learner); accounts.signup_source keeps the app the account signed up from, fixed at creation; the account's audience (teacher, learner), derived from it by audienceOf, decides its space: each app opens its content only to the accounts of its audience and shows the others a screen pointing to their app (UX only, the API does not filter on the space). Both apps share the Clerk instance on the parent domain.

Architectural principles

Bridge types vs application types

type Card = `${'2'|'3'|...|'A'}${'S'|'H'|'D'|'C'}`;
type Bid = '1C' | '1D' | ... | 'Pass' | 'X' | 'XX';
type Vulnerability = 'None' | 'NS' | 'EW' | 'Both';

Bridge types (bridge) are stable: they are the rules of the game. Application types (training) evolve with the product, hence the ViewModel boundary on the UI side.

Primitives in the UI: OK

Components may import and handle bridge types:

import type { Card, Bid } from '@workspace/bridge';

function CardDisplay({ card }: { card: Card }) { ... }
function BidBox({ onBid }: { onBid: (bid: Bid) => void }) { ... }

What is not OK: making a contextual business decision in a presentational component.

import { canPlayBid } from '@workspace/bridge';

function BidBox() {
  if (canPlayBid(player, context)) { ... }
}

Decisions stay in hooks, controllers or services; the component receives the result.

i18n strategy

Decision: apps own i18n; packages never call useTranslation.

  • Messages live in @workspace/i18n-messages, one namespace per business domain (common, errors, teaching, deals, bridge, …), loaded on demand by the app.
  • The namespaces of shared components (primitives, rotation, rich-editor) are copied into the app at build time through createI18nCopyTargets.
  • A ui-* component receives its copy through a Strings Provider (English defaults), which the app fills with its translations.
const { Provider, useStrings } = createStringsProvider<CardSelectorStrings>(
  defaultCardSelectorStrings
);

function CardSelector() {
  const { placeholder } = useStrings();
  return <span>{placeholder}</span>;
}
import { useTeachingTranslation } from '@/hooks/useTranslation';

function GroupsPage() {
  const { t } = useTeachingTranslation();
  return <h1>{t('groups.title')}</h1>;
}

Override: the consumer can override translations by merging an i18n namespace.

Rationale: an internal FFB ecosystem where everything already uses i18n. No need to be agnostic.

See i18n strategy and the repository's docs/patterns/strings-provider.md for the detailed conventions (namespaces, hooks, when to create a namespace vs. a key prefix).

Database strategy

PostgreSQL enums

Rule: when in doubt → TEXT + runtime Zod validation.

Kind of dataStrategyExample
Bridge rules (immutable)DB enumplayer_position, vulnerability_type
Application logic (evolving)TEXT + Zodteaching_type, share_status

DB enums to keep (will never change):

  • player_position (N, S, E, W)
  • vulnerability_type (NONE, ALL, NS, EW)
  • counter_status (none, doubled, redoubled)
  • contract_level (partial, game, small_slam, grand_slam)
  • player_side (defense, attack)

TEXT + Zod (may evolve):

  • teaching_type (FFB teaching modes)
  • share_status (private, public, shared, ...)
  • scoring_type (TPP, IMP, ...)
  • auction_system_type (natural, strong_club, ...)

Zod validation

export const teachingTypeSchema = z.enum([
  'show-trump-goal', 'show-trump',
  'show-contract', 'petit-bridge', // ...
]);

export type TeachingType = z.infer<typeof teachingTypeSchema>;

API layer

Supabase access is centralized in apps/app/src/api/.

const api = createApi(supabaseClient);

const api = useApi();
const groups = await api.groups.list();

Rules:

  • No React component calls Supabase directly
  • Every response is validated with Zod
  • Errors are mapped to AppError classes

The wire itself, route tables, body and response schemas and what derives from them, is on API contract.

Adapter packages

The rule for building an adapter package (named by capability, vendor inside, port in training, entries by runtime, vanilla config, tests at three levels) is on Adapter packages.