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.
| Form | Content | Examples |
|---|---|---|
<subject> | Pure TypeScript, no React or DOM | bridge, training, rich-text, shared |
ui | Neutral design system (Radix + Tailwind), no business subject | ui |
ui-<subject> | React component library for a subject | ui-bridge, ui-training, ui-rich-text |
<subject>-assets | Static files consumed at build time | bridge-assets |
| capability | Everything else: the name of what the package does | database, queue, emails, player, bridge-dds, api-mock |
No package is called *-core, *-react, domain or dds.
Inventory
| Package | Former name | Role |
|---|---|---|
bridge | bridge-core | Game rules, engine, parsers, deal validation, notation (cards, bids, play) |
training | domain | Product model: hexagonal core (domain / application) |
rich-text | rich-text (model, lib part) | Rich-text model: canonical types, HTML conversion, diff, localized texts |
shared | Pure cross-cutting utilities (logger, dates and durations, errors, arrays) | |
ui | Neutral design system | |
ui-bridge | bridge-react | Components, hooks and contexts for bridge display and input |
ui-training | training-react | Product-model components, driven by ViewModels |
ui-rich-text | rich-text (Plate part) | Plate.js editor, components and plugins |
shared-react | Cross-cutting React hooks and helpers (Strings Provider, error boundaries) | |
player | bridge-player | Player runtime: engine, phases, generic controllers, narrator, chrome |
bridge-dds | dds | Double-dummy solver (WASM + worker) |
bridge-assets | assets | Card faces and decks, copied into apps at build time |
api-contract | The API wire: XxxApi types, DTOs, errors, zod schemas, domain → DTO mappers, route tables, headers, DTO factories | |
api-client | API fetch clients, HTTP client and error rehydration | |
api-mock | API mocks at the HTTP boundary, by named scenario | |
database, queue, emails, payment, auth, … | Infrastructure adapters and third-party integrations | |
config | Shared presets: vitest, tsconfig, static-copy helper | |
e2e-harness | E2E 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 inbridge(sub-exportsbridge/validation,bridge/format), usable server-side.ui-*= React / DOM only: components, hooks, contexts, Strings Provider, ViewModels, DOM utilities. Aui-*holds no business rule that would stand without React.- A
ui-*family, not a single package. Mergingui,ui-bridgeandui-trainingwould 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 theconfigpresets. - Merge the mechanism, not the packages.
brandingandbridge-assetsshare thestatic-copyhelper fromconfigbut stay separate:bridge-assetsis 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-textandsharedare system libraries: no I/O, imported directly by anyone, the core and the UI included.trainingis the product model: infrastructure adapters reach it through its ports; the UI receives it through ViewModels built in the apps.bridge-ddshas I/O (WASM, worker). If a server rule needs it, it goes through aDoubleDummySolverport inapplication/ports, never through a direct import.
Forbidden:
| Forbidden | Reason | Guard |
|---|---|---|
domain ↛ application ↛ infra | Inside training, the domain knows nothing of use cases; the core knows no adapter (database, queue, payment, …) | pnpm check |
ui-training ↛ training | A 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 ↛ player | ui-bridge is a display library; the player is what assembles it | pnpm check |
ui-bridge ↛ training | Same reason as for ui-training | pnpm check |
ui-rich-text ↛ training | Same reason as for ui-training | pnpm check |
<subject> ↛ React | A pure package imports neither React nor a ui-* | convention (review) |
api ↛ api-client, api-mock ↛ api-client | The server and the mocks implement the wire; only consumers go through the fetch clients | package.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' testsdomain: entities, value objects, errors, pure rules (no repository or port), domain events the day there are any. Noevents/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/trainingroot only exposes the cross-cutting part ofdomain/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-contractapi-contractcarries the whole wire:XxxApitypes 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/factoriesand/testingexports).apps/apiderives 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 onapi-client.- Routes are derived, never retyped. A Hono
createRoutetakes itsmethodandpathfrom the contract's table throughcontractRoute(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 usesrouteBasePath(table)followed by/*. Because the module mounts at/, a middleware it registers must be scoped to a path (routeBasePath(table)+/*, or a route'sgetRoutingPath()), neveruse("*"): 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 shareGET /deal-collections): the coverage testapps/api/__tests__/lib/route-contract.test.tschecks 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-clientonly 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 fromrouteBasePath; a plain Hono route outsidecreateRoute(the player's proxy) renders a pattern withrenderPattern, the contract knowing nothing of Hono's:paramsyntax. 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
| App | Role |
|---|---|
app | Teacher app (Vite SPA + TanStack Router) on app.bridge-training.com |
learn | Learner space (Vite SPA + TanStack Router) on learn.bridge-training.com: sign-in, learner plan, subscription. Future learner hub (history, progress) |
player | Public player (Vite + Hono SSR host) |
api | HTTP API (Hono) |
web | Marketing site (Next, static export) |
admin | Back-office (Next) |
docs | API and product documentation (Next) |
emails | Preview 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 throughcreateI18nCopyTargets. - 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 data | Strategy | Example |
|---|---|---|
| Bridge rules (immutable) | DB enum | player_position, vulnerability_type |
| Application logic (evolving) | TEXT + Zod | teaching_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
AppErrorclasses
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.