Skip to content
Engineering8 min read

Architecting a Multi-Currency Wallet and Balance Service

How a wallet and balance core is designed so every credit, debit, and FX conversion stays correct, auditable, and safe under concurrent access.

  • Double-entry ledger
  • PostgreSQL
  • Event sourcing
  • Idempotency keys
  • Outbox pattern
  • gRPC
  • Kafka

By TRAGenX Engineering

The problem and its constraints

A wallet and balance service looks simple from the outside: it holds a number per user per currency and lets that number go up or down. In practice it is one of the least forgiving components in a fintech stack, because every mutation is a claim about money that has to survive crashes, retries, concurrent access, and audits years later. The service has to answer "what is this account worth right now" correctly under load, and it has to answer "how did it get there" precisely, on demand, for a regulator or a support agent.

The constraints that shape the design are: multiple currencies with different precision rules (two decimal places for most fiat, eight for many crypto assets, zero for some loyalty-point-style units); a high fan-in of write sources — deposits, internal transfers, fee debits, FX conversions, reversals — all racing to touch the same account; upstream callers that retry on timeout, meaning the same logical operation can arrive twice; and a hard requirement that the system never silently loses or fabricates money, even when a dependency fails mid-operation.

Given that, a wallet service is not a key-value counter with a lock around it. It is closer to an accounting system: the current balance is a derived, reconstructable fact, not the primary source of truth.

The core data model: double-entry, not counters

The source of truth is an immutable ledger of entries, not a mutable balance field. Every account is identified by (owner_id, currency, account_type) — a user typically owns several accounts, one per currency, plus internal accounts for fees, suspense, and FX clearing. Every state change is expressed as a transaction containing two or more balanced entries: a debit on one account and a matching credit on another, always in the same currency, always summing to zero within that currency.

Amounts are stored as integers in the currency's minor unit — cents, satoshis, or whatever the smallest denomination is — against a currency metadata table that defines scale and rounding rules. Floating point is never used for money; even fixed-precision decimal types are avoided in favor of integers plus an explicit scale, because it removes an entire class of rounding ambiguity at comparison and aggregation time.

For read performance, each account also carries a balance snapshot: a cached running total plus the sequence number of the last entry it reflects. The snapshot is a materialized view of the entry log, never the authority — if it ever disagrees with the sum of entries, the entry log wins, and the discrepancy is treated as an incident, not resolved by trusting the cache.

Write path: idempotent, atomic postings

Every write arrives as a posting request carrying a client-supplied idempotency key. The key is enforced with a unique constraint on the transactions table, so a retried request that already committed is detected and answered from the existing record rather than applied twice — this is the single most important defense against network-level retries turning into double-spends.

A posting is validated (sufficient balance, matching currencies, account status) and then committed as one atomic database transaction: the entry rows, the transaction header, and the updated balance snapshots are written together, or none of them are. Account rows involved in a multi-leg posting are locked in a fixed, deterministic order (for example, by account_id) to prevent lock-ordering deadlocks when two transactions touch the same pair of accounts from opposite directions.

Cross-currency transfers are modeled as two linked postings inside the same database transaction — a debit in the source currency and a credit in the destination currency — referencing a captured FX rate snapshot by id, so the exact rate used is permanently attached to the transaction rather than recomputed later from a rate table that may have moved on.

Anything that isn't required for correctness of the balance itself — a push notification, a webhook to a partner system, an analytics event — is not done inside that transaction. Instead, the commit writes a row to an outbox table in the same atomic write, and a separate relay process reads the outbox and publishes to the message broker, guaranteeing at-least-once delivery without a distributed transaction spanning the database and the broker.

Consistency, correctness, and failure handling

The posting path runs at a strict isolation level — serializable or repeatable-read with explicit locking — because the cost of a subtle race condition on money is much higher than the cost of some contention. A background reconciliation job continuously recomputes each account's balance from its entry log and compares it against the cached snapshot; any drift pages an operator rather than resolving itself, since an unexplained drift usually means a bug, not noise.

Failure handling follows a fail-closed philosophy. A crash mid-write is covered by database atomicity — either the whole posting lands or none of it does. A duplicate request is caught by the idempotency key. If a required external dependency, such as an FX rate provider, is unavailable, the posting is rejected outright rather than applied with a stale or default rate. A poisoned outbox message goes to a dead letter queue with alerting rather than being dropped or retried forever.

What we would deliberately avoid: treating balance-affecting writes as eventually consistent across services, because "the balance will catch up later" is not an acceptable answer for money; reaching for two-phase commit across independently owned services when the same guarantee is available for free inside one database transaction plus an outbox; and splitting the ledger into a service per currency before there is a throughput reason to, since that immediately turns every FX conversion into a distributed transaction.

Scaling and operability

As long as a single primary database can sustain the write rate, the design stays deliberately boring: one ledger, strong consistency, no partitioning. When write throughput on a single primary becomes the actual constraint, accounts are sharded by account_id, keeping multi-leg postings for a given user's accounts within a shard wherever possible. Transfers that must cross shards are handled as sagas with explicit compensating entries rather than a cross-shard atomic commit, which keeps the failure semantics visible in the ledger itself instead of hidden in infrastructure.

Reads scale independently through replicas; because every write carries a monotonic sequence number, a caller that needs read-your-writes consistency can pin to a replica that has caught up to that sequence rather than always hitting the primary.

Operability leans on the fact that the entry log is immutable and append-only: any account's balance at any past point in time is reconstructable by replaying entries up to a given sequence number, which is what makes disputes and audits tractable. The metrics that matter day to day are reconciliation drift, outbox lag, and idempotency-key collision rate — not raw requests-per-second — because those are the signals that something is actually wrong with the money, not just with the traffic.

Trade-offs we made

We chose a single strongly-consistent ledger over a horizontally sharded, eventually-consistent one for the core posting path. The cost is a ceiling on write throughput set by a single primary database; the justification is that ledger correctness is the entire point of the system, and sharding can be layered on later behind the same account-id partitioning scheme once there's a measured need for it, rather than paid for upfront as speculative scale.

We chose integer minor-unit amounts with an explicit currency scale table over a shared decimal type. This means every currency needs metadata before it can be onboarded, which is friction, but it removes an entire category of silent rounding bugs at the boundary between currencies with different precision.

We chose synchronous validation with asynchronous side effects over a single call chain that also waits on notification or webhook delivery. That means a slow or down downstream notification system never blocks a wallet write, at the cost of notifications arriving with some delay and needing their own delivery guarantees through the outbox — a trade we consider clearly worth it, since a wallet that can't post a transaction because a webhook endpoint is slow is a worse failure mode than a notification that arrives a few seconds late.

Building something like this?

This is the kind of foundational systems work TRAGenX takes on. If you have a project that needs a core other things can trust, tell us about it and we'll get back to you.