A ledger should not own a money type
Most ledger libraries ship their own Money class, so any system that already had a money type ends up with two of them and a conversion layer in between. A ledger does not need to own a money type. It needs a contract: an exact amount, the asset that amount is denominated in, and the arithmetic of netting — which a protocol can express without defining a class.
That is how I built ledger-core: it posts any value satisfying a MoneyLike protocol and defines no money type of its own. The protocol itself was the easy part. The interesting part was what a protocol refuses to give you, and where that shows up in the API.
The second money type is the bug#
In the payment systems I have worked on since 2018, money arrives in at least three shapes before anyone writes a line of domain code: an integer count of minor units from a provider, a numeric column from the database, and whatever the application decided a currency-carrying value looks like. Each boundary between those shapes is a function that takes a number and a currency code and returns another number and another currency code, and every one of those functions is a place where quantization, code casing, and — worst — the binding between the amount and its currency can be quietly lost. A Decimal that has lost track of what it is denominated in is just a number, and numbers add up fine no matter how wrong the result is.
A ledger that ships its own money class adds another shape to that list, in the one component whose entire job is to be right about netting. So: one money type per system, provided by the currency library, consumed by everything else.
What the ledger actually needs from a money value#
All of it fits in one module:
@runtime_checkable
class MoneyLike(Protocol):
"""An exact amount tied to the asset it is denominated in."""
@property
def amount(self) -> Decimal: ...
@property
def currency(self) -> str: ...
def __add__(self: M, other: M) -> M: ...
def __sub__(self: M, other: M) -> M: ...
def __neg__(self: M) -> M: ...
def __lt__(self: M, other: M) -> bool: ...Two properties and four operations. amount is a Decimal because a ledger that cannot represent an amount exactly has no business claiming entries net to zero. currency is on the value, not passed alongside it, so the pairing cannot come apart in transit.
What is missing matters more. There is no multiplication, because a ledger posts and nets; it does not price. There is no division, because splitting one incoming amount across several parties is a separate problem with its own exactness invariant — an integer allocation over minor units, not a fraction of a money object — and it does not belong behind an operator. The contract stays at four operations because those four are all the ledger performs.
M is a TypeVar bound to MoneyLike, so the arithmetic is self-typed: __add__ takes the same type it returns. The protocol does not merely permit some money type, it rules out mixing two implementations in a single expression, which is exactly the failure mode a second money type introduces.
The ledger does keep one opinion about currency, because it stores currency codes on accounts and needs entries to net per currency:
class CurrencyMismatch(ValueError):
"""Raised when amounts in different currencies are combined."""
def validate_currency(code: str) -> str:
if not isinstance(code, str) or len(code) != 3 or not code.isalpha() or not code.isupper():
raise ValueError(f"currency must be a 3-letter uppercase ISO 4217 code, got {code!r}")
return codeMore on that check below — it is the part of my own design I would change.
A protocol gives you arithmetic, not constructors#
This is the part nobody warns you about when they tell you to depend on abstractions. A protocol types values you were handed. It does not give you a way to make one. There is no MoneyLike.zero(currency), no parse, no seed value for sum(). A library that consumes a protocol can fold, compare and negate, but it cannot produce a value out of nothing.
Most of the ledger never notices, because every amount it works with came in through an entry. Netting the sides of a movement within a currency needs no seed as long as there is at least one posting to start from. The place where it does notice is the empty account. Holds have to answer "what is available here" for an account that has never been posted to, and the honest answer is zero — but zero of which class?
holds.balance(customer) # what the account holds
holds.held(customer) # what open holds have reserved
holds.available(customer) # balance minus holdsThose return Decimal in the account's currency, not a money object. The ledger owns no money type, so it cannot mint the zero an empty balance would need.
There were three ways out and I want to be explicit about why I picked this one. Widening the protocol with a zero classmethod would make the contract prescribe how implementations are constructed, not just how they behave, and every currency library would have to grow a constructor shaped the way my ledger likes. Taking a money factory as a constructor argument pushes the same problem onto the caller at every entry point, and adds a piece of configuration that can be wrong. Returning the exact number, with the currency already known from the account, keeps the contract narrow and leaves construction where the money type lives.
The rule I would now apply to any library that consumes a value type: consume freely, produce never. Where production is unavoidable, hand back the raw exact quantity and let the caller mint it with the one money type the system already has.
Keep the stand-in obviously temporary#
A package that defines no money type still has to be installable and testable on its own, so ledger_core.Money exists as a stand-in until the currency library it is meant to pair with is released. It lives in a module named _stand_in.py, and the README says what it is:
from datetime import datetime, timezone
from decimal import Decimal
from ledger_core import Account, AccountType, Entry, Journal, Money
cash = Account("cash", AccountType.ASSET, "EUR")
customer = Account("customer:42", AccountType.LIABILITY, "EUR")
deposit = Entry.transfer(
entry_id="e-1",
occurred_at=datetime.now(timezone.utc),
debit=cash,
credit=customer,
amount=Money(Decimal("25.00"), "EUR"),
memo="card deposit",
)The discipline is to keep it useless for anything else. The moment a stand-in grows formatting, conversion, or allocation helpers, applications start importing it for those, and the promise that swapping the money type is an import change stops being true. A stand-in earns its place by staying small enough that nobody wants to build on it.
What I would do differently#
Two things.
First, validate_currency enforces a three-letter uppercase alphabetic code. That is an ISO 4217 assumption, and it is precisely the kind of assumption I have just argued a ledger should not make. Stablecoin rails have been in daily production use in systems I run, and USDT does not fit that check; neither would most asset identifiers outside fiat. The ledger only needs equality and grouping from an asset code — it never interprets it. The right move is to make the code opaque to the ledger and leave denomination rules, including how many decimal places an asset actually has, to the money type. A ledger that bakes in two decimal places is fine right up until the first eight-decimal asset arrives.
Second, runtime_checkable on a protocol only checks that attributes are present, not that their signatures match. isinstance(value, MoneyLike) is documentation with a smoke alarm attached, not validation. It is useful at a boundary to catch someone passing a bare Decimal; it is not a reason to skip the currency checks that actually protect the invariant.
Close#
The division of labour is clean once you say it out loud. Being exact about a quantity of an asset is one job, and it belongs to a currency library. Guaranteeing that both sides of a movement net to zero and that balances are derived rather than stored is another job, and it belongs to a ledger. Two packages, one money type per system, and a protocol as the only thing that crosses between them.