Three Patterns That Keep Fintech Systems Honest
ACID transactions, debit-before-value, and a proper ledger. The three technical patterns every engineer building financial software must internalize, with the code, the failure cases, and the tests to prove them.
Fintech has turned what once required a banking hall and paper forms into a few taps on a phone. But behind every seamless money experience sits an unforgiving engineering problem. Transaction safety, data integrity, and financial accuracy, all at once, all the time.1
This essay covers the three patterns I consider non-negotiable for anyone building financial software, a payment processor, a digital wallet, a banking platform, anything that holds a balance. They are not the whole story; production fintech also demands serious security, testing, and regulatory compliance. But these three are the backbone. Skip any of them and you will eventually pay for it, with interest.
Pattern one, wrap money movement in ACID transactions
Every inflow and outflow must be atomic. Let's earn that rule by watching what happens without it.
The naive version
Here's a simplified transfer, the kind of code that looks functional and passes a demo.
const users = [
{ id: '1212121', balance: 100 },
{ id: '3434343', balance: 50 },
];
function transferFunds(senderId, receiverId, amount) {
const sender = users.find((user) => user.id === senderId);
if (sender && sender.balance >= amount) {
// Deduct from sender
sender.balance -= amount;
// ...what if we crash right here? // [!code highlight]
const receiver = users.find((user) => user.id === receiverId);
if (receiver) {
receiver.balance += amount;
console.log('Transfer successful:', { sender, receiver });
}
} else {
console.log('Insufficient funds or sender not found.');
}
}Look at the highlighted line. If the application crashes after the sender's balance is deducted but before the receiver's is credited, the money simply disappears. Nobody has it. That is not a bug ticket, that is a financial discrepancy, and in a real system it happens under exactly the conditions you can't reproduce. Crashes, timeouts, high concurrency.
Without atomicity, three failure modes are waiting for you.
- Inconsistent data. A balance deducted, a transfer never completed. The real-life version. The ATM debits your account and the cash never comes out.
- Data corruption. Partial updates leave the database in an unpredictable state, especially under concurrent load. Like a cashier debiting your account without printing a receipt, neither side knows what actually happened.
- Lost trust. "Missing money" tickets are the angriest tickets in any support queue, and they escalate from customer complaints to legal problems faster than any other bug class.
The honest version
ACID, Atomicity, Consistency, Isolation, Durability, means every part of the operation succeeds or fails as a single unit.
async function transferFundsACID(senderId, receiverId, amount) {
const session = await database.startSession();
session.startTransaction();
try {
// Debit, the balance check and deduction are one atomic operation
const sender = await User.findOneAndUpdate(
{ id: senderId, balance: { $gte: amount } }, // [!code highlight]
{ $inc: { balance: -amount } },
{ session, new: true }
);
if (!sender) throw new Error('Insufficient balance or sender not found');
// Credit, inside the same transaction
const receiver = await User.findOneAndUpdate(
{ id: receiverId },
{ $inc: { balance: amount } },
{ session, new: true }
);
if (!receiver) throw new Error('Receiver not found');
// Both legs succeeded, commit as one unit
await session.commitTransaction();
return { status: 'success', sender, receiver };
} catch (error) {
// Any failure, roll back everything
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
}Two details deserve attention. The balance check lives inside the update query (balance: { $gte: amount }), so no concurrent request can sneak between "check" and "deduct." And the rollback path is not an afterthought, it is the entire point.
Takeaway. ACID transactions for money movement aren't good practice. They are the price of admission.
Pattern two, debit before you deliver value
This sounds obvious. It is violated constantly, usually by teams who ordered the steps for developer convenience rather than financial safety.
The safe order
Debit the customer first. Only when the debit succeeds do you deliver the value, activate the subscription, release the product, execute the trade.
async function processPurchase(customerId, amount, value) {
const session = await database.startSession();
session.startTransaction();
try {
// Step 1: debit. This is the gate. // [!code highlight]
const customer = await User.findOneAndUpdate(
{ id: customerId, balance: { $gte: amount } },
{ $inc: { balance: -amount } },
{ session, new: true }
);
if (!customer) throw new Error('Insufficient balance or customer not found');
// Step 2: deliver value, only after money is secured
const valueProvided = await Value.create([{ customerId, value }], { session });
await session.commitTransaction();
return { status: 'success', customer, valueProvided };
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
}The risky order
Deliver first, debit after, and you've built a machine for giving things away.
- Insufficient funds. The value has shipped; the debit fails; now you're doing manual reconciliation to claw it back. Like fulfilling an online order before discovering the card declines.
- Crashes. A failure between "deliver" and "debit" means the customer got value for free, silently, at scale, until someone audits.
- Concurrency. In a busy system, another transaction can drain the balance between your delivery and your debit. The window is small. At millions of transactions, small windows are guarantees.
Takeaway. money in, then value out, inside one atomic transaction. This single ordering decision prevents an entire category of financial loss, and it costs nothing to get right on day one.
Pattern three, keep a ledger and a journal
Balances tell you where money is. Only a ledger and journal tell you how it got there, and in financial systems, the second question is the one auditors, regulators, disputing customers, and 2 a.m. incident responders will actually ask. Maintain both. The ledger as the authoritative record of account balances, the journal as the append-only log of every individual movement.
Here is what they buy you.
- Audit and transparency. A clear, provable record of balances, the foundation of regulatory compliance.
- Dispute resolution. When a customer says "I was charged twice," the journal answers in seconds what a balances-only system can't answer at all.
- Fraud prevention. Irregularities are visible in detailed records and invisible in aggregates.
- Error recovery. When something goes wrong, and it will, the ledger and journal are how you reconstruct the correct state instead of guessing at it.
- Reconciliation. Your internal records must provably match external ones. Processors, banks, partners. Without a journal there is nothing to reconcile against.
These patterns in the wild
Look at any serious financial product and you'll find all three patterns load-bearing.
| System | Pattern at work |
|---|---|
| Digital wallets (Venmo, Cash App) | ACID transfers, splitting a dinner bill fires simultaneous transfers with no double-charges and no lost funds |
| Trading platforms (Robinhood) | Debit-before-value, funds are verified and locked before the order is placed |
| Subscriptions (Netflix) | Ledger, a failed renewal is resolved by consulting the record of the last successful payment |
| Cross-border transfers (Wise) | All three, currency conversion demands atomic multi-account updates with a full audit trail |
Testing what you built
Financial code earns trust through tests that attack it the way production will.
Unit-test the transaction flows for success and rollback both.
describe('TransferService', () => {
it('completes the transfer when the sender has sufficient funds', async () => {
const result = await transferFundsACID('sender123', 'receiver456', 100);
expect(result.status).toBe('success');
expect(result.sender.balance).toBe(previousBalance - 100);
});
it('rolls back fully when the receiver account is invalid', async () => {
await expect(
transferFundsACID('sender123', 'invalid456', 100)
).rejects.toThrow();
const sender = await User.findOne({ id: 'sender123' });
expect(sender.balance).toBe(previousBalance); // untouched // [!code highlight]
});
});Attack the concurrency. Stress-test simultaneous transactions, fire parallel requests at the same account, and verify isolation holds under load. The bugs that ruin fintech companies live exactly here.
Test the whole path. Integration tests from API to database, verifying that ledger entries match actual balance changes and that error paths roll back cleanly.
Test the reconciliation itself. The balance must equal the sum of its history.
describe('LedgerReconciliation', () => {
it('matches the account balance to the sum of its transactions', async () => {
const account = await Account.findById('acc123');
const transactions = await Transaction.find({ accountId: 'acc123' });
const calculated = transactions.reduce(
(sum, tx) => sum + (tx.type === 'credit' ? tx.amount : -tx.amount),
0
);
expect(account.balance).toBe(calculated);
});
});That last test is quietly the most important one in this essay. A system where balances provably equal the sum of their history is a system that cannot lie to you.
The patterns work together
ACID transactions guarantee that operations complete as a unit. Debit-before-value ensures the sequence never leaks money. The ledger and journal make every movement transparent, auditable, and recoverable. Each pattern covers a failure mode the others can't.
- Transactions without a ledger are correct but unaccountable.
- A ledger without transactions is a detailed record of your inconsistencies.
- Both together, in the wrong order, still give value away for free.
Implement all three from the first commit, and you have the foundation of a system people can trust with their money. Which, as everything else I write keeps circling back to, is the only product a fintech actually sells.
Footnotes
-
Originally published on my dev.to blog in November 2024 and revised for this site in June 2025. The patterns haven't changed. They rarely do. That is rather the point. ↩