A customer holds a wallet in dollars. She wants to send money to a supplier in the United Kingdom who only accepts pounds. Somewhere in between, that money has to exist briefly in a form that is neither dollars nor pounds, a kind of intermediate value that gets converted at a specific rate, at a specific time, and every step of that conversion has to be recorded precisely enough that both sides can trust the final number.
This is the part of fintech backend work that looks simple from the outside and turns out to be one of the fastest ways to lose real money if it is modeled carelessly. A wallet that only ever holds one currency is a spreadsheet. A wallet that has to hold several currencies, convert between them, and stay correct under concurrent access is a real system, and it needs to be treated like one from the start.
Why a single balance column breaks immediately
The natural first instinct is to give a wallet a single balance field and a currency field, then update the balance whenever money moves. This works fine until a customer holds funds in more than one currency at once, which is the entire point of a multi currency wallet. The moment that happens, a single balance column cannot represent reality anymore.
The correct starting point is treating each currency a wallet holds as its own separate, trackable balance, all belonging to the same wallet owner.
@Entity()
export class Wallet {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
ownerId: string;
@OneToMany(() => WalletBalance, (balance) => balance.wallet)
balances: WalletBalance[];
}
@Entity()
export class WalletBalance {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => Wallet, (wallet) => wallet.balances)
wallet: Wallet;
@Column()
currency: string;
@Column('numeric', { precision: 18, scale: 6 })
amount: string;
@VersionColumn()
version: number;
}
Notice the amount is stored as a numeric string, not a floating point number. Money should never be represented as a float in a backend, since floating point rounding errors are exactly the kind of thing that quietly corrupts financial data over time.
Structuring a currency conversion as its own tracked operation
Converting money between currencies is not a single update, it is really two linked operations, a debit in one currency and a credit in another, tied together by the exchange rate that was actually used at that exact moment. That rate needs to be stored, not just applied and forgotten, because a customer or an auditor may later ask exactly what rate was used for a specific transfer.
@Entity()
export class CurrencyConversion {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
walletId: string;
@Column()
fromCurrency: string;
@Column()
toCurrency: string;
@Column('numeric', { precision: 18, scale: 6 })
fromAmount: string;
@Column('numeric', { precision: 18, scale: 6 })
toAmount: string;
@Column('numeric', { precision: 18, scale: 8 })
exchangeRate: string;
@CreateDateColumn()
executedAt: Date;
}
Recording the conversion this way means the wallet balances always reflect the outcome, and the conversion record explains exactly how that outcome was reached.
Keeping conversions correct under concurrent access
Two conversions hitting the same wallet balance at nearly the same moment is a real risk, especially at any real scale. NestJS's dependency injection makes it straightforward to keep this logic in one dedicated service, and using the version column defined earlier gives you optimistic locking, so a conflicting update fails cleanly instead of silently overwriting another one.
@Injectable()
export class ConversionService {
constructor(
@InjectRepository(WalletBalance)
private readonly balanceRepo: Repository<WalletBalance>,
@InjectRepository(CurrencyConversion)
private readonly conversionRepo: Repository<CurrencyConversion>,
private readonly rateService: ExchangeRateService,
) {}
async convert(
walletId: string,
fromCurrency: string,
toCurrency: string,
fromAmount: string,
): Promise<CurrencyConversion> {
const fromBalance = await this.balanceRepo.findOneOrFail({
where: { wallet: { id: walletId }, currency: fromCurrency },
});
if (Number(fromBalance.amount) < Number(fromAmount)) {
throw new BadRequestException('Insufficient balance for conversion');
}
const rate = await this.rateService.getCurrentRate(fromCurrency, toCurrency);
const toAmount = (Number(fromAmount) * rate).toFixed(6);
let toBalance = await this.balanceRepo.findOne({
where: { wallet: { id: walletId }, currency: toCurrency },
});
if (!toBalance) {
toBalance = this.balanceRepo.create({
wallet: { id: walletId },
currency: toCurrency,
amount: '0',
});
}
fromBalance.amount = (Number(fromBalance.amount) - Number(fromAmount)).toFixed(6);
toBalance.amount = (Number(toBalance.amount) + Number(toAmount)).toFixed(6);
await this.balanceRepo.save([fromBalance, toBalance]);
return this.conversionRepo.save({
walletId,
fromCurrency,
toCurrency,
fromAmount,
toAmount,
exchangeRate: rate.toString(),
});
}
}
If two conversions try to update the same balance at once, the version column causes the second save to fail with an optimistic locking error, which the caller can catch and safely retry against the fresh balance, rather than silently applying two updates on top of stale data.
Handling exchange rates as something that changes constantly
Exchange rates are not static values you hardcode, they shift constantly and need to come from a reliable source, cached briefly so you are not calling an external provider on every single request. A dedicated module keeps this concern separate from the wallet logic itself.
@Injectable()
export class ExchangeRateService {
private cache = new Map<string, { rate: number; fetchedAt: number }>();
private readonly cacheTtlMs = 60000;
constructor(private readonly httpService: HttpService) {}
async getCurrentRate(from: string, to: string): Promise<number> {
const key = `${from}_${to}`;
const cached = this.cache.get(key);
if (cached && Date.now() - cached.fetchedAt < this.cacheTtlMs) {
return cached.rate;
}
const response = await firstValueFrom(
this.httpService.get(`/rates?from=${from}&to=${to}`),
);
const rate = response.data.rate;
this.cache.set(key, { rate, fetchedAt: Date.now() });
return rate;
}
}
Keeping the cache window short matters here. Too long and customers get stale rates that do not match what the provider is currently quoting, too short and you are calling an external API on nearly every request. A minute is often a reasonable starting point, adjusted based on how volatile the currency pair actually is.
The bigger picture
None of this complexity comes from NestJS itself, it comes from the nature of money that moves between currencies. What NestJS gives you is a clean place to put each concern, wallet balances as their own entities, conversions as their own auditable records, exchange rates handled by a dedicated service, and concurrency safety built in through the same tools the framework already gives you for other parts of the system.
A multi currency wallet that looks simple in a demo can fall apart quickly in production if any of these pieces are treated as an afterthought. Getting the structure right early is far cheaper than untangling incorrect balances after real customer money has already moved through the system.
If you are building a wallet system that needs to handle multiple currencies correctly under real production conditions, this is exactly the kind of work I focus on.
I am Peace Melodi, a backend software engineer. If you want your business to scale big, comfortably handling millions of users without breaking, with strong scalability and security in place, feel free to reach out.
LinkedIn: https://www.linkedin.com/in/melodi-peace-406494368
GitHub: https://github.com/PeaceMelodi
Top comments (0)