Design a Payments Platform
- Trace one card transaction from authorization to a double-entry ledger that reconciles
- Make money movement exactly-once under retries with an idempotency key on every write
- Split a synchronous authorize path from asynchronous capture, then add Adyen's single-platform shape
Understanding the prompt
The prompt is short: design a payment service provider, and walk one card transaction through it. Skip the throughput template. A payments prompt grades something most candidates rehearse far less than scale: moving money correctly when the network is against you. So bound the problem out loud first. A shopper taps pay on a merchant's checkout. The merchant's server calls your API. From that boundary you own the whole path. You authorize the card, route the payment to its rail, capture the funds, settle them to the merchant, and keep a record that balances. Get that spine clean and every follow-up has somewhere to attach.
Strip the branding off and the reusable machine is any system that debits one party and credits another and must never get the total wrong. A card transaction, a wallet transfer, a payout: the same core sits behind all of them. A request arrives and you decide whether it is allowed. You record the movement of money in a ledger that always balances. And it has to survive a retry when the network drops your response. Build that core honestly and the Adyen shape becomes a short final step. The gateway, the risk check, the acquiring and the authorize-then-settle rhythm all map onto pieces the general design already has.
Bounding the requirements first
State the functional surface plainly before you name a single store. The prompt is deliberately open, and the interviewer wants to watch you scope it. A merchant asks to charge a shopper some amount on some payment method. The platform decides whether to allow it and routes it to the rail that method rides. It records the movement of money durably and returns a definite outcome. Later it finalizes that movement so funds settle to the merchant. At all times it can answer what any account holds and how any payment reached its current state. That core is enough to build something real without drowning in features.
Then draw the line between what you handle now and what you name and defer. Risk scoring, multi-currency conversion, chargebacks and scheme certification will not all fit in the first pass. Try, and you run out of time before the ledger is clean. Model the skeleton so each of those has an obvious home, then say so. In a good design, "add real risk scoring later" means dropping a model behind an existing risk seam. In a bad one, it means rewriting the charge path. Put a hook where each deferred piece attaches, and name the non-goals for this pass.
- What the round grades: correctness, then scale. A clean ledger, exactly-once money movement, and an auditable trail come first. Throughput matters, but a design that scales beautifully and can double-charge a card fails the part that counts. Lead with the money being right.
- Money is not a float: integer minor units. Represent every amount as an integer count of minor units, cents rather than dollars, so rounding never invents or destroys a fraction of a currency. Say this early; a floating-point balance is a fast way to look like you have never shipped a ledger.
- The ledger is the source of truth: double-entry, always balanced. Every movement of money is two matched entries, a debit and an equal credit, that sum to zero. The invariant that the whole ledger nets to zero is what proves no money was created or lost, and it is the spine of the design.
- Retries are the normal case: idempotent by key. Networks drop responses, so clients resend. Every mutating request carries an idempotency key, and a repeat of a key returns the first outcome without applying the money again. This is how one tap stays one charge.
The money invariants before the boxes
Spend the first real minutes on the invariants. The components fall out of them cleanly once the rules are straight, and stating them up front reads as senior here. Three ideas carry the whole design. The first is double-entry accounting. You never store a balance as a number you edit in place. You store an append-only sequence of postings. Every posting moves an amount out of one account and into another as a matched debit and credit. A balance is derived, the sum of an account's postings, and all postings across the ledger sum to zero. That structure makes the system auditable. You can replay the log and prove how every balance came to be, and both a settlement break and a chargeback need exactly that.
The second idea is the non-negative invariant on real accounts. A merchant or shopper account must never drop below zero, because that would mean the platform let out money that was not there. So a debit against a real account is conditional. It posts only if the balance covers it, and otherwise the transaction declines. The exception is a designated clearing or settlement account. It represents value crossing the platform boundary from the outside world. It may run negative, because it is the counterweight that keeps every posting balanced. Name that one privileged account explicitly. Then funding and payouts stay double-entry without breaking the rule everywhere else.
The third idea is exactly-once through idempotency, and it is the one most candidates state weakly. On an unreliable network a client cannot tell a lost request from a lost response. So it retries, and a naive system applies the money twice. The fix starts with a client-chosen idempotency key on every mutating call. The platform records the outcome of a key the first time it processes it. Any later request with the same key returns that stored outcome without touching the ledger again. The subtle part is that you cache the decision, including declines. A retried request that was declined stays declined. Say that out loud, and exactly-once becomes a mechanism.
One card transaction, end to end
With the invariants straight, the design comes down to the wiring between the components and the order of one payment. Draw it. Read the diagram by role. Blue is an external party the platform does not control: the merchant that starts the charge, and the card scheme and issuer that live beyond your edge. Violet is a service the platform owns and runs. Amber is the queue that hands the finalize step to a worker. Green is the durable state the whole design exists to protect: the idempotency store, the payment record, and the double-entry ledger. Follow the edges to read one card transaction. Dedupe it, screen it for risk, route it and authorize it against the scheme, and post the hold in the ledger. Then capture and settle it. Reconciliation checks the books against what the scheme cleared.
Authorize now, capture and settle later
The design converges on the order of one payment. First the gateway dedupes the request against its idempotency key, so a retry short-circuits to the stored outcome before anything else runs. Then it screens the request with the risk engine, because a blocked payment should never reach the scheme or the ledger. Then the router picks the rail and asks the issuer, through the scheme, for a real-time approve or decline. On approval the platform does two durable things together. It records the authorized state in the payment record, and it posts an authorization hold in the ledger as balanced double-entry. Only later, on capture, does the settlement worker finalize the funds and write the settling posting. Splitting authorize from capture lets the decision feel instant while the money moves in its own time.
// One payment is one lifecycle, and the split is by LATENCY. Authorize is// synchronous: it sits in the shopper's checkout and must return fast.// Capture and settlement are asynchronous, handed to a worker off a queue.async function authorize(req: PayRequest): Promise<Outcome> { // 1. IDEMPOTENCY first, before any side effect: a retried key replays the // first outcome, so a resent tap never screens, routes, or charges twice. const prior = await idem.lookup(req.idempotencyKey) if (prior) return prior.outcome // exactly-once: replay, do not re-apply // 2. RISK: a blocked payment never reaches the scheme or the ledger. if (!(await risk.allow(req))) { return idem.record(req.idempotencyKey, { status: 'BLOCKED' }) } // 3. ROUTE + AUTHORIZE: pick the rail for this method, get a real-time // approve or decline from the issuer through the scheme. const rail = router.routeFor(req.method) // Visa, local scheme, bank transfer... const auth = await rail.authorize(req.instrument, req.amount) if (!auth.approved) { return idem.record(req.idempotencyKey, { status: 'DECLINED' }) } // 4. POST THE HOLD durably: payment state + balanced double-entry, together. await payments.append(req.id, 'AUTHORIZED') await ledger.post({ // debit and credit sum to zero key: req.idempotencyKey, debit: 'CLEARING', credit: req.merchant, amount: req.amount, }) // Capture is ENQUEUED, not done inline: settlement finalizes funds later. await captureQueue.enqueue(req.id) return idem.record(req.idempotencyKey, { status: 'AUTHORIZED' })}Say two things about this order to the interviewer directly. First, the idempotency lookup comes before every side effect on purpose. Screen or authorize before you dedupe, and a retry re-runs the risk call and re-hits the scheme. At best that wastes work. At worst it double-authorizes. Deduping first makes the entire payment exactly-once, beyond the ledger write. Second, the payment-state append and the ledger post have to be durable together. A crash must not leave the books saying a payment authorized while the ledger holds no posting, or the reverse. You might use a transaction across one store, an outbox the settlement worker drains, or a saga with compensations. The round wants to hear that you noticed the two writes must not diverge.
Authorize is synchronous
The authorize path sits inside the shopper's checkout, so it runs on a tight latency budget and returns a decision without waiting on anything slow. Everything on this path is either fast or faked fast: dedupe, risk, route, and a real-time issuer call. If it cannot be fast, it does not belong here.
Routing is per payment
The same checkout might be a card, a local scheme, or a bank-transfer method, and each rides a different rail with its own protocol. The router picks the route per transaction and speaks each downstream rail. Retrying a declined authorization on a second route is fine, as long as it never charges twice. A generic prompt never makes you build this routing.
Capture and settle are asynchronous
Capture, settlement, and webhook delivery run behind the decision, off a durable queue, often batched. Money truly moves here, so these writes carry the same idempotency and double-entry guarantees as the authorize path. A worker crash costs a retry, never a lost or doubled capture.
The hold is provisional
Authorization reserves funds without moving them. Recording the hold as its own posting lets a capture settle it or a timeout reverse it cheaply. The ledger always knows what is provisional and what is final, and that separation makes the reconciliation pass possible.
Idempotency, exactly-once, and reconciliation
A payment platform that works in a clean demo and drifts on a real rail fails the round. Spend real time on the guarantees that keep it correct under retries and partial failure. No queue sells you exactly-once delivery. You engineer it as an effect. The network delivers at least once, clients resend, and workers crash mid-batch. So build the money layer so that applying the same logical operation twice lands the same as applying it once. Idempotency keys give you that on the write path. The append-only ledger gives you a replayable history. Reconciliation checks after the fact that the two sides agree.
Reconciliation gets its own beat, because the happy path cannot provide that safety net. Even with idempotent writes, the outside world and your books can disagree. The scheme reports a settlement your ledger never recorded, an authorization ages out without a capture, or an amount comes back different. The reconciliation pass reads the ledger postings and the payment record and matches them against what the scheme cleared. Then it flags every break for repair. It only works because the ledger is append-only and every payment state was recorded immutably. That is the payoff of designing for auditability from the start.
Authorization (synchronous)
- A hold: the issuer reserves funds but no settled money has moved yet
- Must feel instant, so it returns a decision without waiting on settlement
- Recorded as an authorized state plus a hold posting in the ledger
- Can be reversed cheaply if the capture never comes
- Answers the question "is this allowed and are the funds there?"
Capture and settlement (asynchronous)
- The real movement: settled funds actually reach the merchant
- Runs off the capture queue, often batched, behind the decision
- Recorded as a settled state plus the final balanced posting
- Is where reconciliation matches your books to the scheme
- Answers the question "did the money move, exactly once?"
The single-platform flavor
Only now, with the general system standing, add the Adyen shape, because it maps cleanly onto pieces the design already has. The usual payments picture is a chain of separate companies. A gateway sits in front, a payment processor in the middle and an acquiring bank at the back, with a bolt-on fraud vendor off to the side. Each is a hop the payment crosses and re-crosses. Adyen collapses that chain into one platform. The gateway, the risk engine and the acquiring are the same company. In the diagram they are all violet services inside one boundary. The only truly external edges are the card scheme and the issuer.
Keep the auditability thread running through the single-platform framing too. One company owns the payment from the shopper's tap to the merchant's settlement, so it also owns the whole audit trail. A card payment is exactly the kind of transaction disputed weeks later through a chargeback. Answering a dispute takes an immutable record of how the payment moved from authorization to settlement, tied to the scheme's own identifiers. So the payment record and the append-only ledger are required here. They make a chargeback answerable and a settlement break traceable end to end. The single platform raises the stakes on the audit trail and replaces none of the design.
The runnable core: exactly-once capture
The interviewer often compresses the whole design into one implementable class. For this platform the sharpest one is the authorize-then-capture core. It places a hold, captures against it exactly once under retries, and never captures more than was authorized. It makes the lifecycle split concrete. The idempotency key on capture is exactly-once in one method. The remaining-hold check is the non-negative invariant. Partial capture reflects the real-world detail that a payment is often captured in pieces as goods ship. Here a candidate either states a clean invariant or hand-waves.
The problem below keeps the money bookkeeping and drops the scheme, so it runs. Amounts are integer minor units so nothing rounds. Every authorize and capture carries an idempotency key, so a resend replays the first outcome. A capture can never exceed the funds still on hold. The exercise is handling the retried capture and the over-capture decline cleanly.
Exactly-once capture: authorize, capture, replay
Problem
-
PaymentGateway() creates an empty gateway.-
authorize(key, paymentId, amount) places a hold of amount (an integer number of minor units, such as cents) on paymentId, governed by an idempotency key:- If
key has been seen before, return the same status string recorded for it and change nothing. This is exactly-once: a retry replays the first outcome.- Otherwise, if
amount <= 0, record and return "INVALID".- Otherwise, add
amount to the hold available on paymentId, record and return "AUTHORIZED".-
capture(key, paymentId, amount) captures amount against the remaining hold, governed by idempotency key:- If
key has been seen before, return the same status string recorded for it and change nothing.- Otherwise, if
amount <= 0, record and return "INVALID".- Otherwise, if
paymentId has no authorization, record and return "NO_AUTH".- Otherwise, if
amount is greater than the remaining hold, record and return "EXCEEDS" and change nothing.- Otherwise, subtract
amount from the remaining hold, add it to the captured total, record and return "CAPTURED".-
captured(paymentId) returns the total captured for paymentId (0 if none).-
remaining(paymentId) returns the hold still available on paymentId (0 if none).The retried-key replay and the over-capture decline are the whole exercise: a resent capture must move money once, and a capture must never exceed the funds still on hold.
Input encoding. A test case is a JSON array of operations. The first entry is
["PaymentGateway", []]; each later entry is [method, [args]]. The expected array holds each operation's return, with null for the constructor.Input: PaymentGateway(); authorize("a1","p1",1000); capture("c1","p1",600); capture("c1","p1",600); captured("p1"); remaining("p1")
Output: the second capture returns "CAPTURED"; captured("p1") is 600; remaining("p1") is 400
The first capture of 600 applies and leaves 400 on hold. The second capture reuses key "c1", so it replays the stored "CAPTURED" and does not capture again; the captured total stays 600, not 1200, and the hold stays 400.
Input: PaymentGateway(); authorize("a1","p1",500); capture("c1","p1",800); captured("p1"); remaining("p1")
Output: capture returns "EXCEEDS"; captured("p1") is 0; remaining("p1") is 500
Only 500 is on hold, so a capture of 800 would exceed the authorization. It is declined, nothing changes, and the full 500 stays available.
Hold three maps: paymentId to remaining hold, paymentId to captured total, and idempotency key to recorded status. Both authorize and capture are a key lookup first, then a validate-and-apply with a few outcomes. captured and remaining are map lookups defaulting to 0.
Exactly-once capture: authorize, capture, replay
Click "Run" to execute your code
Test Cases
[["PaymentGateway",[]],["authorize",["a1","p1",1000]],["remaining",["p1"]],["authorize",["a1","p1",1000]],["remaining",["p1"]]]Expected: [null,"AUTHORIZED",1000,"AUTHORIZED",1000][["PaymentGateway",[]],["authorize",["a1","p1",1000]],["capture",["c1","p1",600]],["captured",["p1"]],["remaining",["p1"]],["capture",["c2","p1",400]],["captured",["p1"]],["remaining",["p1"]]]Expected: [null,"AUTHORIZED","CAPTURED",600,400,"CAPTURED",1000,0][["PaymentGateway",[]],["authorize",["a1","p1",1000]],["capture",["c1","p1",600]],["capture",["c1","p1",600]],["captured",["p1"]],["remaining",["p1"]]]Expected: [null,"AUTHORIZED","CAPTURED","CAPTURED",600,400][["PaymentGateway",[]],["authorize",["a1","p1",500]],["capture",["c1","p1",800]],["captured",["p1"]],["remaining",["p1"]]]Expected: [null,"AUTHORIZED","EXCEEDS",0,500][["PaymentGateway",[]],["capture",["c1","p9",100]],["captured",["p9"]]]Expected: [null,"NO_AUTH",0][["PaymentGateway",[]],["authorize",["a1","p1",0]],["remaining",["p1"]]]Expected: [null,"INVALID",0]What the round rewards
The bar on this design is correctness of money first, then a clean path to scale. The prompt might say payment service provider, card-processing pipeline or Adyen's own platform. The moves are the same. Build the general machine before the branding. That means a double-entry ledger as the source of truth, integer minor units so nothing rounds, and one component that owns every balance write. Make the money movement exactly-once with idempotency keys that cache the decision. Say plainly that this is how a retried tap stays a single charge. Split the synchronous authorize path from asynchronous capture and settlement, so the decision is fast and the money moves in its own time. Route the payment to the right acquirer and scheme. Keep an immutable payment record and an append-only ledger so the system is auditable and reconcilable. Put a hook where risk, currency and disputes will attach. Only then fold in the single-platform shape. Map the scheme and issuer onto the edge, and point at idempotency as what keeps a retried authorization from charging twice.
Key Takeaways
- ✓Trace one card transaction end to end before any Adyen flavor. It runs from authorization at the checkout boundary, through routing to the right acquirer and scheme, to capture, settlement and a double-entry ledger that reconciles. The money machine is the reusable core. Add the single-platform shape on top.
- ✓Money forces the invariants. Every posting balances to zero, real accounts never go negative while one designated clearing account may, and amounts are integer minor units rather than floats. State these before you draw a box, because the round grades them.
- ✓You engineer exactly-once yourself. Every mutating request carries an idempotency key, and the platform caches the decision, declines included. A repeat replays the first outcome without moving money again. Dedupe before every side effect so the whole payment is exactly-once.
- ✓Split by latency. Authorization is synchronous and sits in the shopper's checkout. Capture, settlement and webhook delivery run asynchronously off a durable queue. Keep the payment record and the ledger append-only so a chargeback is answerable and a settlement break is traceable.
- ✓The runnable core is exactly-once capture, built around the retried-key replay and the over-capture decline. The same idempotency and hold reasoning carries to reversal, refund and reconciliation, the follow-ups an Adyen round reaches for.
Adyen interview guideKnow what Adyen asks before you walk in.
7 coding · 1 system design · 15 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.