Part 3 of 7 · Gift card ledger series ~7 min read

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 >= :amount means 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.”

How a balance gets redeemed: two tills race for the same card and only one conditional write wins Inside the dotted AWS account container, a single card row sits in the centre: card GC-7K2M-4QYX with balance 3160 cents (that is $31.60). Two external tills are drawn at the top, side by side. Till A wants to redeem 1840 cents ($18.40); Till B wants to redeem 4000 cents ($40.00). Both fire at the same instant, shown by two arrows pointing down into the card row, each labelled with the same atomic operation: UpdateItem, SET balance = balance - amount, with a condition balance >= amount. A divider shows DynamoDB serialises them: one is applied first. Till A’s write is evaluated against balance 3160, the condition 3160 >= 1840 holds, so it commits and the balance becomes 1320 ($13.20); Till A gets success and a ledger row is appended. Till B’s write is now evaluated against balance 1320, the condition 1320 >= 4000 fails, so DynamoDB rejects it with a ConditionalCheckFailedException; nothing changes. An arrow shows Till B re-reading the now-lower balance 1320 and being told $13.20 remaining, so the cashier does not overspend the card. A bottom note reads: no locks, no queue, no model — a balance can never be spent twice and can never go negative. Till A — redeem $18.40 UpdateItem: balance -= 1840 condition: balance >= 1840 Till B — redeem $40.00 UpdateItem: balance -= 4000 condition: balance >= 4000 applied first applied second AWS account — DynamoDB serialises the two writes Card GC-7K2M-4QYX balance 3160 ($31.60) A: 3160 >= 1840 — commits balance → 1320 ($13.20) success + ledger row appended B: 1320 >= 4000 — fails ConditionalCheckFailed nothing changes; re-read $13.20 No locks, no queue, no model — a balance can never be spent twice and can never go negative.
Fig 3. Two tills race for the same $31.60 card. DynamoDB serialises the writes: Till A’s condition holds and commits ($13.20 left); Till B’s condition now fails and is rejected. Till B re-reads the real balance and the card is never overspent.

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 UpdateItem per redemption — check, subtract, and write in a single step. Never read-then-write.
  • The condition balance >= :amount guarantees no overspend and no negative balance.
  • A ConditionalCheckFailedException is 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.
All posts