Technical Excellence

Money Has Rules
the Framework Does Not Know

Part IV · Design Payments · Data rules

Ten requests leave at the same instant. Each carries the same transfer of seven euros and the same idempotency key. When all ten have come back, the test counts the rows in the transfers table. It passes only if it finds exactly one, and only if the sender's balance fell by seven euros, once.

That test lives in a banking demo I built to walk the whole lifecycle, and the suite that holds it passed on both runs in the project's results log, the latest on 11 September. It is there because of about seven years at a global payments company in Lahore. A framework arrives with sensible defaults for data in general: a number type, an update, a retry, an event. Money needs different ones, and none of them arrive on their own. What those years taught me about the people behind the rows is a different essay. This one is about the rows.

// the crux

You never edit money. You move it, and every move leaves a row.

// in one breath
  • Ten identical requests, one row, and why the cache is not the thing keeping count.
  • Five defaults a framework hands you, and the rule money needs in place of each.
  • The one rule my demo has written down and deliberately not built.
the number

The Number the Framework Hands You

Most languages hand you a floating-point number for a decimal, stored as the nearest binary value it can hold. Python's own tutorial shows the cost in one line: 0.1 + 0.1 + 0.1 == 0.3 is False. In a physics simulation that is noise. In a ledger it is a total that stops matching the rows it came from.

The demo stores every amount as a whole number of the smallest unit, cents, in a BIGINT column, and does integer arithmetic only. In code, one Money type carries that count together with its ISO currency code. Adding one currency to another throws. So does an amount with a third decimal place, which is refused rather than rounded. Addition is exact, so an overflow fails loudly instead of wrapping around. Martin Fowler catalogued the idea as the Money pattern in Patterns of Enterprise Application Architecture. An amount never travels without its currency.

The demo only speaks euros, so its two decimal places hold. A real core cannot assume them. The ISO 4217 list publishes how many minor units each currency has: two for the euro and the Pakistani rupee, none for the Japanese yen, three for the Kuwaiti dinar. I have lived in the countries behind the first three, and a hardcoded two would have been wrong in one of them.

the history

A Transfer Is Never Edited

A framework's default resource has four verbs: create, read, update, delete. The standards my demo runs on, which I wrote up with an AI assistant, name three kinds of record that must stay immutable: money movement, audit events and completed transactions. They get create, read and state transition, and never update or delete. A cancellation is not a DELETE. It is a new request against the transfer, POST /api/v1/payments/transfers/{id}/cancellation, which leaves the original as it was and records the change of mind.

The demo has no cancellation yet, so the rule shows in what is missing: no endpoint updates or deletes a transfer. The ledger table states the rule in its opening comment, append-only and double-entry, never updated and never deleted. Even a failed transfer stays. When one finds insufficient funds, its transaction still commits, as a row marked FAILED, and the caller receives the business error. The attempt happened, so the history keeps it.

the second request

What Does the Second Request Get?

Networks drop replies. A client that sent a transfer and heard nothing back cannot tell whether the money moved, so it sends the request again, and without a rule that retry is a second transfer. The rule is an Idempotency-Key: the client makes up one key for one logical attempt and sends it with every retry of that attempt.

The header has no standard behind it: the IETF draft by Jayadeba Jena and Sanjay Dalal reached its seventh version in October 2025 and expired without becoming an RFC. A convention only holds if the server keeps its side exactly.

My standards give the server three duties. A unique index in the database is the source of truth for the key, not the cache in front of it. A replay returns the original outcome, error included, so a transfer that failed for insufficient funds fails again with the same answer, down to the same error code. And a key that comes back with a different request body is refused, because it no longer names the same attempt.

The demo keeps a fast path for keys in Redis, and its code says how far to trust it: the cache is a shortcut, the unique index decides, and flushing the cache can never post a transfer twice. The first thing a new transfer does inside its transaction is insert its pending row, which claims the key in that index before any money moves.

That is what the ten requests in the opening are testing. A request that arrives while another still holds the key gets a 409 and can try again. A retry after the first one finishes gets the original result back, with a header saying it is a replay. The table holds one row. The balance moved once.

the event

The Event That Left Before the Commit

A transfer has to tell the rest of a system that it happened: a statement, a notification, a fraud check. The trap is the timing. Publish the event inside a transaction that later rolls back, and the rest of the system believes in a transfer that never existed. Publish it after the commit from a process that crashes in between, and the transfer exists with nobody told.

Inside the demo, timing is enough. Its audit trail and business metrics hear about a transfer only after the commit, because, as the code puts it, an audit line or a metric for a transaction that later rolled back would be a lie. That works because none of those events leaves the process.

The moment an event has to leave, my standards switch to a transactional outbox, the pattern Chris Richardson documents on microservices.io. The event is written as a row in the same database transaction as the transfer, so both commit or neither does, and a separate relay delivers it afterwards. A relay can deliver the same event twice, which is why the consumers need the rule from the previous section, keyed by the event's id. The demo has this rule written down and not built, because nothing in it needs one yet.

the order

Two Transfers Meet in the Middle

Alice sends Bob a euro at the same moment Bob sends Alice one. Each transfer locks the two account rows it touches. If Alice's transfer takes her row first while Bob's takes his, each now waits for the row the other holds, and neither will ever let go. That is a deadlock, and all it needs is two customers and bad timing.

The fix is older than any framework in use today. In 1968, J. W. Havender described it in the IBM Systems Journal, from work on the job initiator of IBM's System/360 operating system: take resources in one agreed order, and a circle of waiting can never form. The demo locks both accounts in ascending order of their ids, whichever way the money is going.

Its test sends ten transfers each way between two accounts, all released at once. Every one has to come back 201, with no deadlock, no lock timeout and no server error, and the two balances have to add up to exactly what they did before. A second test fires fifteen transfers of 100 euros at an account holding 1,000. Ten succeed, five are refused, and the balance lands on zero.

Underneath both sits a constraint that should never fire: the accounts table refuses any customer balance below zero. The funds check under the row lock is the real guard. The constraint is there for the day that check is wrong, so the bug becomes a rolled-back transaction instead of a negative balance that nothing reported.

Locking in order is one answer, and optimistic locking with a retry is another, better suited to rows that rarely collide. My standards accept either, provided the choice is written down with its reason.

the five, together
Five defaults, five rules, and where each one stands in the demo
#The framework's defaultThe money ruleIn the demo
01A floating-point decimalInteger minor units and one Money typeWhole-number amounts; a type that refuses a third decimal place
02Update and delete on every resourceCreate, read and state transition for money recordsNo update or delete endpoint; an append-only ledger; failed transfers kept
03A retry runs the request againAn Idempotency-Key, with a database index as the source of truthTen concurrent requests with one key leave one row
04Events published inside the transactionAfter the commit in the process, through an outbox once they leave itAudit and metrics listen after commit; the outbox is written down, not built
05Rows locked in whatever order the code reaches themOne lock order, or an optimistic retry, with the reason written downTen transfers each way, no deadlock, the total unchanged
// the part worth keeping

The constraint that should never fire is the one written for the day your code is wrong.

The demo has no customers and no real money in it. It keeps four of the five rules anyway, and writes the fifth down for the day an event leaves the process. The frameworks under it will change more than once. Ten requests arriving at the same instant will not.
// carry forward

Money rules are the data half of building payments. Two pieces planned for this series take the other half: which failures deserve a retry at all, and the choice between replying now and replying later that decides where an outbox is needed.

// continue exploring