0014 - Adopt a Single Client Codebase with a Platform Capability Seam
Status: Accepted Date: 2026-08-11 Author: Manuel Nucci
Context
FPC targets three interfaces: Android, web, and iOS. Their priority is not equal. The MVP ships as an Android application through the Play Store. The web surface is reduced to the invitation and landing entry point rather than a full product surface, and iOS follows only if the product demonstrates traction.
ADR 0002 chose React and Capacitor over React Native and framed FPC as a web-first application. The product ordering has since inverted, but that technology decision is unchanged and is if anything better supported by an Android-first MVP: one React codebase, wrapped by Capacitor into native shells. ADR 0002 did not answer a question that now matters, and this ADR answers it.
The open question is whether interfaces whose user experience diverges warrant separate applications in the monorepo, and where platform-specific behavior belongs when they do not.
The product roadmap makes part of that question concrete rather than hypothetical. FPC intends to serve venues managing their free slots, and tournament organizers, as segments beyond players and teams. Those are separate audiences doing different jobs with largely disjoint feature sets, and each may eventually want its own domain and its own mobile application. The architecture must therefore make a second and third client application cheap to add later without paying for them now, while none of those products exists, has a specification, or has demonstrated demand.
What determines that cost is not the apps/ layout, which can be changed at any time. It is whether the layers a future application would reuse remain extractable. Two of them already are: cross-runtime contracts live in packages/shared, and the design system is extractable today almost by accident, since components/ui/ imports nothing from stores, services, query hooks, or screens, and components/custom/ does so in only two files. That property is easy to lose silently as components accumulate, and expensive to recover afterwards.
Web cannot be dropped even in an Android-first MVP. The invitation flow depends on it: a person receives an invite link, opens it without the application installed, and must see what they are joining before installing. That flow is live at /invite/:code in apps/app/src/App.tsx, and web invite routes must continue to work directly.
Two properties of the current implementation constrain the decision:
apps/appproduces a single Vite bundle. Capacitor ships that identical bundle into the native WebView, so web and Android do not diverge in build target, framework, or dependency graph at all today.- The layers below the screens are already separated. Server state is isolated in TanStack Query hooks under
apps/app/src/hooks/queries/, client state inapps/app/src/stores/, domain helpers inapps/app/src/lib/, and cross-runtime contracts inpackages/shared. Only two files outside the query hooks call TanStack Query directly.
The GCP and WorkOS migration introduces the first genuinely platform-divergent behavior in the product. Per ADR 0008, authentication needs a browser redirect on the web and a system browser, a verified deep link, and platform secure storage on Android. A decision about where that code lives cannot be deferred.
Decision
Keep one client application for every interface, and confine platform variation to an explicit seam.
apps/appremains the single client application. Capacitor ships it to Android, and later to iOS, from the same source and the same build.- Introduce
apps/app/src/platform/as a capability seam. Each capability declares one interface, has a web implementation and a native implementation, and is selected at runtime through Capacitor's platform detection. Authentication is its first inhabitant; deep links, secure storage, push notifications, camera, and share follow as they are needed. - Platform detection is confined to that seam and to route-level screen selection. Screens, hooks, stores, domain helpers, and reusable components never branch on the platform directly.
- Where user experience genuinely diverges, select the screen component at the route while sharing the query hooks, stores, and contracts beneath it. Reserve this for divergence that responsive styling cannot express.
- Reusable components keep their two existing layers, and both stay platform-agnostic:
components/ui/holds shadcn primitives andcomponents/custom/holds FPC components. - Cross-runtime contracts stay in
packages/shared. Do not extract an API-client package or a UI package until a second client application actually exists, and extract them at that moment rather than earlier. - Keep the design system extractable by rule instead of by habit. Neither
components/ui/norcomponents/custom/may import fromstores/,services/,hooks/queries/, orpages/; a component that needs application state receives it through props or a hook supplied by its caller. Enforce this with a lint boundary rule rather than review convention, because the cost of recovering the property grows with every component added. - Serve every future segment from one API. Venues and tournaments become NestJS modules under
apps/api/src/modules/, not separate services, and authorization for them stays FPC-owned in PostgreSQL rather than moving into WorkOS Organizations, preserving the provider independence ADR 0008 establishes. - Give each future segment its own client application, its own domain, and its own Capacitor application identifier when it needs one. A person who belongs to more than one segment remains a single FPC user with separate membership records per segment, because application-owned identity makes that a domain change rather than an identity migration.
- Create a second application only when one of these triggers fires: a distinct audience emerges whose feature set is largely disjoint from the current one, such as a venue, club, or league administration console; the invitation and landing surface requires server-side rendering or real search-engine visibility; an interface requires a navigation paradigm that route-level screen selection cannot express cleanly; or the Android bundle becomes dominated by screens only the web uses. The first split, if it happens, is a public invitation and landing application, while the Capacitor client keeps the authenticated single-page experience.
- Do not treat a differing form factor as a trigger on its own. Team roles such as organizer, manager, and substitute are affordances inside shared screens, not separate audiences, and splitting on them would produce applications that share almost every screen.
- Ship Google as the only identity provider for the Android MVP. Add Sign in with Apple before any App Store submission, because App Store Review Guideline 4.8 requires an equivalent privacy-preserving option once a third-party login is offered. Google Play imposes no equivalent requirement, so deferring Apple does not block the Android launch.
Consequences
Positive
- One codebase, one build, and one test suite continue to serve every interface, so a feature reaches Android and web without being written twice.
- Platform-specific behavior has exactly one home, which makes it discoverable, individually testable against a fake implementation, and cheap to extend when iOS arrives.
- The boundary between shared and divergent code is drawn against a real second consumer rather than guessed in advance, so the seam that eventually appears reflects observed need.
- Divergent user experience is possible immediately through route-level screen selection, without the cost of a second application.
- Extracting shared packages stays a mechanical move, because domain logic, server state, and contracts already sit outside the screen layer.
Negative
- A single bundle serves both interfaces, so web users download screens they never open and Android users download the invitation and landing screens.
- Route-level screen selection can multiply screens if applied liberally, producing two components that drift apart while appearing to share a route.
- Deferring the API-client and UI package extractions means a future split does that work under time pressure rather than in advance.
- A venue or tournament application will find that parts of the design system do not generalize, because business-facing scheduling needs primitives such as calendars, availability grids, and dense tables that the current player-facing set does not contain.
- One application means one release cadence for behavior, even though the Play Store and the web deploy through different pipelines and review delays.
Mitigations
- Treat bundle composition as a measurable trigger rather than a worry: route-based code splitting addresses it first, and a second application only when measurement justifies it.
- Require that route-level screen selection share the query hooks, stores, and domain helpers beneath it, so divergence is confined to presentation and cannot silently fork behavior.
- Keep the layer boundaries the codebase already has, since they are what makes a later extraction mechanical; treat business logic appearing inside a screen component as a defect.
- Extract the shared packages when the second application is specified, so the boundary is drawn against its real needs, and accept that segment-specific primitives belong to that segment until a third consumer proves otherwise.
- Deploy the web surface from the same source continuously while the Play Store release proceeds on its own cadence, and keep the API authoritative so an older client cannot bypass a rule.