Selling 20 units to 20,000 people in the same second — without ever overselling one.
🚀 Quickstart · 🏗 Architecture · 🔬 The Core Idea · 📡 API · 🎛 Admin Console · 🧯 Troubleshooting · 📄 Full Report
An online store built around one hard problem: a flash sale drops 20 units, and 20,000 people click Buy inside the same second.
The textbook approach — a SELECT … FOR UPDATE row lock inside a transaction — is correct,
and it is also a throughput cliff:
🐢 Naive SELECT … FOR UPDATE |
⚡ FlashX | |
|---|---|---|
| Where stock is decided | PostgreSQL row lock | Redis, single atomic Lua script |
| Concurrency shape | Every request serialises behind one lock | Single-threaded Redis, no lock contention |
| Connection pool | Exhausted under load | Never touched on the hot path |
| Request latency | Grows with queue depth | Bounded by one Redis round trip |
| DB write | Inside the request | Async, via RabbitMQ |
| Response | 200 OK after commit |
202 Accepted + correlation id |
FlashX moves the contended decision out of the database. Inventory is reserved by a single atomic Lua script in Redis; the order is persisted to PostgreSQL asynchronously through RabbitMQ. The customer waits only for the Redis reservation.
📄 Read the full engineering report → — architecture decisions, failure modes, verification results, and every environment gotcha found the hard way.
The entire nine-process stack — 3 backing services, 5 Spring Boot apps, 1 Next.js server — comes up from the repository root:
docker compose up -d --buildThen open http://localhost:3000. First boot takes ~2 minutes: Flyway creates the
auth, catalog, and orders schemas, seeds six products, and catalog-service pre-warms the
Redis counters automatically.
| 🔗 What | Where | Credentials |
|---|---|---|
| 🛍 Storefront | localhost:3000 | register any account |
| 🎛 Admin console | localhost:3000/admin | admin@flashx.local / flashx-admin |
| 🗂 Eureka registry | localhost:8761 | — |
| 🐰 RabbitMQ UI | localhost:15672 | flashx / flashx |
| 🚪 API gateway | localhost:8080 | JWT bearer |
docker compose ps # every service should read (healthy)
docker compose down # stop, keep data
docker compose down -v # stop + wipe volumes → fresh migration and reseedTip
Prefer running the Spring Boot apps from your IDE with a debugger attached? Bring up only the
backing services with docker compose up -d postgres redis rabbitmq, then see
Running it the long way.
flowchart TB
subgraph client["🖥 Client"]
UI["Next.js 16 Storefront + Admin<br/><b>:3000</b><br/><i>Server Components · Server Actions</i>"]
end
subgraph edge["🚪 Edge"]
GW["api-gateway<br/><b>:8080</b><br/><i>Spring Cloud Gateway · CORS · routing</i>"]
EUR["eureka-server<br/><b>:8761</b><br/><i>service registry</i>"]
end
subgraph svc["⚙️ Services"]
AUTH["auth-service<br/><b>:8081</b><br/>JWT · BCrypt · Google OAuth"]
CAT["catalog-service<br/><b>:8082</b><br/>products · sales · pre-warming"]
ORD["order-service<br/><b>:8083</b><br/>atomic checkout · consumers"]
end
subgraph data["💾 State"]
RD[("⚡ Redis 7<br/>stock counters<br/>Lua reservation")]
MQ[["🐰 RabbitMQ 4<br/>order queue + DLQ"]]
PG[("🐘 PostgreSQL 16<br/>auth · catalog · orders")]
end
UI --> GW
GW -.discovers.-> EUR
AUTH -.registers.-> EUR
CAT -.registers.-> EUR
ORD -.registers.-> EUR
GW --> AUTH
GW --> CAT
GW --> ORD
AUTH --> PG
CAT --> PG
CAT ==>|pre-warms| RD
ORD ==>|<b>hot path</b>| RD
ORD -->|publishes| MQ
MQ ==>|consumer writes| PG
classDef hot fill:#DC382D,stroke:#8b0000,color:#fff,stroke-width:2px
classDef queue fill:#FF6600,stroke:#a04000,color:#fff,stroke-width:2px
classDef db fill:#4169E1,stroke:#1a3a8f,color:#fff,stroke-width:2px
class RD hot
class MQ queue
class PG db
The request waits only for the Redis reservation. Redis runs commands on a single thread,
so concurrent callers each receive a distinct value — no two buyers can claim the same unit.
Persistence happens off the request path, and checkout returns 202 Accepted with a
correlation id rather than pretending an order already exists.
sequenceDiagram
autonumber
participant B as 🧑 Buyer
participant G as 🚪 Gateway
participant O as ⚙️ order-service
participant R as ⚡ Redis
participant Q as 🐰 RabbitMQ
participant P as 🐘 PostgreSQL
B->>G: POST /api/v1/flash-sale/checkout<br/>Bearer JWT + idempotencyKey
G->>O: route via Eureka
Note over O,R: identity comes from the JWT subject,<br/>never the request body
O->>R: EVAL reserve_stock.lua
activate R
Note right of R: single-threaded · indivisible<br/>window ✓ user cap ✓<br/>idempotency ✓ DECRBY
R-->>O: remaining ≥ 0 — reserved
deactivate R
O->>Q: publish OrderCreatedEvent
O-->>B: 202 Accepted { orderId, PENDING_PERSISTENCE }
Note over B: ⏱ the request ends here
Q-->>P: consumer INSERTs the order as CONFIRMED
P-->>R: DEL reservation:{orderId}
B->>G: GET /api/v1/orders/{id} — polled every 1.5s
G-->>B: CONFIRMED
Note
A 404 right after checkout is not an error. The 202 returns before the queue worker
has written the row, so /orders/{id} legitimately has nothing to show for a moment. The UI
treats a missing order as "still coming" when it arrived from a checkout, and as
"not found" on a cold visit — different copy for what is genuinely a different situation.
Everything that makes overselling impossible lives in
reserve_stock.lua.
Redis executes Lua on its single execution thread, so every check and every write below happens
as one indivisible step. Nothing can interleave between the stock check and the decrement —
which is precisely the race that causes overselling.
📜 See the reservation logic (click to expand)
-- KEYS[1] stock counter flash_sale:stock:{saleId}
-- KEYS[2] buyers hash flash_sale:buyers:{saleId} userId -> qty bought
-- KEYS[3] sale metadata flash_sale:meta:{saleId}
-- KEYS[4] reservation reservation:{orderId}
-- KEYS[5] idempotency flash_sale:idem:{idempotencyKey}
local meta = redis.call('HGETALL', KEYS[3]) -- -2 unknown / never pre-warmed
if meta['status'] ~= 'ACTIVE' then return -3 end -- -3 not open …
if now < startTime or now >= endTime then return -3 end -- … or outside its window
local already = redis.call('HGET', KEYS[2], ARGV[1]) -- -4 per-user cap exceeded
if already + quantity > max_per_user then return -4 end
local stock = redis.call('GET', KEYS[1]) -- -1 sold out
if tonumber(stock) < quantity then return -1 end
local claimed = redis.call('SET', KEYS[5], orderId, 'NX', 'EX', ttl)
if not claimed then return -5 end -- -5 duplicate → original id
-- past this point the reservation is granted; all three writes land together
local remaining = redis.call('DECRBY', KEYS[1], quantity)
redis.call('HINCRBY', KEYS[2], ARGV[1], quantity)
redis.call('SET', KEYS[4], ARGV[1], 'EX', reservation_ttl)
return remaining -- >= 0 successThree design decisions worth calling out:
| Decision | Why |
|---|---|
| A request that cannot be satisfied never touches the counter | Replaces the DECR-then-INCR-to-compensate pattern: two round trips, a transiently negative counter, and a crash between the calls permanently loses a unit of stock. |
| The idempotency claim is the last check | Claiming it before the stock and window tests would burn the key on an attempt that was then rejected — and the customer's retry after a restock would be refused as a duplicate. |
| It returns a plain integer, not a table | Mixed-type Lua tables come back through Spring Data Redis's serializer as a List of Longs and byte arrays that must be picked apart by position. A status code is far harder to misread. |
Validating the sale window inside the script means checkout needs no database read and no call to catalog-service: the entire accept/reject decision is this one script.
Sending the same idempotencyKey twice returns the same orderId and takes no
additional stock. Verified end to end on 2026-08-17:
checkout #1 → {"orderId":"808c514b-…","status":"PENDING_PERSISTENCE"}
checkout #2 → {"orderId":"808c514b-…","status":"PENDING_PERSISTENCE"} ← same id
stock → 49 then 48 ← exactly one unit
GET /orders → 1 order, CONFIRMED, 79.00~78% complete. The backend engine is built, tested, and working end to end. Zero-oversell is verified in CI. The admin console is complete and the storefront runs entirely on live API data. Load testing, live order updates, and the storefront redesign remain.
Important
Verified: 60 simultaneous checkouts against 20 units of stock produce exactly 20 confirmed orders, every time — covered by 21 Testcontainers integration tests plus a manual end-to-end run against the full stack.
Not yet measured: throughput and latency. The 5,000 RPS and <15 ms targets are goals, not results, until Phase 5.
| Layer | Technology |
|---|---|
| Frontend | Next.js 16 (App Router), React 19, Tailwind CSS 4, TypeScript 5, Zod 4 |
| HTTP client | axios — one server-only instance, see below |
| Backend | Spring Boot 4.1, Java 21 LTS, Spring Cloud 2025.1.2 |
| Discovery / edge | Eureka, Spring Cloud Gateway (WebMVC) |
| Hot state | Redis 7 — atomic Lua reservation |
| Message broker | RabbitMQ 4 — order queue with dead-letter queue |
| Database | PostgreSQL 16, Flyway, schema per service |
| Auth | Spring Security, Nimbus HS256 JWT, BCrypt, Google OAuth 2.0 |
| Testing | JUnit 5, Testcontainers (Postgres + Redis + RabbitMQ) |
Warning
Java 21, not 25. Boot 4.1's baseline is Java 17 and 21 is the newest LTS installed here.
The Initializr scaffold's <java.version>25</java.version> could not build on this machine.
.
├── 🐳 docker-compose.yaml # the whole stack: infra + 5 services + frontend
├── 📄 docs/PROJECT-REPORT.md # living engineering report — decisions & failure modes
├── 🖥 frontend/ # Next.js 16 storefront + admin console
│ ├── app/admin/ # ops console routes (dark rail, dynamic)
│ ├── app/actions/admin.ts # every admin mutation, each re-checking the role
│ ├── app/lib/http.ts # the one axios instance; all calls go through it
│ ├── app/lib/catalog.ts # public storefront reads
│ ├── app/lib/orders.ts # the signed-in customer's orders
│ ├── app/lib/admin-api.ts # authenticated gateway client (server-only)
│ ├── app/lib/admin-data.ts # read queries; every endpoint path appears once
│ └── components/admin/ # console-specific UI, separate from the shop's
└── ☕ backend/
├── pom.xml # aggregator; Boot parent, Java 21, Spring Cloud BOM
├── run-all.ps1 # one-shot local launcher (finds JDK 21, tracks PIDs)
├── common/ # error contract, JWT config, Redis keys, event types
├── eureka-server/ # :8761 service registry
├── api-gateway/ # :8080 single entry point + CORS
├── auth-service/ # :8081 register, login, JWT, Google OAuth
├── catalog-service/ # :8082 products, sales, Redis pre-warming
└── order-service/ # :8083 atomic checkout, consumers, order history
Prerequisites — and the one failure everybody hits
| Tool | Version | Check |
|---|---|---|
| Docker Desktop | any recent | docker --version |
| JDK | 21 (not 25 — see note above) | java -version |
| Maven | 3.9+ | mvn -version |
| Node.js | 20+ | node --version |
[!CAUTION] The single most common failure is
JAVA_HOME. Maven readsJAVA_HOME, not thejavaon your PATH. If they disagree, the build dies with a confusinginvalid target release: 21. Check withmvn -version— the "Java version:" line it prints is the one that matters.run-all.ps1finds and sets a JDK 21 for you; do it manually and you must set it yourself.
Step 1 — Start Docker Desktop
The docker CLI can exist while the engine is stopped. If you see
open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified, the engine
is not running.
Start-Process "C:\Program Files\Docker\Docker\Docker Desktop.exe"Wait until docker info succeeds (30–60 s on a cold start).
Step 2 — Backing services only, and why the ports are odd
docker compose up -d postgres redis rabbitmq
docker compose ps # wait until all three say (healthy)| Container | Host port | Container port | Notes |
|---|---|---|---|
flashx-postgres |
5433 | 5432 | Avoids a locally installed PostgreSQL |
flashx-redis |
16379 | 6379 | Avoids a Windows reserved port range |
flashx-rabbitmq |
5672 / 15672 | same | UI at http://localhost:15672 |
Both non-standard host ports are deliberate, and both were chosen after the obvious port failed on a real machine:
Postgres on 5433. A locally installed PostgreSQL service commonly owns 5432. When it does, Docker's published port silently loses and the services connect to your Postgres instead — which has no
flashxuser, so they fail withpassword authentication failed for user "flashx". That reads like a credentials bug and is actually a port collision.
Redis on 16379. Windows/Hyper-V reserves shifting blocks of TCP ports. On this machine
6285-6384is reserved, which swallows the conventional 6379 — Docker then fails to bind with "An attempt was made to access a socket in a way forbidden by its access permissions" and, worse, the container still reports Running with no published port at all. Check your machine's blocks with:netsh interface ipv4 show excludedportrange protocol=tcp
Step 3 — Backend: the quick way (PowerShell)
Finds JDK 21, starts infrastructure if it isn't up, and opens each service in its own window so you can read the logs:
cd backend
.\run-all.ps1 -Build # first run, or after code changes
.\run-all.ps1 # subsequent runs (jars already built)| Flag | Effect |
|---|---|
-Build |
mvn clean install first (stops running services so the jars aren't locked) |
-GatewayPort 8090 |
Run the gateway elsewhere if something owns 8080 |
-Logs |
Write each service to backend/logs/*.log instead of opening a window — use this when you need to grep a stack trace |
-Stop |
Stop the five services; leaves Docker running |
The script records the PIDs it launched in .run-all-pids.json and -Stop kills only
those, matching on start time so a recycled PID is never hit. It deliberately does not stop
"whatever java is on port 8080" — that is how you take down an unrelated application.
Step 3b — Backend: the manual way
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-21.0.11.10-hotspot"
cd backend
mvn clean installThen start all five, registry first — the others register with it on boot:
java -jar eureka-server/target/eureka-server-0.0.1-SNAPSHOT.jar # :8761
java -jar auth-service/target/auth-service-0.0.1-SNAPSHOT.jar # :8081
java -jar catalog-service/target/catalog-service-0.0.1-SNAPSHOT.jar # :8082
java -jar order-service/target/order-service-0.0.1-SNAPSHOT.jar # :8083
java -jar api-gateway/target/api-gateway-0.0.1-SNAPSHOT.jar # :8080Each blocks its terminal, so use a separate window per service (or Start-Process).
On first boot Flyway creates the auth, catalog, and orders schemas and seeds six products;
catalog-service then pre-warms the Redis counters automatically. Allow ~40 seconds.
Step 4 — Frontend
cd frontend
npm install # first time only
Copy-Item .env.example .env.local # first time only
npm run dev.env.local must contain the gateway's address:
API_BASE_URL=http://localhost:8080If you started the gateway on 8090, change this to match. It has no
NEXT_PUBLIC_prefix on purpose — only Server Actions call the backend, so the URL never reaches the browser bundle.
Open http://localhost:3000 for the storefront, or http://localhost:3000/admin for the ops console.
First run: both the storefront and the admin console read the live API. An empty catalogue means the database is genuinely empty, not that the UI is unwired — create a product and a flash sale in the console, then activate it.
Step 5 — Verify, and stopping cleanly
curl http://localhost:8761/actuator/health # eureka -> {"status":"UP"}
curl http://localhost:8080/actuator/health # gateway -> {"status":"UP"}
curl http://localhost:8080/api/v1/flash-sales # -> 6 sales via the gatewayThe Eureka dashboard at http://localhost:8761 should list 4 registered services.
cd backend
.\run-all.ps1 -Stop # the five services (or just Ctrl+C each window)
cd ..
docker compose down # containers, keeps data
docker compose down -v # containers + volumes; forces a fresh Flyway migration and reseedCtrl+C in the npm run dev terminal stops the frontend.
Setup — and why it is all-or-nothing
Off by default. The service starts fine without credentials and the button reports 503, which
the frontend renders as disabled.
1. Create the OAuth client. Google Cloud console → APIs & Services → Credentials → Create credentials → OAuth client ID → Web application. Under Authorised redirect URIs add this exact string:
http://localhost:8080/login/oauth2/code/google
That is the gateway's port, not auth-service's 8081. The browser starts the flow on the
gateway origin, and coming back on a different origin risks arriving without the session cookie
holding the pending authorization request. A trailing slash or the wrong port is a
redirect_uri_mismatch, not a warning. No Authorised JavaScript origin is needed — the
code-for-token exchange is server-to-server and the client secret never reaches a browser.
2a. Running from jars:
$env:GOOGLE_CLIENT_ID="..."; $env:GOOGLE_CLIENT_SECRET="..."
java -jar auth-service/target/auth-service-0.0.1-SNAPSHOT.jar --spring.profiles.active=google2b. Running under compose, or via run-all.ps1: copy .env.example to .env at the repo
root and fill in:
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...That is the whole switch. There is no separate "enable" flag: the google Spring profile is
derived from GOOGLE_CLIENT_SECRET being non-empty, by docker-compose.yaml and
backend/run-all.ps1 alike, so a compose run and a local jar run cannot disagree. Compose reads
.env natively; run-all.ps1 loads the same file and prints one line at startup saying whether
Google sign-in came up enabled. With no .env at all, it stays off and the button renders
disabled.
Both halves or nothing, because neither half alone fails cleanly. The registration lives in
application-google.yaml rather than the main config because declaring it with an empty
client-id does not mean "disabled" — Boot validates it and auth-service refuses to start,
failing its health check and taking the stack with it. A client id with no secret is worse in a
quieter way: the service starts, and the flow only dies at Google's token endpoint with
invalid_client, three redirects later, surfacing on the storefront as a generic
/login?error=google.
What the flow actually does
sequenceDiagram
autonumber
participant S as 🛍 Storefront :3000
participant G as 🚪 Gateway :8080
participant A as 🔐 auth-service
participant Go as 🌐 Google
S->>S: POST signInWithGoogle<br/>park ?next in a short-lived cookie
S->>G: browser redirect → /api/v1/auth/oauth2/google
G->>A: 302 /oauth2/authorization/google
A->>Go: browser redirect → consent screen
Go->>G: browser redirect → /login/oauth2/code/google
G->>A: OAuthSuccessHandler
Note right of A: upsert user · mint FlashX JWT
A->>S: redirect /auth/callback?token=…&expiresIn=…
S->>S: token → httpOnly cookie, redirect by role
Both OAuth legs are routed through the gateway (auth-service-oauth2 in its
application.yaml); without those routes the relative redirect in step 3 lands on a gateway 404
and the flow dead-ends before it ever reaches Google.
The token crosses the last hop as a query parameter, which is the weak point of the design — see
D1 in docs/PROJECT-REPORT.md. /auth/callback moves it into the
cookie and redirects to a clean path immediately, so it never reaches a rendered page or a
Referer header.
Everything goes through the gateway on :8080. Errors are uniform:
{ "message": "...", "code": "..." }.
| Method | Endpoint | Auth | Returns |
|---|---|---|---|
POST |
/api/v1/auth/register |
— | 201 { token, expiresIn } · 409 if email taken |
POST |
/api/v1/auth/login |
— | 200 { token, expiresIn } · 401 otherwise |
GET |
/api/v1/auth/oauth2/google |
— | 302 to Google · 503 if unconfigured |
GET |
/api/v1/auth/me |
🔑 | current user |
GET |
/api/v1/flash-sales |
— | all sales, storefront shape |
GET |
/api/v1/flash-sales/{sku} |
— | one sale |
POST |
/api/v1/flash-sales/prewarm |
🔑 | loads Redis counters |
POST |
/api/v1/flash-sale/checkout |
🔑 | 202 { orderId, PENDING_PERSISTENCE } |
GET |
/api/v1/orders |
🔑 | order history |
GET |
/api/v1/orders/{id} |
🔑 | one order |
Important
Checkout takes the buyer's identity from the JWT subject, never the request body —
accepting a userId field would make the per-customer cap trivially bypassable.
🎛 Admin API — every route requires role: ADMIN
Enforced twice — by hasRole('ADMIN') on the URL and by @PreAuthorize on the handler —
so a controller added to the package without the annotation is still not reachable by a
customer. A customer's token gets 403; no token gets 401.
| Method | Endpoint | Does |
|---|---|---|
GET |
/api/v1/admin/products |
Paged, ?search= over SKU/title/category |
POST |
/api/v1/admin/products |
Create; 409 on duplicate SKU |
PUT |
/api/v1/admin/products/{id} |
Update |
DELETE |
/api/v1/admin/products/{id} |
409 if a SCHEDULED or ACTIVE sale references it |
GET |
/api/v1/admin/flash-sales |
All sales + live Redis counter and units sold |
POST |
/api/v1/admin/flash-sales |
Schedule; validates discount < base price, allocation ≤ inventory |
PUT |
/api/v1/admin/flash-sales/{id} |
Edit — SCHEDULED only |
PUT |
/api/v1/admin/flash-sales/{id}/status |
Lifecycle; activating pre-warms Redis, ending tears it down |
POST |
/api/v1/admin/flash-sales/prewarm |
Repair metadata for live sales without resetting counters |
GET |
/api/v1/admin/orders |
Paged; filter by status, flashSaleId, search |
GET |
/api/v1/admin/orders/reconciliation/{saleId} |
🔍 the zero-oversell proof (?allocatedStock=N) |
GET |
/api/v1/admin/metrics |
Order totals, revenue, queue depth, DLQ depth, consumers |
GET |
/api/v1/admin/users |
Paged, ?search= |
PUT |
/api/v1/admin/users/{id}/role |
Promote/demote; refuses self-demotion and last-admin |
Default admin: admin@flashx.local / flashx-admin, seeded by
V2__add_roles_and_seed_admin.sql. Override with FLASHX_ADMIN_PASSWORD; the service logs a
loud warning at startup while the seeded password is still in use.
stateDiagram-v2
direction LR
[*] --> SCHEDULED
SCHEDULED --> ACTIVE: activate — pre-warms Redis
SCHEDULED --> ENDED: cancel before opening
ACTIVE --> ENDED: end — writes true unsold count back
ACTIVE --> EXHAUSTED: stock hits zero
ENDED --> [*]
EXHAUSTED --> [*]
Reactivating an ENDED sale is refused — it would republish a counter for a sale whose
orders are already closed.
Drive a real checkout end to end:
GW=http://localhost:8080
TOKEN=$(curl -s -X POST $GW/api/v1/auth/register -H 'Content-Type: application/json' \
-d '{"name":"Ada","email":"ada@example.com","password":"Sup3rSecret!"}' | jq -r .token)
curl -s $GW/api/v1/flash-sales | jq '.[] | {id, sku, status, remainingStock}'
curl -s -X POST $GW/api/v1/flash-sale/checkout \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"flashSaleId":1,"quantity":1,"idempotencyKey":"demo-1"}'
docker exec flashx-redis redis-cli GET flash_sale:stock:1
docker exec flashx-postgres psql -U flashx -d flashx -c 'select * from orders.orders;'Run the checkout twice with the same key — same orderId, one unit of stock.
cd backend
mvn test # unit tests only — no Docker needed
mvn verify # + 21 Testcontainers integration tests (~2 min, needs Docker)cd frontend
npm run build # production build, also typechecks
npm run lint| Suite | Covers |
|---|---|
OversellIT |
60 simultaneous checkouts vs 20 units → exactly 20 confirmed |
CheckoutFlowIT |
Reservation → queue → Postgres, end to end |
ReservationServiceIT |
Every Lua return code, per-user caps, idempotency |
http://localhost:3000/admin — a dark-rail operations panel, deliberately not mistakable for the storefront at a glance, since the same browser may have both open and this one can end a live sale.
Sign in at /login with the seeded admin account, or go straight to /admin and you will be
sent to sign in and returned there afterwards.
| Screen | What it does |
|---|---|
/admin |
Order totals and revenue, persistence-queue depth, DLQ, consumer count, recent orders, and every sale currently open. Optional 5-second live refresh. |
/admin/products |
Paged, searchable catalogue. Create, edit, delete. |
/admin/sales |
The sale board, filterable by status. Lifecycle buttons and metadata repair. |
/admin/sales/{id} |
One sale: lifecycle, stock reconciliation, its orders, and the edit form while SCHEDULED. |
/admin/orders |
Every checkout, filterable by status and sale. Read-only. |
/admin/users |
Who can sign in and who can reach this console. Promote and demote. |
Three design decisions worth knowing about
1 · The sale board shows two stock figures side by side. live is the Redis counter — the
only number customers are actually buying against. remaining is the Postgres projection, which
is allowed to trail during a sale. Showing both makes the lag visible instead of leaving an
operator to guess which one is lying. A sale that is ACTIVE with no live counter is flagged
in red: it will reject every checkout, and that is invisible from the storefront.
2 · Orders are read-only, by design rather than omission. An order is the record of a completed financial event, and the reconciliation below is only meaningful because nothing in the UI can quietly adjust one. Corrections belong in a refund flow that writes a new row.
3 · Reconciliation is shown as arithmetic, not a verdict:
allocated = confirmed + live + in-flight
A green "healthy" badge would be worth very little — the entire claim of this architecture is
that the identity holds under concurrent load, so the numbers themselves are the evidence.
in-flight is stock reserved in Redis but not yet written to Postgres: normal while the queue
drains, a problem only if it never settles to zero. oversold means more units were confirmed
than ever existed, which is a correctness defect rather than a reporting lag.
🛡 How authorisation is layered — and what a forged token actually gets you
The console decodes the JWT to decide what to render, without verifying its signature — the
signing secret does not belong in the web tier. That is UI gating, not access control. Every
/api/v1/admin/** call carries the token and the backend verifies it, so a hand-crafted cookie
claiming role: "ADMIN" gets someone the sidebar and a wall of session-expired panels, and no
data. Verified:
| Session | Result |
|---|---|
| None | 307 → /login?next=/admin |
| Customer | 307 → /dashboard?denied=admin |
Forged ADMIN claim, bad signature |
Shell renders, every panel empty, zero data leaked |
| Real admin | Full access |
Every mutating Server Action re-checks the role itself. An action is a public POST endpoint reachable without the layout ever rendering, so a layout-only check would secure the navigation and nothing else.
Every call goes through one axios instance in
app/lib/http.ts, pointed at the API gateway. No component ever
addresses a service directly, so a change in backend topology is a gateway config edit rather
than a frontend one.
Server Component / Server Action
└─▶ app/lib/{catalog,orders,admin-data}.ts endpoint paths live here, once each
└─▶ app/lib/http.ts axios: base URL, timeout, error shape
└─▶ API gateway :8080
└─▶ auth-service │ catalog-service │ order-service
Nothing runs in the browser. http.ts imports server-only, so an accidental import into a
Client Component is a build error rather than a token silently shipped to the browser in the
RSC payload. API_BASE_URL has no NEXT_PUBLIC_ prefix for the same reason.
Errors are normalised at the boundary. axios rejects on any non-2xx, which would make "this
sale sold out" (a 409 the UI must render) look the same as "the network died". Every call
returns ApiResult<T> instead:
const catalogue = await listFlashSales();
if (!catalogue.ok) return <EmptyState description={catalogue.message} />;Transport failures get sentinel statuses so they can be told apart from HTTP ones:
-1 not configured, 0 unreachable, -2 timed out.
⚠ The one real cost of axios — and the bug it caused
Next.js patches global fetch to add request dedup and the cache/revalidate tag system.
axios uses Node's http module and gets none of it — which means Next has no idea these
pages depend on request-time data and will happily prerender them. That is not theoretical:
before this was handled, next build marked / and /sales as static, freezing live stock
at build time.
The fix is await connection() inside the catalogue functions, following the Next.js docs'
guidance for synchronous database drivers. Putting it in the data layer rather than on each
page means every caller is excluded from prerendering automatically and a new page cannot forget
to opt out.
Nothing here is cached, deliberately. remainingStock and status are the entire subject of the
page and both move during a sale; a cache measured in seconds would invite a customer to click
Reserve on something already gone.
| Screen | Data |
|---|---|
/ and /sales |
GET /api/v1/flash-sales, sliced into live and scheduled |
/sales/{sku} |
GET /api/v1/flash-sales/{sku} — a real 404 for an unknown SKU, an error panel for an unreachable backend |
| Cart | Still localStorage, but only skus and quantities. Prices and stock are joined against the live catalogue on every render, so nothing stale is ever served from storage |
/checkout |
POST /api/v1/flash-sale/checkout — one call per cart line |
/orders, /dashboard |
GET /api/v1/orders, scoped to the JWT subject |
/orders/{id} |
GET /api/v1/orders/{id}, polled every 1.5 s while the write is outstanding |
Note
Checkout is a loop, not a transaction. The endpoint reserves one sale at a time — the shape the Lua script and the per-user cap are built around. A three-line cart is three reservations, issued sequentially so a partial failure is legible. There is deliberately no rollback: returning stock from a successful reservation because a different sale sold out would take back inventory a queued order already depends on. Lines that succeed are removed from the cart; a line that fails stays there with its reason.
The table of things that will actually go wrong
| Symptom | Cause | Fix |
|---|---|---|
invalid target release: 21 |
JAVA_HOME points at an older JDK |
$env:JAVA_HOME="...jdk-21...", or use run-all.ps1 |
open //./pipe/dockerDesktopLinuxEngine |
Docker engine not running | Start Docker Desktop, wait for docker info |
password authentication failed for user "flashx" |
A local Postgres owns 5432 | Already handled — the container publishes 5433 |
Connection refused … :6379 while docker exec redis-cli ping works |
Redis port not published to the host | See Ports that vanish below |
bind: An attempt was made to access a socket in a way forbidden… |
Port sits in a Windows reserved range | Pick a port outside netsh interface ipv4 show excludedportrange protocol=tcp |
Port 8080 was already in use |
Something else owns the port | .\run-all.ps1 -GatewayPort 8090 and update API_BASE_URL in .env.local |
Unable to access jarfile C:\Users\...\Individual |
Unquoted path with spaces | Fixed in run-all.ps1; quote the jar if launching by hand |
Checkout returns SALE_NOT_OPEN on a live-looking sale |
Seeded sale windows expired, or clock drift | See Stale sale windows below |
Health says DOWN but endpoints work |
A dependency's health indicator is failing | .\run-all.ps1 -Logs, then grep the log — Redis and RabbitMQ are the usual suspects |
| Frontend says "Could not reach the authentication service" | API_BASE_URL unset or wrong port |
Check frontend/.env.local, restart npm run dev |
Gateway returns 503 for a route |
Service not registered with Eureka yet | Wait ~30 s; confirm at http://localhost:8761 |
👻 Ports that vanish after a Docker restart
After a Docker Desktop restart a container can come back Running with its port mapping
silently missing — docker compose ps shows 6379/tcp instead of 0.0.0.0:16379->6379/tcp.
docker compose up -d will not fix it, because the config hash still matches and Compose
reuses the container. Force it:
docker compose up -d --force-recreate redisIf that then fails with the socket-permissions error, the host port has fallen inside a Windows reserved range — pick another one. (This is why Redis is on 16379.)
⏰ Stale sale windows → every checkout returns SALE_NOT_OPEN
The seed migration sets sale windows relative to migration time, so a database left running for a day has no live sales. Real windows come from the admin path, not a migration. To refresh the demo data in place:
docker exec flashx-postgres psql -U flashx -d flashx -c \
"UPDATE catalog.flash_sales SET start_time = now() - INTERVAL '1 hour',
end_time = now() + INTERVAL '6 hours'
WHERE status = 'ACTIVE';"
docker exec flashx-redis redis-cli --scan --pattern 'flash_sale:meta:*' \
| tr -d '\r' | xargs -r -n1 docker exec flashx-redis redis-cli DEL
curl -X POST http://localhost:8080/api/v1/flash-sales/prewarm -H "Authorization: Bearer $TOKEN"prewarm reporting salesWarmed: 0 is correct here — it refreshes the metadata but uses
SETNX on the counters, so a sale already in progress is never reset to full stock.
A less common variant is genuine clock drift: Flyway seeds windows using Postgres's clock
while the reservation script judges them against the service's. Compare
docker exec flashx-postgres psql -U flashx -d flashx -t -c "select now();" against the host.
If they disagree, wsl --shutdown and restart Docker Desktop.
Or just reset everything:
docker compose down -v # wipes volumes, forces a fresh migration and reseed
docker compose up -d- Redesign the storefront — the data is live now; the visual language is still the original one, and a marketplace layout is the remaining half
- SSE order-status stream — replaces both the order page's 1.5 s poll and the console's
- Idempotency keys minted before the first attempt and reused across retries. They are currently generated per attempt, which dedupes nothing on a lost response
- Sweeper for expired reservations (failure mode F1)
- k6 load test — 1,000+ VUs against a 500-unit sale, then reconcile
- Extend the test suite to auth-service and catalog-service
- Browser tests — the Server Actions are verified by driving them directly, which skips React's form serialisation
Full detail in report §14.
Engineering report — architecture decisions, failure modes, verification results
reserve_stock.lua — the heart of it, heavily commented
An exercise in the thing that is easy to describe and hard to do:
being correct while being fast.