Clients
The same app, built three times
The mobile companion to the seven backends: one ticketing flash-sale client implemented in Kotlin Multiplatform, Flutter and React Native. Same contract, same seven-screen flow, same modelled states. Each app receives only a base URL and is blind to which backend answers. Everything client-side lives in /apps; the single-sourced contract, tokens, scenarios and copy live in /shared.
Kotlin Multiplatform
Compose Multiplatform · Ktor · multiplatform ViewModel
iOS (arm64 + simulator) and Android compile · 35 tests pass on the Android host · Android APK builds
- laid out to the official CMP template: a shared :sharedUI module, a :androidApp app, an iosApp Xcode project
- multiplatform ViewModels drive the seven screens; the payment reconcile lives in the tested use cases
- the whole UI, state and previews live in commonMain and are shared verbatim across Android and iOS
Flutter
Bloc/Cubit · dio · Material 3
flutter analyze clean · 23 tests pass · web bundle builds
- Cubits hold the state machines; the order cubit owns the reconcile-and-poll loop
- defensive dio mappers reject malformed payloads as MalformedResponse
- a widget test proves the state-driven UI renders loading, success and error
React Native (Expo)
zustand · TanStack Query · ky · New Architecture
typecheck clean · 19 tests pass
- reads cached through TanStack Query; stateful flows held in vanilla zustand stores
- the ky executor collapses every failure into one typed AppError
- the same seven screens and error copy as the other two, expressed in TSX
Why it is shaped this way is recorded in ADR 0007 and 0008: three clients one contract, and sharing artefacts not source.
Offline-first, no infinite loading
All three behave the same way. On start (and on Retry) a bounded reachability probe hits {baseUrl}/health with a short timeout and resolves to online or offline — it never hangs. A banner surfaces server status; the flow renders from local state and stays usable offline. Every network call carries a timeout, so each async state resolves into a modelled state — never an endless spinner.
One place to set the endpoint
Each app reads only a base URL. Point it at the gateway through an external HTTPS tunnel — the address a real phone (and the simulator) can reach and trust. Run make up && make tunnel (ngrok, or Cloudflare Tunnel) and use https://<your-tunnel-host>/api, set in one place per app:
- KMP —
config/AppConfig.kt - Flutter —
lib/config/app_config.dart(or--dart-define=BASE_URL) - React Native —
src/config/appConfig.ts(orEXPO_PUBLIC_BASE_URL)
Never point a device at a local IP: https://localhost/api only works on the same machine and an Android emulator would need https://10.0.2.2/api — both dev-only. See the tunnel recipe.
Demo or real, with a secure session
Each app runs on in-memory demo data by default (no server needed). Flip one flag (USE_REAL_BACKEND) and it talks to the gateway instead: real HTTP repositories, a login screen, and a session that refreshes and rotates its token on a 401. The refresh token is kept in the platform secure store — iOS Keychain, Android EncryptedSharedPreferences, expo-secure-store — behind one TokenStore port, so a signed-in session survives a restart. See the token-refresh recipe.
Client architecture
Three apps, one architecture. Kotlin Multiplatform, Flutter and React Native express it
in their own idioms, but the layering is identical, and it is the same layering the
backend lab uses. If you have read architecture.md, this is that,
pointed at a screen instead of a database.
The layers
Screen / Page (Atomic Design)
│ observes state, sends events
State Holder ViewModel (KMP) · Bloc/Cubit (Flutter) · hook + store (RN)
│ calls
Use Case pure business logic, no UI framework, unit-testable
│ depends on
Repository Port an interface; maps DTOs → validated domain models
│ implemented by
Data Source Adapter HTTP client → the API Gateway base URL
Rules that hold in every app:
- The base URL is injected. It is the only thing the app knows about the backend. No app contains a branch on "which backend is this". It consumes the OpenAPI contract and nothing else. See the recipe on consuming the injected base URL.
- Use cases return a typed Result, never throw across a layer boundary. Success or a typed error from the taxonomy; those are the only two outcomes.
- Repositories validate. A DTO generated from the contract is raw input. The
repository turns it into a domain model or into a
MalformedResponseerror. Invalid data does not propagate as a half-populated object. - Cross-cutting concerns live in HTTP middleware: auth token attachment, request-id propagation, retry, timeout, logging. One place per app, not sprinkled per call.
Why this shape
Business logic that does not import a UI framework is business logic you can test in milliseconds without a simulator. The state holder becomes a thin translator between "what the use case returned" and "what the screen shows". The screen becomes a pure function of state. Each layer is boring in isolation, which is the goal; interesting code is code that pages you at 3am.
Atomic Design
Components are organised atoms → molecules → organisms → templates → pages. Atoms hold no business logic. State flows down, events flow up. Every atom, molecule and organism ships a preview covering its states (default, loading, error, disabled, empty), and the preview catalog is itself a deliverable — see the "component with previews" recipe.
- Atoms: Button, Text, Input, Badge, Spinner, Icon, CountdownTimer
- Molecules: FormField, SectorRow, QueuePositionCard, PriceTag, ErrorBanner, RetryPanel
- Organisms: EventCard, SectorList, ReservationSummary, OrderStatusPanel, WaitingRoom
- Templates: header/content/footer scaffolds with loading/error overlays
- Pages: the seven flow screens
State model
Every async operation is one of Idle | Loading | Success | Empty | Error | Retrying | TimedOut. Every error is a typed taxonomy value carrying a code, a request_id, a
message and a recovery affordance. The full model and the two domain state machines are
drawn in client-state-machines.md.
Per-platform mapping
| Concern | KMP | Flutter | React Native |
|---|---|---|---|
| State holder | ViewModel + StateFlow | Bloc/Cubit (or Riverpod) | hooks + Zustand |
| Networking | Ktor + kotlinx.serialization | dio + generated models | ky/fetch + generated types |
| Server-state cache | store + repository cache | repository cache | TanStack Query |
| Secure storage | multiplatform settings + Keychain/Keystore | flutter_secure_storage | expo-secure-store |
| Previews | @Preview in commonMain | @Preview / widgetbook | Storybook for RN |
The generated DTOs, the tokens, the scenarios and the copy all come from
/shared. Nothing in that list is written three times.
Client state machines
The apps are state machines wearing a UI. Two of them are worth drawing: the reservation hold and the order lifecycle. Both are modelled identically in all three apps; only the syntax differs.
There is also the generic async-operation state, which wraps every network call.
Async operation (every network call)
Every call resolves into exactly one of these. Nothing is inferred from a stray null.
Error always carries a typed error (see shared/copy/errors.json),
a request_id from the response, a message, and a recovery affordance. TimedOut is
kept distinct from Error because "the server said no" and "the server said nothing"
call for different words and, for payment, different behaviour entirely.
Reservation hold
A hold is a promise with a stopwatch attached. It is created with an Idempotency-Key
so a double-tap cannot create two, and it expires on its own if checkout is too slow.
status maps to the contract's Reservation.status enum: held | confirmed | released | expired. Creating and CountingDown are client-side refinements the server does
not name. The double-tap defence lives entirely in Creating: the second tap reuses the
same Idempotency-Key and gets the same reservation back.
Order lifecycle
The order is where the money is, so this is where the care goes. The server owns
pending | paid | failed | refunded. The client adds one state the server never sends:
Unknown, for when a request times out and the outcome is genuinely unresolved.
The crucial edge is Creating --> Unknown --> Polling. A timeout does not go to
Failed. It goes to Unknown, and the only way out is polling until the server states
an outcome. Because the create carried an Idempotency-Key, any retry is safe. This is
the payment-unknown-outcome scenario, and it is the difference between a lab and a
liability.
Failed is terminal from the app's side (recover by starting a new order). Paid can
still move to Refunded if a refund arrives later. A delayed webhook simply means
Polling runs longer; the app does not give up early.