Skip to content

ADR 0012 Single Currency Transactions

Tiana_ edited this page Jun 2, 2026 · 1 revision

ADR-0012: Single-currency transactions, FX via linked legs

Status: Accepted Date: 2026-06-01 Decider: Maintainer Amends: ADR-0007 (Layer 1 domain enforcement)

Context

ADR-0007 framed the double-entry invariant as SUM(entries.amount) = 0 per (transaction_id, currency) and enforced it in the domain constructor by grouping entries per currency. That phrasing quietly permits a single Transaction to carry legs in more than one currency, as long as each currency nets to zero on its own.

Implementing issue #7 (currency-consistency guard) forced the question to the surface: is a multi-currency transaction a real thing, or an accident of the per-currency phrasing?

The arguments against multi-currency-in-one-transaction:

  1. The SUM=0 invariant is only meaningful within one unit of measure. 100 USD + (-90 EUR) is not zero and is not anything else without an exchange rate. Adding amounts across currencies is a category error; the per-currency grouping hides that by never summing across the boundary.
  2. A "balanced multi-currency transaction" is N independent transactions glued together. If a transaction has a balanced USD pair and a balanced EUR pair, those are two economically separate events sharing one row. The shared envelope implies an atomicity that the numbers do not justify, and it makes any FX gain or loss invisible because no rate is ever recorded.
  3. Reconciliation and audit are per-currency. A trial balance is computed one currency at a time. Single-currency transactions make that mapping one-to-one; multi-currency transactions force every consumer to re-split by currency.
  4. Reference ledgers are single-currency per posting. TigerBeetle models one ledger (unit) per transfer and expresses FX as linked transfers through an FX/liquidity account. Stripe's ledger, Modern Treasury, and Formance follow the same shape: single-currency postings, FX made explicit with a recorded rate. A planned TigerBeetle storage backend (see Roadmap) has zero impedance mismatch under the single-currency model and a structural mismatch under the multi-currency one.

Decision

A Transaction carries exactly one currency. Every Entry must use it.

  • A new domain field currency: Currency on the Transaction aggregate.
  • A guard validateCurrencyConsistency() rejects, at construction, any entry whose amount.currency differs from the transaction currency, throwing CurrencyConsistencyViolationException (a DomainException).
  • Guard ordering in the constructor init is count -> duplicate-pair -> currency-consistency -> balance. Currency consistency runs before the balance check so a mixed-currency set surfaces the currency violation rather than a misleading balance violation.
  • validateDoubleEntryBalance() collapses from a per-currency grouping to a single signed SUM = 0 over all entries, because the currency guard now guarantees a single currency.

FX is modelled as two single-currency legs through a dedicated FX (liquidity) account, with the exchange rate recorded on the linking structure, not as one transaction mixing currencies. The linked-transaction grouping and FX account modelling are a future epic and are out of scope for issue #7.

class Transaction(
    val id: TransactionId,
    val reference: String,
    val description: String?,
    val entries: List<Entry>,
    val currency: Currency,
) {
    init {
        validateEntryCount()
        validateNoDuplicatePairs()
        validateCurrencyConsistency()
        validateDoubleEntryBalance()
    }
}

Relationship to ADR-0007

ADR-0007 is amended in part, not superseded. Its three-layer defense-in-depth stance still holds. Only the Layer 1 (domain) enforcement changes shape:

  • Layer 1 (domain constructor): was per-currency grouping; now single-currency guard plus single SUM=0. This ADR is the source of truth for Layer 1.
  • Layer 3 (database trigger): the GROUP BY currency HAVING SUM(amount) <> 0 trigger from ADR-0007 remains valid and unchanged. A single-currency transaction trivially satisfies a per-currency check, so the trigger stays correct as last-resort defense in depth and needs no migration.

ADR-0007 carries a forward note pointing here. Its body is immutable per the ADR process.

Consequences

Positive

  • Sound invariant: SUM=0 is always within one unit of measure. No cross-currency addition anywhere in the domain.
  • Explicit, auditable FX: a conversion becomes two single-currency legs with a recorded rate; FX gain/loss is isolatable on the FX account instead of being smeared invisibly across one mixed row.
  • One-to-one reconciliation: a transaction maps to exactly one currency's trial balance.
  • TigerBeetle alignment: matches one-ledger-per-transfer, removing a structural blocker for the planned storage backend.
  • Simpler domain code: balance check is a single fold, no grouping.

Negative

  • FX requires explicit modelling (FX account, two linked legs, recorded rate, linked-transaction grouping). This is real work deferred to a later epic. The complexity is exposed rather than hidden, which is the point.
  • Multi-currency-in-one-transaction is no longer expressible. Nothing in the codebase relied on it except tests, which were updated. Any future caller must use linked legs.
  • ADR-0007 Layer 1 snippet is now historical; readers must follow the forward note to here.

Neutral

  • The database trigger remains per-currency. That is intentional: it is harmless under single-currency and avoids a migration, while still catching any bypass that somehow introduced a foreign-currency entry.

Alternatives considered

B: Keep multi-currency, per-currency balance only

  • Rejected. Issue #7's currency guard becomes moot, mixed-currency transactions stay silently valid, FX stays invisible, and the TigerBeetle backend gains a structural mismatch.

Derive transaction currency from the first entry

  • Rejected. Makes a mismatch unrepresentable, which sounds attractive but removes the explicit caller intent and leaves the guard with nothing to validate against. An explicit field records what the caller meant and lets the guard reject what they got wrong.

Reuse core.CurrencyMismatchException

  • Rejected. It extends IllegalArgumentException (a different hierarchy from the ledger DomainException family), is a value-level error from Money arithmetic with a fixed two-argument message that cannot name the transaction, account, and expected currency, and lives in the wrong module for an aggregate invariant.

Validation

  • Unit test: a transaction whose entry currency differs from the transaction currency throws CurrencyConsistencyViolationException at construction.
  • Unit test: a mixed-currency entry set (even one balanced per currency) is rejected at the domain layer, before any I/O, by the currency guard ahead of the balance check.
  • Unit test: a single-currency unbalanced set throws DoubleEntryViolationException.
  • Unit test: a zero-amount entry is rejected independently by the Entry guard.
  • Existing happy paths (single-currency balanced, 2 and 4 entries) continue to pass.

Related

  • ADR-0007 - double-entry invariant, three-layer enforcement (amended here at Layer 1)
  • Domain-Model - Transaction aggregate definition
  • Code-Rules - Money and domain invariant rules
  • Issue #7 - Enforce currency-account match and no duplicate account+direction in Transaction

Clone this wiki locally