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-Keyheader. - Every response carries an
X-Request-Idheader 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, becauseoffsetunder a moving dataset is a lie told slowly.
Where each concept lives
| Concept | Home |
|---|---|
| Virtual queue, distributed locks, hot cache, rate counters | Redis |
| Async payment, domain events | RabbitMQ |
| Circuit breaker / retry / timeout demo | Backend <-> fake payment gateway |
| TLS, edge rate limit, request-id injection, backend routing | API Gateway |
| Persistence, migrations, read/write split | PostgreSQL |
| Metrics, traces, logs, dashboards | OpenTelemetry, Prometheus, Grafana, Loki |
| Proof of no overselling under load | k6 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
| field | type | notes |
|---|---|---|
id | uuid | |
email | string | unique |
password_hash | string | argon2id or bcrypt, never plaintext, never logged |
role | enum | customer | admin |
created_at | timestamptz |
Event
| field | type | notes |
|---|---|---|
id | uuid | |
name | string | |
venue | string | |
starts_at | timestamptz | when the show starts |
sales_open_at | timestamptz | when the stampede starts |
status | enum | draft | on_sale | sold_out | closed |
Sector
| field | type | notes |
|---|---|---|
id | uuid | |
event_id | uuid | FK -> Event |
name | string | e.g. "Pista", "Camarote" |
price_cents | integer | money in cents, because floats and money are a bad marriage |
currency | string | ISO 4217, e.g. BRL |
total_inventory | integer | fixed capacity |
available_inventory | integer | the number everyone is fighting over |
QueueToken
| field | type | notes |
|---|---|---|
id | uuid | |
user_id | uuid | FK -> User |
event_id | uuid | FK -> Event |
position | integer | place in line |
status | enum | waiting | admitted | expired |
admitted_at | timestamptz | null until admitted |
Reservation
| field | type | notes |
|---|---|---|
id | uuid | |
user_id | uuid | FK -> User |
sector_id | uuid | FK -> Sector |
quantity | integer | how many seats are held |
status | enum | held | confirmed | released | expired |
expires_at | timestamptz | when the hold evaporates |
idempotency_key | string | unique per user; the anti-double-click device |
Order
| field | type | notes |
|---|---|---|
id | uuid | |
reservation_id | uuid | FK -> Reservation |
user_id | uuid | FK -> User |
amount_cents | integer | |
status | enum | pending | paid | failed | refunded |
created_at | timestamptz |
Payment
| field | type | notes |
|---|---|---|
id | uuid | |
order_id | uuid | FK -> Order |
provider_ref | string | reference from the fake gateway |
status | enum | pending | succeeded | failed |
attempts | integer | retries with backoff live here |
Invariants
These hold in every backend, under load, forever. They are the acceptance criteria the load test exists to break.
available_inventorynever goes negative. No overselling. Enforced by an atomic conditional update and/or a distributed lock, never by reading then writing and hoping.- A
heldreservation reserves stock for a TTL. When it expires, the stock returns toavailable_inventory. Nobody gets to squat on a seat forever. - 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. - No queue token, no checkout. A user only reaches
/reservationsif theirQueueTokenfor that event isadmitted. 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 -> confirmedis the only path that keeps the stock. Every other exit returns it to the sector.releasedandexpiredare 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
paidorfailedis driven by the payment webhook, whose signature is verified. An unsigned webhook changes nothing; it gets a401and a stern look. pending -> failed -> pendingis where retry-with-backoff and the circuit breaker earn their keep, because the fake gateway can be told to fail on demand.- A
paidorder flips its reservation toconfirmed. 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
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.
See also the decision records for why the structure is what it is.