Skip to main content

Command Palette

Search for a command to run...

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.

Updated
5 min read
Building a Production-Grade Fintech Backend with NestJS
M

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.