Architecture

How the pieces connect

Architecture

One system, many backends, one frontend that has no idea which backend it is talking to and likes it that way.

The big picture

The frontend talks to exactly one host: the gateway. Which backend answers is a routing decision made in infra, not a line of code in the frontend. That is the whole trick, and everything in this repo is arranged to keep it true.

Layers inside every backend

Every backend, regardless of language, has the same shape. Only the idiom changes.

HTTP handler / controller   thin: parse, validate, call, serialize. No business logic.
        |
        v
use case / service          all the rules live here, testable without a web server
        |
        v
repository (port)           an interface. The domain depends on this, not on a driver.
        |
        v
adapter                     concrete Postgres / Redis / broker implementation, injected

Rules:

  • Business rules never leak upward into a controller or downward into an adapter.
  • The use case depends on a repository interface (a port). The Postgres adapter implements it. Dependency inversion, applied for real, not just cited in a standup.
  • You can unit-test a use case with in-memory fakes and no Docker running.

The star of the show is the reservation use case: idempotency guard, distributed lock, atomic stock decrement, TTL hold. It is deliberately the most heavily documented and tested path in each backend, because it is where every hard concept in the checklist shows up at once.

Cross-cutting contract rules

Enforced identically everywhere, defined once in /contract/openapi.yaml:

  • Every mutating endpoint accepts an Idempotency-Key header.
  • Every response carries an X-Request-Id header for tracing.
  • Errors use one envelope: { "error": { "code", "message", "request_id" } }. Internal details and stack traces stay on the inside where they belong.
  • Listings paginate by cursor, not by offset, because offset under a moving dataset is a lie told slowly.

Where each concept lives

ConceptHome
Virtual queue, distributed locks, hot cache, rate countersRedis
Async payment, domain eventsRabbitMQ
Circuit breaker / retry / timeout demoBackend <-> fake payment gateway
TLS, edge rate limit, request-id injection, backend routingAPI Gateway
Persistence, migrations, read/write splitPostgreSQL
Metrics, traces, logs, dashboardsOpenTelemetry, Prometheus, Grafana, Loki
Proof of no overselling under loadk6 scenarios in /infra/load

See docs/domain-model.md for entities and state machines, and docs/adr/ for why things are the way they are.

Domain model

The whole point of this lab is that the domain is identical in every language. The controllers change, the swagger changes, the smug tribal opinions about dependency injection change. The domain does not. Below is the one true model. If a backend disagrees with this document, the backend is wrong.

The domain is a flash sale: a popular event goes on sale, far more people show up than there are tickets, and everyone tries to buy at the same second. That single sentence is why this project needs a virtual queue, distributed locks, idempotency, a TTL on held stock, and asynchronous payment. Nothing here is decoration.

Entities

Field names are snake_case because that is what the API speaks. How each language stores them internally is its own business.

User

fieldtypenotes
iduuid
emailstringunique
password_hashstringargon2id or bcrypt, never plaintext, never logged
roleenumcustomer | admin
created_attimestamptz

Event

fieldtypenotes
iduuid
namestring
venuestring
starts_attimestamptzwhen the show starts
sales_open_attimestamptzwhen the stampede starts
statusenumdraft | on_sale | sold_out | closed

Sector

fieldtypenotes
iduuid
event_iduuidFK -> Event
namestringe.g. "Pista", "Camarote"
price_centsintegermoney in cents, because floats and money are a bad marriage
currencystringISO 4217, e.g. BRL
total_inventoryintegerfixed capacity
available_inventoryintegerthe number everyone is fighting over

QueueToken

fieldtypenotes
iduuid
user_iduuidFK -> User
event_iduuidFK -> Event
positionintegerplace in line
statusenumwaiting | admitted | expired
admitted_attimestamptznull until admitted

Reservation

fieldtypenotes
iduuid
user_iduuidFK -> User
sector_iduuidFK -> Sector
quantityintegerhow many seats are held
statusenumheld | confirmed | released | expired
expires_attimestamptzwhen the hold evaporates
idempotency_keystringunique per user; the anti-double-click device

Order

fieldtypenotes
iduuid
reservation_iduuidFK -> Reservation
user_iduuidFK -> User
amount_centsinteger
statusenumpending | paid | failed | refunded
created_attimestamptz

Payment

fieldtypenotes
iduuid
order_iduuidFK -> Order
provider_refstringreference from the fake gateway
statusenumpending | succeeded | failed
attemptsintegerretries with backoff live here

Invariants

These hold in every backend, under load, forever. They are the acceptance criteria the load test exists to break.

  1. available_inventory never goes negative. No overselling. Enforced by an atomic conditional update and/or a distributed lock, never by reading then writing and hoping.
  2. A held reservation reserves stock for a TTL. When it expires, the stock returns to available_inventory. Nobody gets to squat on a seat forever.
  3. Same Idempotency-Key, same result. Re-sending a reservation or order request with an already-seen key returns the original resource. It does not create a second one. Double-clicks are a fact of life, not an exception.
  4. No queue token, no checkout. A user only reaches /reservations if their QueueToken for that event is admitted. The waiting room is the pressure valve for the whole system.

Reservation state machine

A reservation is a short-lived promise: "this stock is yours if you pay in time."

Notes:

  • held -> confirmed is the only path that keeps the stock. Every other exit returns it to the sector.
  • released and expired are functionally identical for inventory; they differ only in who pulled the trigger (the user vs the clock).
  • There is no path back out of a terminal state. A released reservation is gone; the user queues again like everyone else.

Order state machine

An order is the money side. It is created optimistically and settled asynchronously, which is why the API returns 202 Accepted and the client polls.

Notes:

  • The transition into paid or failed is driven by the payment webhook, whose signature is verified. An unsigned webhook changes nothing; it gets a 401 and a stern look.
  • pending -> failed -> pending is where retry-with-backoff and the circuit breaker earn their keep, because the fake gateway can be told to fail on demand.
  • A paid order flips its reservation to confirmed. That is the one place the two state machines touch.

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 MalformedResponse error. 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

ConcernKMPFlutterReact Native
State holderViewModel + StateFlowBloc/Cubit (or Riverpod)hooks + Zustand
NetworkingKtor + kotlinx.serializationdio + generated modelsky/fetch + generated types
Server-state cachestore + repository cacherepository cacheTanStack Query
Secure storagemultiplatform settings + Keychain/Keystoreflutter_secure_storageexpo-secure-store
Previews@Preview in commonMain@Preview / widgetbookStorybook for RN

The generated DTOs, the tokens, the scenarios and the copy all come from /shared. Nothing in that list is written three times.

See also the decision records for why the structure is what it is.