Building a Production-Grade Fintech Backend with NestJS
A deep dive into architecting a multi-currency wallet system, atomic transactions with TypeORM QueryRunners, and a dynamic fee engine that reads from the database at runtime.

Anthony is a fullstack software engineer with a primary focus on frontend development and experience building high-visibility, scalable, and accessible web applications.
Most tutorials stop at “here’s how to send money between two users.” Real systems don’t get that luxury. Once money is involved, everything becomes stricter: consistency, traceability, failure handling, and flexibility all need to work under pressure.
This piece walks through how to design a fintech backend that can actually survive real-world usage; using NestJS, TypeORM, and a few battle-tested patterns. The focus is on three core areas: multi-currency wallets, atomic transactions, and a dynamic fee engine.
Why NestJS for Fintech?
NestJS gives you structure without getting in your way. In fintech, that matters more than raw speed of development.
You get:
* Clear module boundaries (wallets, transactions, fees, users)
* Dependency injection that keeps services testable
* Interceptors and guards for cross-cutting concerns like logging and auth
* Easy scaling from monolith to microservices later
The goal isn’t just to “build features”; it’s to build something you can safely evolve.
Designing a Multi-Currency Wallet System
A wallet is not just a balance. It’s a ledger-backed abstraction.
The naive approach (don’t do this)
A single table:
balance: number;
This breaks quickly:
* No audit trail
* No currency separation
* Race conditions everywhere
A better model
Split responsibilities:
Wallet Table
id
userId
currency (e.g. USD, NGN, EUR)
Ledger (Transactions) Table
id
walletId
type (credit | debit)
amount
reference
status
createdAt
Optional: Balance Snapshot
walletId
balance
Instead of updating balances directly, you append entries to the ledger. The balance becomes a derived value.
Why this matters
* You can reconstruct history at any point
* You can debug issues without guessing
* You avoid silent corruption
Handling Multi-Currency Correctly
Never mix currencies in the same wallet.
Instead:
* One wallet per currency per user
* Conversion happens explicitly via a service
Example flow:
1. User wants to convert NGN → USD
2. Debit NGN wallet
3. Apply exchange rate
4. Credit USD wallet
This keeps accounting clean and auditable.
Atomic Transactions with QueryRunner
Money movement must be all-or-nothing.
If one part fails, everything should roll back.
TypeORM’s QueryRunner gives you that control.
Example: Wallet Transfer
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const sender = await queryRunner.manager.findOne(Wallet, { where: { id: senderId } });
const receiver = await queryRunner.manager.findOne(Wallet, { where: { id: receiverId } });
if (sender.balance < amount) {
throw new Error('Insufficient funds');
}
sender.balance -= amount;
receiver.balance += amount;
await queryRunner.manager.save(sender);
await queryRunner.manager.save(receiver);
await queryRunner.manager.insert(Transaction, [
{ walletId: sender.id, type: 'debit', amount },
{ walletId: receiver.id, type: 'credit', amount },
]);
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
What this guarantees
* No partial transfers
* No mismatched balances
* Consistent state even under failure
Without this, you will eventually lose money—or worse, misreport it.
Concurrency and Race Conditions
Even with transactions, you still need to think about concurrency.
Two requests hitting the same wallet at the same time can cause issues.
Strategies
1. Pessimistic locking
await queryRunner.manager.findOne(Wallet, {
where: { id },
lock: { mode: 'pessimistic_write' },
});
2. Optimistic locking (versioning)
Add a version column and reject stale updates.
3. Queueing (advanced)
For high-volume systems, push transactions into a queue and process sequentially per wallet.
Building a Dynamic Fee Engine
Hardcoding fees is fine; until business changes.
A proper system reads fees from the database at runtime.
Example Fee Table
id
transactionType (transfer, withdrawal, deposit)
currency
feeType (flat | percentage)
value
minAmount
maxAmount
Fee Resolution Logic
async calculateFee(type: string, currency: string, amount: number) {
const feeConfig = await this.feeRepo.findOne({
where: { transactionType: type, currency },
});
if (!feeConfig) return 0;
if (feeConfig.feeType === 'flat') {
return feeConfig.value;
}
return (feeConfig.value / 100) * amount;
}
Why this matters
* Product teams can adjust fees without deployments
* You can run promotions easily
* Different regions/currencies can have different rules
Ensuring Idempotency
Payment systems must handle retries safely.
If a request is repeated, it shouldn’t duplicate the transaction.
Approach
* Generate a unique reference per request
* Store it in the transaction table
* Reject duplicates
const existing = await repo.findOne({ where: { reference } });
if (existing) return existing;
Observability and Audit
You can’t fix what you can’t see.
At minimum:
* Log every transaction attempt
* Store before/after balances
* Track status (pending, success, failed)
Better:
* Emit events (Kafka, RabbitMQ)
* Build an audit dashboard
Error Handling That Makes Sense
Avoid generic errors like “Something went wrong.”
Instead:
* “Insufficient funds”
* “Wallet not found”
* “Currency mismatch”
This helps both users and developers.
Testing Strategy
If you’re not testing this layer properly, you’re gambling.
Focus on:
* Transaction rollbacks
* Fee calculations
* Concurrency scenarios
* Idempotency
Use integration tests with a real database whenever possible.
Scaling Considerations
As usage grows:
* Move heavy operations to queues
* Separate read/write databases
* Introduce caching for fee configs
* Consider event-driven architecture
But don’t over-engineer too early. Start simple, design clean.
Final Thoughts
Building fintech systems is less about flashy features and more about discipline.
The patterns that matter aren’t the ones that look impressive in demos. They’re the ones that quietly hold everything together under pressure: ledger-based accounting, truly atomic transactions, and fees that can evolve without redeploying your system.
These are the details that turn a working prototype into something you can trust with real money.
If there’s one thing worth holding onto, it’s this:
every edge case you ignore today has a way of showing up later—usually when it matters most.



