Loading home…
Loading home…
Loading article…
How do you prove the money moved if the client retries after a timeout? I asked myself that while picturing a balance column and an UPDATE again. That week I wrote movements you never delete

No spam — just a note when I publish something new on backend, cloud, and architecture.
One email when a post goes live. Unsubscribe anytime.

A LinkedIn post on fintech architecture, a recent technical challenge, and a decision to rebuild a banking core from scratch — slower this time, to actually learn.

Three seconds on the spinner. Timeout on screen. Did the money move? I reopened my Arkano challenge next to a fintech architecture map — and understood why POST /transfer was never just an endpoint.

Six boxes on the diagram. Six microservice temptations. I almost fell for it again — until I got that the full map doesn't mean shipping it all in week one, and splitting repos doesn't split the domain
Last week I closed modeling money in code with a line that won't leave me alone: first the system has to know how to count. Then move.
Money was done. Tests passed. Cents in bigint, no floats, no shortcuts.
I thought the next step would be quick.
I opened the ledger design in my head and saw something very familiar: a balance column on accounts and an UPDATE every time someone transfers. That's what I did in a hurry in an earlier challenge months ago. That's what many tutorials teach. It works in Postman. It passes the demo.
Until I asked the question I had skipped back then:
If the client retries after a timeout, how do I prove the money moved?
I went back to those two lines from the challenge:
from.balance -= dto.amount;
to.balance += dto.amount;
For a long time I didn't see the problem. The number went down on one account and up on the other. Done.
But a silently mutated balance leaves no trail. You cannot reconstruct. You cannot audit at 3 a.m. when support asks about a transfer that "maybe" went through.
That's when I understood the ledger wasn't an UPDATE. It was something else.
The temptation is still the same: store balance on the account row and update it on every operation.
| Approach | Why it tempted me | Why I passed |
|---|---|---|
Mutable balance |
Reading balance is a simple SELECT |
You lose history; a halfway failure leaves doubt |
| Movements you never delete | More work upfront | You can reconstruct; balance is derived from what happened |
A deposit is one credit. A transfer is two movements: debit on the source, credit on the destination. Same database transaction. Same transferReference. If one leg fails, neither sticks.
Not full double-entry accounting yet. It's the first honest step inside the same service: two ledger lines, one commit.
Curiously, I didn't spin up another microservice.
I stayed in the same accounts-service in fintech-core-platform and added three things that finally let me sleep a bit better: deposit with Idempotency-Key, read balance, transfer between accounts.
Deposits use Money from the previous article. If the client retries with the same key, I return the same result without duplicating the credit. I had already promised that in the POST /transfer map; here is the first piece that actually delivers.
The transfer checks balance before writing. If funds are insufficient, InsufficientFundsError. If they are sufficient, two rows in ledger_entries:
const debit = LedgerEntry.debit({
accountId: fromId,
amount: command.amount,
transferReference,
});
const credit = LedgerEntry.credit({
accountId: toId,
amount: command.amount,
transferReference,
});
await this.ledger.appendEntries([debit, credit]);
Balance does not live on accounts. I compute it:
SUM(CASE WHEN direction = 'credit' THEN amount_cents ELSE -amount_cents END)
In TypeScript it stays Money. In PostgreSQL, bigint cents. The user sees a number; the system stores facts.
If you saw my earlier technical challenge, you might wonder where the bus went. There, as soon as money moved, something flew to a topic. Fraud could react. Notifications could be built.
Not here yet.
POST /accounts/transfers writes to PostgreSQL and responds in the same request. No producer, no consumer, no Redpanda in this milestone.
I don't see that as a step back. I see it as order: first make the ledger truthful in one database; then tell the rest of the system with events. If you publish to Kafka before you count correctly, you only amplify the mistake.
The bus comes back. In upcoming articles. With outbox and everything I already tried in the challenge, but this time on movements that don't lie.
pnpm install
docker compose -f infra/docker/docker-compose.yml up -d
cp apps/accounts-service/.env.example apps/accounts-service/.env
pnpm dev:accounts
Two accounts, one deposit, one transfer:
curl -s -X POST http://localhost:3001/accounts \
-H "Content-Type: application/json" \
-d '{"ownerName":"Alice"}'
curl -s -X POST http://localhost:3001/accounts \
-H "Content-Type: application/json" \
-d '{"ownerName":"Bob"}'
curl -s -X POST http://localhost:3001/accounts/{ALICE_ID}/deposit \
-H "Content-Type: application/json" \
-H "Idempotency-Key: dep-001" \
-d '{"amountCents":1000}'
curl -s -X POST http://localhost:3001/accounts/transfers \
-H "Content-Type: application/json" \
-d '{"fromAccountId":"{ALICE_ID}","toAccountId":"{BOB_ID}","amountCents":400}'
curl -s http://localhost:3001/accounts/{ALICE_ID}/balance
curl -s http://localhost:3001/accounts/{BOB_ID}/balance
Alice should show 600, Bob 400. Repeat the deposit with the same Idempotency-Key and it won't duplicate.
When you work with money, balance is a calculated opinion. Movements are what happened.
That doesn't close the full timeout story from the eight-layer map yet. Explicit transfer state, events, async fraud still missing. But without append-only, everything else builds on a hole.
First I counted with Money. This week I moved money inside the same process and left a trail. Then comes the bus.
Series: makingcode.dev/series/fintech-core-platform
Previous: Modeling money in code
Code: github.com/AndresED/fintech-core-platform (commit 2a7f79e)