How a balance gets redeemed
This is the post the whole series is built around. A customer hands over a gift card; a till needs to take $18.40 off it and be certain — even if another till is touching the same card at the same instant — that the figure that comes out is exactly right. The naive way reads the balance, subtracts, and writes it back, and that gap between read and write is precisely where money goes missing. This post walks through how a balance gets redeemed with one atomic conditional write that closes the gap entirely: no double-spend, no negative balance, no model in the loop.
Key takeaways
- The naive read-subtract-write has a gap between reading the balance and writing it back — and that gap is exactly where a balance gets spent twice.
- A redemption is instead one atomic DynamoDB
UpdateItem: check, subtract, and write in a single indivisible step. - A condition expression of
balance >= :amountmeans a balance can never go negative and can never be overspent. - If two tills hit the same card at once, DynamoDB applies exactly one and rejects the other with a
ConditionalCheckFailedException. - The rejected till re-reads the now-lower balance and retries — no locks, no queue, no model, and the figure is always right to the cent.
The gap where money goes missing
Picture the obvious way to spend $18.40 off a card: read the balance ($50.00), subtract in code ($31.60), write the new balance back. On one till, on a quiet afternoon, this works perfectly. The trouble is two tills and the same card at the same second — a customer paying at the counter while a colleague applies the same card to an online order, say. Till A reads $50.00. A heartbeat later, before A has written anything, till B also reads $50.00. A writes $31.60. B, working from the $50.00 it read, writes its own figure and clobbers A’s. Two redemptions happened; only one came off the balance. The business just gave away $18.40.
That gap between the read and the write is the whole problem. Any design that reads a balance, thinks about it, and writes it back later has the gap, no matter how short. Locks can close it, but locks bring their own trouble — a till that takes a lock and then loses its connection leaves a card stuck until something times out. The cleaner answer is to never have the gap at all.
One atomic conditional write
DynamoDB lets you do the check, the subtraction, and the write as a single indivisible operation. A redemption is one UpdateItem call that says, in effect: subtract this amount from the balance, but only if the balance is still at least this amount. In the API that is an UpdateExpression of SET balance = balance - :amount guarded by a ConditionExpression of balance >= :amount. DynamoDB evaluates the condition and applies the change atomically — nothing can read or write that card’s balance in between, because there is no “in between.”
What happens in a race
Take the same two tills from the diagram. The card holds $31.60 (3160 cents). Till A wants $18.40 (1840); till B wants $40.00 (4000). Both fire at once. DynamoDB cannot apply two writes to the same item at literally the same moment — it serialises them, picking one to go first.
Say A goes first. Its condition, 3160 >= 1840, holds, so the subtraction commits and the balance becomes 1320. Till A gets a success and appends its ledger row. Now B is evaluated — but against the new balance, 1320. Its condition, 1320 >= 4000, fails, so DynamoDB rejects the write with a ConditionalCheckFailedException and changes nothing. No partial spend, no negative balance, no clobbering of A’s write. The order could just as easily have been B then A; the guarantee is the same either way — whatever the balance can actually cover gets spent, and no more.
Rejection is a normal outcome, not an error
A rejected redemption is not a failure to log and panic over — it is the system working. The redeem step catches the ConditionalCheckFailedException and treats it as a clear, expected signal: this spend did not fit the current balance. It re-reads the card, sees 1320, and hands the till the truth: “$13.20 remaining.” The cashier can then take $13.20 off the card and the rest on another tender, instead of overspending it. If the rejection was caused purely by a race — the amount would have fit, but another write got there first — the step simply retries against the fresh balance, with a couple of quick attempts and a little jitter so two racing tills do not lock-step.
One more detail keeps the books clean: the ledger row is appended only when the conditional write actually commits. A rejected attempt writes nothing to the ledger, so the history shows exactly the redemptions that happened and nothing else. And, as everywhere in this system, not a line of this depends on a model — it is plain arithmetic over integer cents, decided by a database condition.
Design rules for redemption
- One atomic
UpdateItemper redemption — check, subtract, and write in a single step. Never read-then-write. - The condition
balance >= :amountguarantees no overspend and no negative balance. - A
ConditionalCheckFailedExceptionis an expected outcome: re-read and either retry or tell the till the real balance. - The ledger row is appended only on a committed write, so the history matches reality exactly.
- No locks, no queue, no model — the database guarantee does the work.