Part 7 · 9 chapters · ~20 min

Round seven: “multi-currency, multi-jurisdiction”

The "single currency for now" from round one comes due. Adding currencies is not adding a column, because money in different currencies is not comparable, and an invariant that summed globally must now sum per currency. Then jurisdiction arrives, which is harder still: not a technical constraint but a legal one that decides where bytes may physically live.

79

The pressure: one ledger, many moneys

interviewer

“Customers now hold NGN, USD, EUR and GBP. They convert between them. We operate in Nigeria, the UK and the EU, and each regulator has opinions about where data lives. What breaks?”

What breaks is the assumption buried in every balance calculation so far, which is that amounts can be added together. They cannot. ₦500 plus $500 is not 1,000 of anything.

what silently breaks if currency is treated as a mere column
  1. The invariant. SUM(amount) = 0 across a journal becomes meaningless when the entries are in different units.
  2. Balance queries. A missing WHERE currency = ... returns a number that looks plausible and is nonsense.
  3. Limit checks. "Daily limit 1,000,000" in which currency?
  4. Rounding. Two decimal places is wrong for JPY, KWD, and about twenty others.
  5. Reconciliation. A trial balance must balance per currency, and one that nets across currencies hides real breaks.
functional, new
  1. A customer holds separate balances per currency, each independently auditable.
  2. A customer can convert between currencies at a quoted rate.
  3. The bank tracks its own FX position and earns the spread as revenue.
  4. Customer data lives in the jurisdiction that requires it.
  5. Compliance rules apply per jurisdiction, without forking the codebase.
non-functional, new
  1. The invariant holds per currency, per journal, and is checked as such.
  2. No arithmetic ever combines amounts of different currencies without an explicit, recorded rate.
  3. A stale FX rate cannot be used to price a conversion.
  4. Cross-region reads must not violate residency rules, even accidentally.
80

Currency as a dimension, not a column

The distinction sounds like vocabulary and is structural. A column is data you filter by when you remember to. A dimension is part of the identity of the thing, so forgetting it is impossible rather than merely unwise.

ApproachModelProblem
Currency as a column on the entryOne account holds many currencies; each entry states its ownA balance query without a currency filter returns garbage. Nothing in the schema prevents it, and the bug is invisible in review.
One account per currency(owner, currency) identifies an account. Currency is on the account, and entries inherit itChosen. A balance is per account, so it is per currency by construction. A cross-currency sum becomes impossible to write by accident.
A separate ledger per currencyFully isolated schemas or databasesStrongest isolation, and it makes an FX conversion a cross-database saga for no real benefit over the middle option.
code
-- currency lives on the ACCOUNT. an entry cannot disagree with it.
CREATE TABLE accounts (
  id        UUID PRIMARY KEY,
  owner_id  UUID,
  kind      TEXT NOT NULL,
  currency  CHAR(3) NOT NULL REFERENCES currencies(code),
  -- one wallet per owner per currency. the pair IS the identity.
  UNIQUE (owner_id, kind, currency)
);

-- the entry still carries currency, and the database ENFORCES that it
-- matches the account. belt and braces, because this is the invariant
-- that protects every other one.
ALTER TABLE entries ADD CONSTRAINT entry_currency_matches_account
  CHECK (TRUE) -- enforced by trigger or by a composite FK:;

ALTER TABLE entries
  ADD FOREIGN KEY (account_id, currency)
  REFERENCES accounts (id, currency);   -- requires UNIQUE(id, currency)
why the composite foreign key is worth the trouble
It makes "a USD entry against an NGN account" unrepresentable. Not caught in review, not caught by a test, rejected by the database. When the cost of an error is a corrupted ledger, pushing the constraint as far down as it will go is the correct instinct, and it is the same reasoning as REVOKE UPDATE, DELETE in Part 2.
81

Minor units, exponents, and JPY vs KWD

Round one stored money as integer minor units and flagged that the exponent is not always 2. Here is where that matters.

CurrencyExponentMinor unit100.00 displayed stores as
NGN Nigerian naira2kobo10000
USD US dollar2cent10000
JPY Japanese yen0yen100
KWD Kuwaiti dinar3fils100000
BHD, OMR, TND3fils / millime100000
CLF Chilean unit of account4n/a1000000
code
CREATE TABLE currencies (
  code      CHAR(3) PRIMARY KEY,      -- ISO 4217
  exponent  SMALLINT NOT NULL,        -- 0, 2, 3, 4. NEVER assumed.
  name      TEXT NOT NULL,
  -- some currencies are quoted per 100 units by convention (JPY pairs).
  -- display concerns belong here too, not scattered in the frontend.
  symbol    TEXT,
  is_active BOOLEAN NOT NULL DEFAULT TRUE
);
code
// formatting reads the exponent. it never divides by 100.
function format(minor: bigint, ccy: Currency): string {
  if (ccy.exponent === 0) return minor.toString();
  const divisor = 10n ** BigInt(ccy.exponent);
  const whole = minor / divisor;
  const frac  = (minor < 0n ? -minor : minor) % divisor;
  return `${whole}.${frac.toString().padStart(ccy.exponent, '0')}`;
}

// and parsing is the inverse, also driven by the exponent. a user typing
// "100" in JPY means 100 minor units; in KWD it means 100000.
the bug this prevents, concretely
A system that hardcodes two decimals and receives a ¥10,000 payment will store 1000000 instead of 10000, making the payment 100 times too large. It will also display ₩ and JPY amounts with phantom decimal places that no customer recognises. Both are real, shipped bugs, and both are prevented by one column read at the point of formatting.
82

The zero-sum invariant, restated per currency

The most important correction in this part. The check from chapter 16 is now wrong, and wrong in a way that would hide real errors.

worked numbers
OLD, now incorrect:

            SELECT journal_id, SUM(amount) FROM entries

             GROUP BY journal_id HAVING SUM(amount) <> 0


          a journal with −50000 NGN and +50000 USD passes this check.

it sums to zero and it is catastrophically unbalanced.


NEW, correct:

            GROUP BY journal_id, currency HAVING SUM(amount) <> 0
        
code
-- the invariant, correctly stated. must return zero rows, forever.
SELECT journal_id, currency, SUM(amount) AS drift
  FROM entries
 GROUP BY journal_id, currency
HAVING SUM(amount) <> 0;

-- and the in-transaction assertion updated to match
DO $$ BEGIN
  IF EXISTS (
    SELECT 1 FROM entries
     WHERE journal_id = $jid
     GROUP BY currency
    HAVING SUM(amount) <> 0
  ) THEN RAISE EXCEPTION 'ledger imbalance per currency on %', $jid;
  END IF;
END $$;
how to raise this in the room
Go back and correct yourself out loud: "The invariant I wrote in round one is now wrong. SUM(amount) = 0 per journal has to become per journal per currency, otherwise a journal with a naira debit and a dollar credit passes a check it should fail." Volunteering a correction to your own earlier work is one of the strongest signals available in an interview, because it shows you are tracking the design's consequences rather than defending your first answer.

What this means for an FX conversion

A conversion has a naira leg and a dollar leg, and neither sums to zero on its own against the customer. So a conversion cannot be two entries. It must be four, and the extra two are the bank's own position, which is the subject of the next chapter.

83

FX conversion as two coupled postings

A customer converts ₦800,000 into USD at a rate of 1,600. The design question is how to express that in a ledger where each currency must balance independently.

convert ₦800,000 → USD at customer rate 1,600 · one journal, four entries, two currencies
wallet A (NGN)NGN−80000000customer gives up naira
fx_position:NGNNGN+80000000bank now holds the naira
Σ NGN0naira balances independently ✓
fx_position:USDUSD−50000bank gives up dollars
wallet A (USD)USD+50000customer receives $500.00
Σ USD0dollars balance independently ✓
invariantper currencyboth legs zero. no cross-currency arithmetic in the ledger at all

Where the rate actually lives

Notice that no entry contains the rate. The entries are two independent, balanced facts. The rate is metadata on the journal, recorded for audit but not participating in any arithmetic the invariant checks:

code
CREATE TABLE fx_conversions (
  journal_id    UUID PRIMARY KEY REFERENCES journal(id),
  from_currency CHAR(3) NOT NULL,
  from_amount   BIGINT NOT NULL,
  to_currency   CHAR(3) NOT NULL,
  to_amount     BIGINT NOT NULL,
  -- the rate as an exact decimal, never a float. and BOTH rates:
  -- what we charged the customer, and what the market was.
  customer_rate NUMERIC(20,10) NOT NULL,
  market_rate   NUMERIC(20,10) NOT NULL,
  quote_id      UUID NOT NULL,   -- which quote authorised this
  quoted_at     TIMESTAMPTZ NOT NULL
);
why storing both rates matters
The difference between customer_rate and market_rate is the spread, which is the bank's revenue on this conversion. Storing both makes FX revenue a query rather than a reconstruction, lets you answer "were we competitive on this corridor last month", and satisfies the regulatory expectation in several jurisdictions that the margin charged be demonstrable per transaction.
FX conversion
two currencies, each balancing separately
swipe the figure sideways, or tap expand for full screen
1/8
debit NGN wallet
The customer gives up 800,000 naira, so their naira wallet is debited 80,000,000 kobo.
84

Quote, spread, and the treasury position

The bank is taking the other side of every conversion, which means it is carrying risk. Three mechanisms manage that.

One: the quote, which must expire

code
CREATE TABLE fx_quotes (
  id            UUID PRIMARY KEY,
  pair          TEXT NOT NULL,       -- 'NGN/USD'
  customer_rate NUMERIC(20,10) NOT NULL,
  market_rate   NUMERIC(20,10) NOT NULL,
  max_amount    BIGINT NOT NULL,     -- a quote is size-bounded
  customer_id   UUID,                 -- quotes are not transferable
  -- short. 30 to 120 seconds. an expired quote CANNOT be executed.
  expires_at    TIMESTAMPTZ NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
why every property of a quote exists
  1. Expiry bounds market risk. If the naira moves 3% and a customer executes a five-minute-old quote, the bank eats the difference.
  2. Size cap prevents a retail quote being used for an institutional trade the spread was never priced for.
  3. Customer binding stops a favourable quote being shared or resold.
  4. Both rates stored so the spread earned is known at execution, not reconstructed later.
code
-- execution atomically consumes the quote, so a quote cannot be used twice.
-- this is the Part 2 conditional write, applied to a quote instead of a balance.
UPDATE fx_quotes SET consumed_at = now()
 WHERE id = $quoteId
   AND consumed_at IS NULL
   AND expires_at > now()
   AND customer_id = $customer
   AND max_amount >= $amount;
-- 0 rows ⇒ expired, already used, wrong customer, or too large. reject.

Two: the position, which is the bank's exposure

worked numbers
          fx_position:USD balance = net dollars the bank is long or short


          customers buy $500 → position:USD goes −50000 (short dollars)

          customers sell $300 → position:USD goes +30000

          net position                = −20000 = short $200


a short USD position loses money when USD strengthens

so treasury hedges: buy $200 in the market to flatten it

The position account balance is the exposure, live, queryable, and derived from the same entries as everything else. No separate position-keeping system, no reconciliation between the ledger and a risk system, because they are the same data.

Three: the spread, booked as revenue

recognising the spread on the ₦800,000 conversion · market 1,592 vs customer 1,600
fx_position:NGNNGN−400000₦4,000 of the naira received is margin
fx_income:spreadNGN+400000bank revenue, in naira
Σ NGN0revenue recognised in the currency it was earned in
the tradeoff to name
Hold the position or hedge immediately? Holding it means the bank keeps the spread and takes market risk, which can exceed the spread many times over. Hedging immediately locks the spread and gives up any upside. Most banks set a position limit per currency and hedge automatically when it is breached, which is an alert on the position account balance. If Z changed, if volumes grew large enough that intraday moves mattered, I would move to continuous delta hedging rather than threshold-based.
85

Data residency and jurisdictional partitioning

The shift here is that this is not an engineering optimisation. It is a legal constraint, and the penalty for getting it wrong is a regulator rather than a latency graph.

JurisdictionRequirementArchitectural consequence
Nigeria (CBN, NDPA)Customer and transaction data held within Nigeria; regulator access on demandA Nigerian region that is complete, not a cache of somewhere else
EU (GDPR)Transfers outside the EEA need a legal basis; strong subject rights including erasureAn EU region, plus a documented answer to erasure that does not break the ledger
UK (FCA, UK GDPR)Similar to the EU, with safeguarding rules for client moneySegregated client-money accounts, reportable separately
US (state and federal)Varies; BSA/AML reporting obligationsReporting pipelines per obligation rather than one global report

The partitioning model: region outside, shard inside

worked numbers
          region        = ng | eu | uk | us   ← legal boundary, immovable

                        ↓

          logical shard = hash(account_id) % 4096  ← performance, inside a region


          so an account's home is:  (region, logical_shard)


a customer's region never changes. it is assigned at onboarding

          from their residency and cannot be migrated without a legal process.
        

This layering is why Part 3 chose account-based sharding rather than region-based. Region is the outer, legally-mandated layer; sharding is the inner, performance layer. They compose cleanly, and the routing layer already built in Part 3 extends with one field.

Cross-region transfers

A Nigerian customer sending to a German one is now a cross-region saga, which is the Part 3 pattern with a stricter rule attached:

what may and may not cross
  1. May cross: the minimum payment instruction. Amount, currency, beneficiary identifier, a reference.
  2. Must not cross: the full customer profile, identity documents, transaction history, behavioural data.
  3. The mechanism is the same in-transit suspense account per region, so each region's ledger still sums to zero independently.
  4. Enforcement lives in the transport layer: the cross-region event schema simply has no fields for the data that may not travel. Make the violation unrepresentable rather than forbidden by policy.
the design instinct worth stating
Every time a rule must be obeyed, ask whether it can be made structurally impossible to break instead. Currency mismatches became a foreign key. Ledger edits became revoked privileges. Residency violations become a schema with no field for the forbidden data. Policies get violated; schemas do not.
86

Per-jurisdiction compliance as policy, not code

Four jurisdictions, each with its own limits, reporting thresholds, retention periods and screening requirements. The wrong answer is conditionals.

code
// this is the shape that becomes unmaintainable by the third country,
// and untestable by the fourth.
if (region === 'ng') {
  if (amount > 5_000_000_00n) requireReport();
  if (tier === 1 && amount > 50_000_00n) reject();
} else if (region === 'eu') {
  if (amount > 10_000_00n) requireReport();
  ...
}

Policy as data, evaluated by one engine

code
CREATE TABLE compliance_policies (
  id             UUID PRIMARY KEY,
  jurisdiction   CHAR(2) NOT NULL,
  rule_type      TEXT NOT NULL,   -- limit | report | screen | retain
  applies_to     JSONB NOT NULL,  -- {kind, currency, kyc_tier, corridor}
  parameters     JSONB NOT NULL,  -- {threshold, window, report_to}
  -- rules change by regulation, and past decisions must remain
  -- explicable under the rule that applied AT THE TIME.
  effective_from TIMESTAMPTZ NOT NULL,
  effective_to   TIMESTAMPTZ,
  source_ref     TEXT              -- the circular or directive it implements
);
what this buys
  1. A new jurisdiction is configuration, not a release. Compliance staff can prepare rules without engineering capacity.
  2. Rules are versioned in time, so a 2024 decision can be re-explained under the 2024 rule rather than today's.
  3. Every decision is auditable to the specific rule and its source document, which is exactly what a regulator asks for.
  4. The engine is tested once, and each rule is data validated against a schema.
code
// every compliance decision is RECORDED, with the rule version used.
// "why was this blocked?" must be answerable years later.
interface ComplianceDecision {
  journalId: string;
  jurisdiction: string;
  outcome: 'allow' | 'block' | 'allow_with_report' | 'review';
  rulesEvaluated: { policyId: string; version: string; matched: boolean }[];
  decidedAt: Date;
  // if a human overrode the engine, who, when, and on what authority.
  override?: { by: string; reason: string; approvedBy: string };
}
the sentence that signals maturity here
"Compliance rules change on a regulator's timetable, not a release schedule, so they are data with validity periods rather than code. And every decision records which version of which rule produced it, because the question is never just 'is this blocked', it is 'explain, two years later, why this was blocked under the rules in force that day'."
87

Sketch v7: currency-aware, region-aware core

What changed, and the cost accepted

ChangeDriven byCost accepted
Currency on the account, composite FKCross-currency sums must be unrepresentableOne wallet per currency per customer, so more accounts
Exponent tableJPY has 0 decimals, KWD has 3Every format and parse path reads it
Invariant per currencyA global sum hides real imbalancesA correction to the round-one check, stated out loud
FX position accountsThe bank takes the other side of every conversionTreasury must monitor and hedge; position limits become alerts
Expiring, bound quotesMarket risk on stale ratesCustomers can see a quote expire, which the UX must handle
Regional deploymentsLegal residency, not latencyFull stack per region; cross-region transfers are sagas
Compliance policy as dataFour regulators on four timetablesA policy engine to build and a store to govern

The invariants, restated for v7

enforced
  1. Entries sum to zero per journal, per currency.
  2. An entry's currency must equal its account's currency, by foreign key.
  3. Each region's ledger sums to zero independently.
  4. No amount is formatted or parsed without reading its exponent.
  5. An expired or reused quote cannot price a conversion.
  6. Protected personal data has no field in the cross-region schema.
how to close round seven
"v7 makes currency part of an account's identity rather than a column, which makes the dangerous query impossible to write. The correction I want to flag is that the round-one invariant was wrong and is now per currency. FX is four entries through a position account, so the bank's exposure is a live ledger balance rather than a separate risk system. And regions are a legal boundary, sitting outside the sharding from round three. What I have not handled is the card network, which imposes a latency budget far tighter than anything so far."
architecture v7
regions outside, shards inside
swipe the figure sideways, or tap expand for full screen
1/8
four regions
Four jurisdictions, four regulators, four sets of rules about where data may live.