SAY THIS SENTENCE FIRST
"Robux is a closed double-entry economy, and DevEx is the one door out of it. A request escrows Robux atomically at submission, and what happens to that escrow, destroyed (burned) or refunded, is decided by what real dollars actually did."
STEP 1 OF 5
Understanding the problem
4 min💸 What is DevEx?
- Creators earn Robux when players spend in their games.
- DevEx converts earned Robux into real US dollars via an external payment processor.
- It is the only place the closed in-game economy touches real money.
Functional Requirements
Core Requirements
- Developers should be able to see Robux earnings per game in near real time.
- Developers should be able to submit a DevEx request (a minimum cashout amount and eligibility rules apply).
- The system should convert at the rate in effect at submission, not at processing.
- Developers should be able to track request status and full history.
Below the line (out of scope):
- The eligibility rule engine itself. We consume it as a service.
- Tax documentation and reporting.
- Multi-currency rails; the processor internals.
Non-Functional Requirements
Ask about scale first: it splits the problem in half
- Earnings: tens of millions of purchases/day × 3 legs each: hundreds to ~1k ledger writes/sec. Spiky.
- Payouts: thousands/day. Under 0.1/sec. Tiny but high-stakes.
- You are designing two systems with opposite profiles. Say that.
- The system should never allow over-withdrawal. Balance at submission: strongly consistent.
- The system should never lose or mutate a financial record. Every decision explainable years later.
- The system should finish payouts within 5 business days, despite processor downtime, rejections, and partial payments (paid X of a requested Y).
- The system should serve earnings reads cheaply. Eventually consistent display is fine, labeled with a freshness timestamp (asOf).
DONE WHEN: interviewer nods at the scale split: earnings are high-volume, payouts are tiny but high-stakes. That split shapes everything.
STEP 2 OF 5
Entities + API
4 minDefining the Core Entities
- Developer Account: holds the withdrawable Robux balance and a status (ACTIVE / FROZEN).
- Earnings Ledger Entry: one leg (one half of a transfer, + or -) of a Robux movement. Double-entry. The truth.
- DevEx Request: one exchange. Carries a locked rate, an eligibility snapshot, a status.
- Exchange Rate Version: immutable (rate, valid-from) row. Requests point at one.
- Payout Attempt: one processor call and its outcome. Immutable.
- System Accounts:
DEVEX_ESCROWholds in-flight Robux.ROBUX_BURNdestroys it when a payout completes.
💡 The sentence to plant early
"Robux is a closed double-entry economy, and DevEx is the one door out. A request escrows Robux atomically at submission. Burn or refund is decided by what real dollars actually did."
API or System Interface
POST /v1/devex-requests Idempotency-Key: <key> // unique per (developer + key) pair
{ "robux": 100000 }
→ 201 { requestId, PENDING, rateId, usdMinor } // usdMinor = cents; rate + USD locked NOW
→ 422, body: { error: INSUFFICIENT_BALANCE | BELOW_MINIMUM | NOT_ELIGIBLE, reasons }
→ 409, body: { error: KEY_REUSED } // same key, different body. same key + same body: replay the stored 201
GET /v1/devex-requests?cursor= // history
GET /v1/devex-requests/:id // status + every attempt
DELETE /v1/devex-requests/:id // cancel; PENDING only; 409 after
GET /v1/earnings?game=&granularity=day // display; eventual; carries asOf
- The 201 already carries the locked rate and computed USD.
- Both are fixed inside the submission transaction. The amount can never drift after.
DONE WHEN: you have said the opening sentence again, pointing at ESCROW and BURN.
STEP 3 OF 5
High-level design
10 min, end to end, no dives yet1) Developers should be able to see earnings in near real time

- Every purchase writes double-entry legs: minus player, plus developer share, plus platform cut.
- Legs sum to zero. The economy conserves by construction.
- Earnings displays: derived rollups off the ledger change stream (CDC: change data capture). Seconds stale, labeled asOf.
- The withdrawable balance: exact, read strongly only where it gates money, at submission.
2) Developers should be able to submit a DevEx request

- Replay check first: (developer, key) seen with matching body hash → return the stored answer, even if eligibility has since changed. Body differs → 409.
- Then the eligibility check (external call). Full result snapshotted.
Then ONE transaction:
- Insert the request row. This is the idempotency anchor (the record a retry lands on).
- Read the current rate version. Compute the USD. Store both on the request.
- Debit developer, credit ESCROW. The no-overdraft guard is fused into the debit (one statement does check and change: deep dive 1).
If the guard matches zero rows (the branch of step 3):
- Re-read the account once to say WHY: frozen account or insufficient balance. One error code each.
- Mark REJECTED, commit, 422. Nothing was debited, so there is nothing to refund.
- The anchor survives for retries (unique index on developer + key makes it findable).
TRIGGER: "which rate applies" / "rules changed after they submitted"
3) The rate is the one at submission

- A rate change is a NEW immutable row. Never an update.
- The request stores which rate row it used and the computed USD.
- Processing days later reads the request. It cannot see any other rate.
- The guarantee is a foreign key, not a convention.
4) Developers should be able to track status and history

- State machine: PENDING → PROCESSING → COMPLETED | PARTIAL | FAILED | ON_HOLD. Plus PENDING → CANCELLED.
- REJECTED is born terminal at submit (the guard said no; nothing escrowed). Lease expiry loops PROCESSING back to PENDING.
- Every processor call: one immutable attempt row.
- The history endpoint returns that table verbatim. Audit trail and UI at once.
DONE WHEN: the interviewer picks a box to open. Let them steer from here.
STEP 4 OF 5
Deep dives
~20 min, interviewer steersTRIGGER: "two submissions race for the same balance" / "walk me through submit"
1) How do we guarantee a developer can never over-withdraw?
- The race: two submissions, one 100K balance, 80K each. Both pass app-level checks.
Bad Solution: check-then-act in the application
- Read balance, compare in code, then debit.
- Between read and write, the other request commits.
- Both pass. You paid 160K from 100K. Interviewers plant this deliberately.
Good Solution: serializable transactions
- Run everything at the strongest isolation level, retry on conflict.
- Correct, but you now handle retry storms in the app.
- And you pay the maximum price for a path needing exactly one guarantee.
Great Solution: fuse the guard into the debit statement
UPDATE accounts SET balance = balance - :robux WHERE id = :dev AND status = 'ACTIVE' AND balance - :robux >= 0
- The minimum-cashout check is NOT here: it is static validation at the API edge (a constant cannot be raced). The guard holds only the racy checks.
- Check and mutation are ONE atomic statement, under the row lock.
- The second transaction waits, re-checks the already-debited balance, matches zero rows, rejected.
- No check-then-act window exists.
- "Strongly consistent balance" = one line of SQL, not a distributed protocol.
TRIGGER: "should this be event sourced" / "what do you watch"
2) How is every payout decision auditable years later?
Bad Solution: mutable rows plus application logs
- Update the request in place; log what happened.
- Logs rotate. Updates destroy history.
- "Why did this pay $3,500 in 2024?" has no answer a regulator accepts.
Good Solution: event-source everything
- Rebuild ALL state only by replaying an event log.
- Fully auditable, but you adopted a whole architecture: snapshots, upcasting (migrating old stored events to new schemas), replay tooling.
- This domain can answer the question more cheaply.
Great Solution: event-sourced where it matters, immutable inputs everywhere
- The ledger legs + attempts table already ARE an append-only event log.
- Rates: immutable versions. Eligibility: snapshotted onto the request.
- Request status: a normal column, every change streamed via CDC to write-once, read-many (WORM) archive.
- Say it as: "event-sourced where it matters, the money. Archived change stream everywhere else."
TRIGGER: "the processor is slow/down" / "your worker crashes mid-payout"
3) How do we finish in 5 business days when the processor fails, rejects, or half-pays?
- Volume check first: thousands/day. ONE worker is enough.
- The machinery below exists for crash-safety, not throughput. Saying that is the judgment signal.
Claiming work
- Workers sweep PENDING with FOR UPDATE SKIP LOCKED. The claim takes a lease and an attempt number and sets PROCESSING.
- A reaper flips expired-lease rows back to PENDING, the one backward transition. A definitive answer is the only way forward.
- "ONE worker" means one logical consumer; run a spare; SKIP LOCKED makes extras safe.
Calling the processor
- The idempotency key IS the requestId. A SECOND key, one hop out: the client's Idempotency-Key dedupes developer→us; this one dedupes us→processor. Every retry collapses to at most one disbursement.
- Per-attempt keys are the double-payout bug. Name it before they do.
The ambiguous moment: we called, then crashed before recording
- The row stays PROCESSING.
- On lease expiry: retry with the SAME key (the processor replays its answer), or query their status endpoint.
- Only a definitive processor answer moves the row.
- Retry schedule: 15m, 1h, 6h, 1d, 2d. Fits inside 5 business days with room.
- Processor fully down? Trip a circuit breaker: leave rows PENDING, no attempt churn, and drain oldest-first on recovery.
TRIGGER: "the processor paid half" / "what ends a request"
Terminal states settle the escrow, in the same transaction as the status change
- COMPLETED → burn all of it.
- FAILED → refund all of it.
- PARTIAL (paid X of Y) → burn floor(robux × X/Y), refund the rest.
- CANCELLED (only while PENDING) → refund all of it. Every terminal state settles its escrow; none may strand it.
- Ambiguous X (their report says PARTIAL but X is missing or bigger than Y) → CAS to ON_HOLD, park in the dead-letter TABLE (a parking table, not queue infrastructure). No legs move; a human picks an ending above.
TRIGGER: "processor is down for 6 hours" / "their report disagrees with you"
Reconciliation, daily, both directions
- Their report has a line, we have no row: repair from processor truth, page the on-call at SEV-2.
- We say COMPLETED, their report is silent: SEV-1. Until explained, that is missing money.
🔒 The invariant that audits the whole system
- ESCROW balance = SUM of in-flight requests' Robux. Continuously checkable.
- Every burn has a matching line in the processor's settlement report.
- Money left the platform if and only if Robux left the economy.
TRIGGER: "how do earnings get tracked at scale"
4) How do we handle the scale asymmetry?
- The earnings ledger is the high-scale piece. Hundreds of writes/sec, sharded. It exists regardless of DevEx.
- The payout side is ONE Postgres and a worker. Keep it that way at 1000x volume.
- Component count is earned by the split, not by the payout path.
- The interviewer is checking: do you size the two sides separately, or reach for queues everywhere.
FLASHCARDS
The five hardest probes
show all (interview mode)Two concurrent submissions, one balance. Where exactly does the double-spend die?
Inside the escrow debit: the guard "balance minus robux stays at or above zero" is part of the UPDATE itself, under the row lock. The second transaction waits, re-evaluates against the already-debited balance, matches zero rows, and is rejected. There is no separate check step to race.
The processor paid, then you crashed before recording it.
The row is still PROCESSING with an expiring lease. The next attempt presents the SAME idempotency key (the request id), so the processor replays its recorded success instead of paying again, and we record COMPLETED late. If even that replay is lost, the daily settlement reconciliation catches the report line with no matching row and repairs from processor truth.
Why can the rate never drift between submission and processing?
Because "which rate" is not a policy, it is data: rates are immutable versioned rows, and the submission transaction writes the rate id AND the computed USD onto the request. Processing reads the request row. There is no code path that could consult a different rate.
Walk the request state machine and what each transition writes.
PENDING to PROCESSING when a worker claims it. PROCESSING to COMPLETED, PARTIAL, or FAILED on a definitive processor answer. PENDING to CANCELLED on user cancel, guarded so it cannot race a claim. PROCESSING to ON_HOLD on an ambiguous answer (DLQ, human decides). Lease expiry loops PROCESSING back to PENDING. REJECTED is born terminal at submit. Every transition is an atomic compare-and-set on the status column, committed in the same transaction as its escrow settlement legs (burn, refund, or split), so no ending can skip the money.
Would you event-source this?
It already is, where it matters: the double-entry legs and the immutable attempts table are an append-only event log, and every balance derives from them. Rebuilding ALL state only by replay would add operational cost without adding auditability. Compliance gets every change streamed to write-once archive storage with retention, satisfying the audit ask without event-sourcing the write path.
STEP 5 OF 5
Final design + levels
wrapFinal Design
- Closed double-entry Robux economy; DevEx is the one audited exit.
- Submission: one transaction, fused guard, frozen inputs (rate version, eligibility snapshot).
- Payout: leased worker, requestId as the processor idempotency key.
- Terminal states settle escrow as ledger transfers, atomic with the status change.
- Daily reconciliation + continuous escrow invariant keep it honest. CDC to WORM for compliance.

What is Expected at Each Level?
Mid-level
- Working end-to-end: ledger, request table with statuses, processor call with retries.
- Knows the balance check must be transactional.
- Hand-waving on idempotency and reconciliation is forgivable.
Senior
- Fused guard produced unprompted. Request-scoped processor key, plus the crash-before-recording walkthrough.
- Escrow settlement tied atomically to state transitions.
- Rate locked by foreign key. Drives the double-spend race probe (deep dive 1) instead of being led.
Staff
- Scale-split framing up front. Inputs frozen by construction, sold as an auditability strategy.
- Event-sourcing answered with judgment, not adoption.
- Partials and rounding surfaced as finance-team decisions, mechanism ready.
- The payout path stays small on purpose, and says so.
NEXT ACTION, 3 MINUTES
Read the four Step 3 subsections aloud once, timing yourself. If they run past 90 seconds total, cut words until they fit. Then answer the first flashcard from memory before opening it.