Money is one of those areas where small omissions can generate technical debt faster than almost any other slice of an application domain. Not because “finance is hard,” but because average code likes to pretend it’s simple. Bankster is an attempt to do this thing properly.
Bankster is a software library for Clojure. I started writing it in 2021, and after a few years I introduced a lot of improvements related to performance, generalizing operations, and adapting it to the requirements of modern financial data processing systems.
Bankster’s goal is to give your applications tools for working with currencies and amounts in a way that:
- lets you build systems, not just helpers,
- keeps rules explicit (scale, rounding, currency code conflicts),
- makes errors readable (and lets you choose the strictness level),
- and keeps basic operations fast enough, so you don’t have to run to “bare” primitive types at the first sign of increased load.
This post is not a manifesto condemning other approaches, but rather a description of what Bankster brings as a craftsman’s building block – into small and large projects alike.
What does Bankster consider “money”?
The model is intentionally simple, but not primitive:
-
Money is a currency and a number of its units (major and minor).
-
Money is represented by the
Moneyrecord. -
Money = [currency, amount], where:currencyis aCurrencyrecord describing the currency,amountis aBigDecimalwith full control over scale and rounding.
-
Currency = [id, scale, numeric, domain, kind], where:idis an identifier like:USDor:crypto/BTC(or your own);scaleis the nominal scale of the unit;numericcan store the ISO numeric code,domainis the domain affiliation (e.g.:CRYPTO,:ISO-4217),kindis the kind of currency (e.g.:iso/fiator:virtual/native).
Depending on the situation, we can use Currency to express:
- ISO 4217 currencies (the common cases),
- cryptocurrencies (different scales, different rules),
- system currencies (points, credits, game tokens, accounting units),
- historical currencies (important in archives and migrations).
All of the above options fit in one Bankster model, without artificial exceptions.
See also:
- “Data Structures”, library documentation
Where do currencies live?
Bankster introduces the concept of a registry (the Registry record),
i.e. a directory of currencies and their properties. This matters, because in
practice programs don’t operate on currencies “in a vacuum”; somewhere there must be
a source of truth.
Sometimes the code USD means the same thing everywhere, but sometimes we have
a conflict – when our own currency (e.g. :our/USD) or some virtual currency
(e.g. a hypothetical :crypto/USD) has the same code. In that case we want to
control the priority of recognizing currencies by codes using some kind of
weight.
A registry is, in essence, a set of indexes (implemented using maps), which allows you to obtain:
-
a currency based on:
- a unique identifier (e.g.
:crypto/ETH,:PLN), - an assigned code (e.g.
:USD,:PLN) and weight, - an optional ISO number (e.g. 985) and weight,
- an optionally assigned country;
- a unique identifier (e.g.
-
a set of currencies based on:
- a code,
- an ISO number,
- a domain (e.g.
:CRYPTO), - a country (e.g.
:PL);
-
and additionally based on the identifier:
- regional settings of the currency,
- traits of the currency,
- countries of the currency,
- the currency’s weight.
The traits mentioned above are optional sets associated with currencies, and
their elements are keywords, e.g. :peg/fiat or :network/distributed. Thanks to
them, we can “talk about” important currency properties and later use that
information for distinguishing, filtering, or classifying.
For traits, kinds, and domains, the currency registry maintains relation hierarchies that can be extended with your own dependencies. Built-in Bankster predicates make use of them. So, for example, we can ask a currency whether it is decentralized, or whether it is fiat.
The built-in trait hierarchies also contain meta-categories that let you apply the same predicates both to ISO currencies and to virtual ones, if needed.
See also:
- “Currency kinds”, library documentation
- “Currency traits”, library documentation
Registry scope
Depending on the architecture, there are three natural modes used by all functions that work with the currency registry:
- a global registry (sensible in an application or service),
- a dynamic registry (e.g. in tests, workers, or ad-hoc instances),
- a local registry (in a narrow algorithm or module, passed as an argument).
This kind of flexibility gives freedom without loss of control.
You can have one main, global registry, but at the boundaries of your system – for example, in data exchange gateways with exchanges – use its modified copies (e.g. with scales adapted to the other side’s constraints or with narrowed currency sets). Then the code responsible for handling specific partners will use a dynamically bound, derived registry.
Contracts: how Bankster says what it guarantees
At some point I stopped caring only about whether it works at all, and started caring more about in which mode it works. That’s why Bankster has explicit behavior contracts, including:
- the strict vs soft API distinction (where it makes sense),
- an error handling model and validation rules,
- registry rules and currency resolution,
- the consequences of rounding and scale choices.
The contracts weren’t created solely with the library’s user in mind, but also with future me in mind. In programming we often reach for tools that have been sitting on the shelf for a while, and we need guidelines that let us quickly regain orientation regarding the rules and behaviors of particular components.
Thanks to contracts I know what to expect from different functions and protocol methods, and which data shapes materially affect them.
In practice, that means you can pick an integration style and, for example:
-
in an accounting system – prefer API functions labeled strict
(errors will signal that rules are being violated), -
in parsers and importers – often use soft
(absence is communicated by returningnil, we have fallbacks to defaults, currency resolution based ontry-resolve), -
in analytics tools – usually a mix of the above.
See also:
- “Bankster Contracts”, library documentation
- “Bankster Front API”, library documentation
Operations without losing pennies
Three classes of financial operations tend to be particularly treacherous:
- splitting and allocating amounts (allocation and distribution),
- performing calculations on huge lists (where object creation overhead grows),
- rounding.
Bankster has functions for accumulating computations, as well as allocation and distribution – i.e. splits in which:
- sum in = sum out,
- differences caused by rounding are distributed in a controlled way,
- there are no “vanishing pennies.”
This is the foundation for settlements, fees, taxes, amount splitting, and billing.
Also, from the very first releases, Bankster includes built-in variadic variants of arithmetic operations on amounts, which are significantly faster than calling the two-argument versions repeatedly.
Rounding is a deep topic. When building financial and accounting software we must watch out, for example, for infinite decimal expansions during division and react differently depending on the currency scale type (fixed or automatic). What’s more, the user should be able to consciously choose whether rounding happens after each successive operation (for a list of actions) or only at the very end. Bankster supports both modes, and also includes built-in mechanisms for properly handling infinite expansions.
Serialization: EDN and beyond
Financial data lives at the junction of systems. That’s why I equipped the library with the following:
-
EDN support in code:
tagged literals#moneyand#currencyand the data readers that use them, -
EDN/JSON serialization,
-
a sensible representation (the records discussed above)
that does not “smear” scale (by implicitly converting) and does not lose semantics.
If you build a system where data travels through queues, APIs, and storage – then the above is usually quite important.
See also:
- “Serialization”, library documentation
- “Example EDN config”, library documentation
Operators: when Money meets clojure.core
Bankster has an operators layer in two “flavors,” represented by two namespaces:
-
io.randomseed.bankster.money.ops
– Money semantics (you know what you’ll get), -
io.randomseed.bankster.money.inter-ops
– behaves likeclojure.coreuntilMoneyenters the game.
This is a compromise between ergonomics and safety. In small modules it’s convenient
to require inter-ops and use the same as usual arithmetic operators and predicates
everywhere – in critical points they will reach for Bankster implementations meant
for amounts, not arbitrary numbers. However, it’s still a form of monkey patching.
In production code I recommend using:
- the
io.randomseed.bankster.api.money.opsnamespace belonging to the API.
Its functions do delegate to inter-ops, but as long as we don’t use :refer :all,
we’re safe.
For hot-paths one may reach to calculations in:
io.randomseed.bankster.api/money, orio.randomseed.bankster/money(in performance-critical scenarios).
Minimal quickstart
The version without literals and with the default registry:
(require
'[io.randomseed.bankster.api :as b]
'[io.randomseed.bankster.api.money :as m]
'[io.randomseed.bankster.api.currency :as c]
'[io.randomseed.bankster.api.ops :as ops])
(def usd (c/resolve "USD"))
(def pln (c/resolve "PLN"))
(def a (m/resolve 12.34 usd))
(def b (m/resolve 10 pln))
(ops/+ a (m/resolve 1.66 usd))
;; => #money[14.00 USD] (example representation)
(require
'[io.randomseed.bankster.api :as b]
'[io.randomseed.bankster.api.money :as m]
'[io.randomseed.bankster.api.currency :as c]
'[io.randomseed.bankster.api.ops :as ops])
(def usd (c/resolve "USD"))
(def pln (c/resolve "PLN"))
(def a (m/resolve 12.34 usd))
(def b (m/resolve 10 pln))
(ops/+ a (m/resolve 1.66 usd))
;; => #money[14.00 USD] (example representation)
The version with literals (when it fits the project’s style):
(def a #money[12.34 :USD])
(def x #money[1.66 :USD])
(ops/+ a x)
(def a #money[12.34 :USD])
(def x #money[1.66 :USD])
(ops/+ a x)
Currency resolution in the soft style:
(c/resolve-try "NON-EXISTENT")
;; => nil
(c/resolve-try "NON-EXISTENT")
;; => nil
Querying properties:
(c/info :PLN)
{:id :PLN,
:numeric 985,
:scale 2,
:domain :ISO-4217,
:kind :iso/fiat,
:weight 0,
:countries #{:PL},
:localized {:en {:name "Polish zloty", :symbol "zł"}}}
(c/info :crypto/USDC)
{:id :crypto/USDC,
:numeric -1,
:scale 8,
:domain :CRYPTO,
:kind :virtual.stable.peg/fiat,
:weight 4,
:localized {:* {:name "USD Coin", :symbol "USDC"}},
:traits #{:peg/fiat :stable/coin :token/erc20}}
(c/info :PLN)
{:id :PLN,
:numeric 985,
:scale 2,
:domain :ISO-4217,
:kind :iso/fiat,
:weight 0,
:countries #{:PL},
:localized {:en {:name "Polish zloty", :symbol "zł"}}}
(c/info :crypto/USDC)
{:id :crypto/USDC,
:numeric -1,
:scale 8,
:domain :CRYPTO,
:kind :virtual.stable.peg/fiat,
:weight 4,
:localized {:* {:name "USD Coin", :symbol "USDC"}},
:traits #{:peg/fiat :stable/coin :token/erc20}}
Combat-proven use cases
Bankster is useful when you need the financial domain as a component, not as a list of helper functions. What does that mean? The most common scenarios are below.
-
Billing and settlements in service systems
For example: subscriptions, limits, fees, discounts, pricing plans, invoicing.What matters then:
- explicit scaling rules,
- deterministic rounding,
- lossless allocations.
-
Imports and data migrations
Different sources, different representations, inconsistent currency codes.Here you win with:
- a registry with weights and rules,
- soft/strict API depending on the processing stage,
- serialization and data readability.
-
Systems of internal units of value
For example: points, credits, tokens, settlement units, domain currencies.Here what matters:
- the currency’s
domainandkindattributes, - custom scales or automatic scales,
- coexistence with ISO 4217 currencies.
- the currency’s
-
Analytics and reporting
When a currency represented by the
Moneyrecord should be a “boring, correct type” in ETL and reports:- the operators layer makes calculations easier,
- explicit contracts help preserve semantics.
Performance
I’m not publishing big tables here, because micro-benchmarks have their drawbacks. Still, at a practical level Bankster is not an academic experiment.
In tests like reduce sum of 10k and divide+sum of 10k, the library maintained a noticeable advantage over an approach based on Joda-Money, while keeping its own exception handling, explicit scale control, and explicit rounding rules.
The most important thing, however, is that with Bankster you can write readable code
with predictable behavior first, and profile it later – instead of immediately
running to raw BigDecimal and manually inventing semantics.
Pitfalls
-
Scale is part of the semantics.
If you try to useMoneylike “a number with a dot,” sooner or later you’ll hit differences caused by rounding. -
Don’t mix currencies without an explicit reason.
Bankster does not pretend that adding USD to PLN is fine. If we want currency exchange support, we must add our own FX layer (rates) – the library’s built-in conversion functions can help. -
The registry is a tool.
For simple programs the default registry is enough. For systems: you want your own. -
Soft API is great at the edges, not in the core.
resolve-tryin a parser: yes;resolve-tryin posting/accounting: probably not.
What’s next: ledger and markets
Bankster deliberately does not pretend to be an accounting tool, because its job is to deliver a precise, semantically rich, and performant money layer.
Ledger, markets, positions, accounts, accounting documents, posting rules – this is a separate space that can be built on top of Bankster. I have the sense that this is safer: the library provides operations and semantics, and the system provides place and process.
If an accounting layer ever appears in my ecosystem, Bankster will be its foundation – not its competitor.
See also:
- Project repository: https://github.com/randomseed-io/bankster
- Documentation: https://randomseed.io/software/bankster/