Every payments platform reaches a moment where a single currency stops being enough. For us it arrived the day a logistics customer in France asked whether they could settle invoices in euros while their UK subsidiary kept billing in pounds. What sounded like a feature request was, in reality, a request to rebuild a set of assumptions baked into our schema, our ledger, our pricing logic, and our reconciliation jobs. Multi-currency is rarely a checkbox you tick. It is a property of the system that has to hold everywhere money is represented.
This post is a practical account of how we approached multi-currency support in a regulated environment, the mistakes we avoided because others made them first, and the engineering decisions that made the difference between a fragile bolt-on and a foundation we could build a decade of product on. None of it is glamorous, but the discipline pays compounding dividends.
Money Is Not a Number
The first thing every team gets wrong is treating an amount as a decimal and moving on. An amount of twelve point five zero is meaningless without a currency attached. Twelve fifty in Japanese yen and twelve fifty in US dollars are not comparable, do not round the same way, and do not even share the same notion of a minor unit. Yen has no minor unit at all, while most currencies have two decimal places and a few, like the Tunisian dinar, have three. If your domain model carries a bare decimal, you have already lost the information you need to behave correctly.
We model money as an immutable value object that pairs an integer amount of minor units with an ISO 4217 currency code. Storing minor units as integers rather than decimals removes an entire class of floating point and rounding bugs, and it forces every operation to be explicit about the currency it operates on. Addition between two Money values of different currencies is simply not a compilable operation in our codebase.
public readonly record struct Money
{
public long MinorUnits { get; }
public string Currency { get; }
public Money(long minorUnits, string currency)
{
if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
throw new ArgumentException("Currency must be an ISO 4217 code", nameof(currency));
MinorUnits = minorUnits;
Currency = currency.ToUpperInvariant();
}
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException(
$"Cannot add {a.Currency} and {b.Currency}");
return new Money(a.MinorUnits + b.MinorUnits, a.Currency);
}
}
Storing Amounts in the Ledger
Our ledger is the source of truth, and it never stores a converted value as if it were the original. Each ledger entry records the transacted currency and amount exactly as it occurred. When a customer pays an invoice in euros, the entry is in euros, full stop. Any conversion to a reporting currency is a separate, traceable fact with its own row, its own rate, and its own timestamp. This separation is the single most important architectural rule we enforce, because it preserves the truth of what actually happened from the derived numbers we calculate later.
In the database, every monetary column travels with a currency column, and the two are constrained together. We resisted the temptation to add a global default currency at the account level that would let downstream code omit the currency, because defaults are how implicit assumptions creep back in. Explicit is slower to write and far cheaper to maintain.
CREATE TABLE ledger_entry (
id BIGINT PRIMARY KEY,
account_id BIGINT NOT NULL,
minor_units BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
entry_type VARCHAR(16) NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
CONSTRAINT currency_is_iso CHECK (currency ~ '^[A-Z]{3}$')
);
CREATE INDEX idx_ledger_account_currency
ON ledger_entry (account_id, currency, occurred_at);
Exchange Rates as First-Class Data
An exchange rate is not a constant you fetch when convenient. It is a piece of data with a source, a validity window, and a direction. We learned to treat rates as immutable records that we store and reference, never as ephemeral lookups performed at display time. When a conversion happens, we capture which rate was used, from whom we obtained it, and the precise moment it applied. Months later, when a customer or an auditor asks why a figure looks the way it does, we can reconstruct the calculation exactly.
There is a meaningful difference between the rate used to settle a transaction and the rate used to display an indicative value on a dashboard. The former is a financial event that must be reproducible; the latter is informational and can be approximate. Conflating the two leads to disputes that are impossible to resolve because nobody can say which number was real.
If you cannot reproduce a converted amount months after the fact, from stored data alone, you do not have multi-currency support. You have a guess that happened to look right on the day.
The Rounding Problem
Rounding is where multi-currency systems quietly leak money and trust. The classic failure is splitting an amount across several line items, rounding each independently, and discovering the parts no longer sum to the whole. A hundred euros split three ways cannot be three clean amounts, and pretending otherwise creates a cent that exists in one view of the system and not another. These discrepancies are small individually and catastrophic in aggregate, because reconciliation depends on exact equality.
We adopted a single, documented rounding policy and a largest-remainder allocation method for splits, so that the residual cent is assigned deterministically rather than lost. Every rounding operation in the platform routes through one function, and that function is exhaustively tested against known edge cases. We also fixed the rounding mode explicitly rather than relying on a language or database default, because defaults vary across runtimes and a silent change in behavior is the worst kind of bug.
Enjoying this article?
Get more like it in your inbox — practical engineering leadership, fintech, and AI. No spam, unsubscribe anytime.
- Always round at a defined boundary, never repeatedly and incidentally throughout a calculation.
- Allocate split remainders deterministically so totals reconcile to the cent.
- Pin the rounding mode in code; do not inherit it from the platform.
- Never round in a currency other than the one the amount is denominated in.
- Test the awkward currencies, including zero-decimal and three-decimal ones.
Presentation and Localisation
Displaying money correctly is more than putting a symbol in front of a number. Decimal separators, grouping, symbol placement, and negative formatting all vary by locale, and the locale of the viewer is not the same as the currency of the amount. A German user looking at a dollar figure expects German number formatting around a dollar sign. We keep formatting strictly at the edge of the system, in the presentation layer, and we never let a formatted string travel back into business logic.
We rely on platform globalisation libraries for the formatting rules rather than maintaining our own table of separators and symbols, because those rules change and keeping them current is not our core competency. What we do own is the contract that every amount crossing an API boundary carries its currency code explicitly, so that the consumer is never forced to infer it. Inference is where bugs hide.
APIs That Cross Currency Boundaries
When we exposed multi-currency through our public API, we made the currency a required, non-defaultable field on every monetary payload. Clients that had previously sent a bare amount had to be migrated deliberately, which was friction we accepted because the alternative was silently misinterpreting their values. We versioned the change, communicated it well ahead of time, and provided a compatibility window, but we did not compromise on the requirement itself.
We also resisted the urge to let the API perform conversions implicitly. If a client wants an amount in a different currency, they request a conversion explicitly and receive back the rate, the source, and the resulting amount as distinct fields. An API that quietly converts is an API that hides the most financially significant decision in the request, and hidden financial decisions are exactly what regulators and customers expect us to surface.
Reconciliation Across Currencies
Reconciliation is the daily ritual that proves the system is honest, and multi-currency makes it considerably harder. You cannot net positions across currencies, so reconciliation runs per currency, and only after each currency balances on its own do you produce a consolidated view at a stated reporting rate. The consolidated figure is a report, not a fact, and we label it as such everywhere it appears so that nobody mistakes a derived total for a settled balance.
The discipline here is to reconcile the raw, transacted amounts first and treat any conversion as a later, clearly demarcated step. When a mismatch appears, the per-currency structure tells us immediately whether the problem is a real movement of money or an artifact of how we converted. That distinction saves hours during incident response, because it narrows the search to a specific layer rather than the whole pipeline.
Operational Realities
Rates come from providers, and providers have outages, stale feeds, and occasional bad data. We treat a rate feed as an untrusted external dependency, with sanity checks that reject implausible movements and alerts that fire when a feed goes quiet. A rate that jumps twenty percent in a minute is far more likely to be a data error than a market event, and acting on it automatically would be reckless. We would rather pause conversions and ask a human than book a transaction at a fictional rate.
We also keep a deliberate boundary between the rate used for real settlement and any cached rate used for non-binding display. Caching display rates is fine and keeps dashboards responsive; caching settlement rates is dangerous because staleness there has direct financial consequences. The two paths share no code, which makes it impossible for a convenience cache to accidentally feed a binding calculation.
Rollout and Migration
We did not flip multi-currency on overnight. We first backfilled an explicit currency onto every existing record, defaulting historical rows to our original operating currency, which was the correct value for that data. Only once every amount in the system carried its currency did we enable transactions in new currencies. Doing it in that order meant we never had a period where some amounts were typed and others were ambiguous, which would have made any bug nearly impossible to diagnose.
We rolled the new currencies out to a small set of cooperative customers first, watched reconciliation closely for several cycles, and only then opened it broadly. The slow rollout felt cautious to the point of frustration for the commercial team, but the first time a real euro invoice settled and reconciled cleanly without a single manual correction, the patience justified itself. Confidence in a payments system is earned one boring, balanced day at a time.

Conclusion
Multi-currency support is not a feature you add; it is a set of invariants you commit to and then defend in every layer of the system. Pair every amount with its currency, store what actually happened separately from what you later derive, treat rates as auditable data, and make rounding deterministic and centralised. Do those things consistently and the hard parts, reconciliation and presentation and API design, become tractable rather than terrifying. The work is unglamorous and largely invisible when done well, which is precisely the point: in payments, the highest compliment a system can receive is that the numbers always add up and nobody had to think about why.
Get new posts in your inbox
Occasional, practical notes on engineering leadership, fintech, and building with AI. No spam, unsubscribe anytime.
Comments (7)
Leave a Comment
Chukwuma Ibe
September 14, 2026
Does the "Rollout and Migration" still hold on a 7-engineer team? We're at the smaller end of that and some of these patterns feel like they need a dedicated platform team to run properly.
Rosie Pemberton
September 7, 2026
Good topic. Senior eng at a London fintech scaleup, working mostly on customer support tooling here. What we do differently: run a shadow processor comparing against production for a week before cut-over on NATS. It is not universally better; operational complexity is real, but the testability is dramatically better and that pays for itself the first time you have to answer a CBN question at 3am.
Nafisa Danjuma
September 2, 2026
Quick question on "Rollout and Migration" — does the pattern hold when you have to support both sync and async callers? We keep running into the bursty case and the textbook answers do not always survive contact.
Stephanie Carter
August 26, 2026
Does the "Money Is Not a Number" still hold on a 4-engineer team? We're at the smaller end of that and some of these patterns feel like they need a dedicated SRE to run properly.
Ikechukwu Ezeh
August 6, 2026
10 years at Interswitch before going independent. We hit this exact thing with NIBSS Verve settlement last May — tail latency was the presenting symptom, and the "Exchange Rates as First-Class Data" section is basically how we untangled it. Ended up adding a sidecar processor on Thales HSM, cut p99 latency by 44%. For context: 361k txn/day.
Lauren Lee
August 4, 2026
Good writeup. One nit on "Presentation and Localisation": worth mentioning DLQ replay tooling — otherwise the pattern degrades under real load.
Amara Eze
August 4, 2026
Quick q on "Operational Realities" — how do you handle partial failures when the downstream service times out? We're on whatever the seniors set up and our current answer is jitter-and-pray.

