-
Notifications
You must be signed in to change notification settings - Fork 0
ADR 0012 Single Currency Transactions
Status: Accepted Date: 2026-06-01 Decider: Maintainer Amends: ADR-0007 (Layer 1 domain enforcement)
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:
-
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. - 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.
- 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.
- 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.
A Transaction carries exactly one currency. Every Entry must use it.
- A new domain field
currency: Currencyon theTransactionaggregate. - A guard
validateCurrencyConsistency()rejects, at construction, any entry whoseamount.currencydiffers from the transaction currency, throwingCurrencyConsistencyViolationException(aDomainException). - Guard ordering in the constructor
initiscount -> 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 signedSUM = 0over 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()
}
}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) <> 0trigger 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Rejected. It extends
IllegalArgumentException(a different hierarchy from the ledgerDomainExceptionfamily), is a value-level error fromMoneyarithmetic 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.
- Unit test: a transaction whose entry currency differs from the transaction currency throws
CurrencyConsistencyViolationExceptionat 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
Entryguard. - Existing happy paths (single-currency balanced, 2 and 4 entries) continue to pass.
- 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
- Overview
- Services
- Data Model
- Domain Model
- Event Flow
- Security
- Observability
- Resilience
- SLA / SLI / SLO