← all topics

Design Roblox's DevEx Payout System

Hello Interview structure · list format, one idea per line

Understanding the Problem

💸 What is DevEx?

Functional Requirements

Core Requirements

  1. Developers should be able to see Robux earnings per game in near real time.
  2. Developers should be able to submit a DevEx request (a minimum cashout amount and eligibility rules apply).
  3. The system should convert at the rate in effect at submission, not at processing.
  4. Developers should be able to track request status and full history.

Below the line (out of scope):

Non-Functional Requirements

Ask about scale first: it splits the problem in half
  1. The system should never allow over-withdrawal. Balance at submission: strongly consistent.
  2. The system should never lose or mutate a financial record. Every decision explainable years later.
  3. The system should finish payouts within 5 business days, despite processor downtime, rejections, and partial payments (paid X of a requested Y).
  4. The system should serve earnings reads cheaply. Eventually consistent display is fine, labeled with a freshness timestamp (asOf).

The Set Up

Defining the Core Entities

💡 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

High-Level Design

DevEx architecture

1) Developers should be able to see earnings in near real time

2) Developers should be able to submit a DevEx request

Then ONE transaction:

  1. Insert the request row. This is the idempotency anchor (the record a retry lands on).
  2. Read the current rate version. Compute the USD. Store both on the request.
  3. 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):

3) The rate is the one at submission

4) Developers should be able to track status and history

Potential Deep Dives

1) How do we guarantee a developer can never over-withdraw?

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.

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."

3) How do we finish in 5 business days when the processor fails, rejects, or half-pays?

Claiming work
Calling the processor
The ambiguous moment: we called, then crashed before recording
Terminal states settle the escrow, in the same transaction as the status change
Reconciliation, daily, both directions
🔒 The invariant that audits the whole system

4) How do we handle the scale asymmetry?

Final Design

What is Expected at Each Level?

Mid-level
Senior
Staff