Money Is Not Just a Number

  • Money
  • Fintech
  • Correctness

Money is pretty straightforward if it’s only in one table.

amount BIGINT NOT NULL,
currency CHAR(3) NOT NULL

That seems fine. Just store the cents, store the currency, and you’re good.

The problem arises once it leaves that first table and flows through the rest of your system. You add it to something else. You compare it. You cross Kafka. You show it to someone. You do FX conversions on it. You split it across multiple parties. You book it in a ledger where “off by one cent” is how you end up in a meeting with EY auditors.

At that point, money is not a column anymore, it becomes a contract.

I found this out while working on a payments platform for merchants. Back then, I thought I already learned my money lesson from other fintech experience: don’t store money as floating point. Duh. That’s in every blog post you read on this.

What took me longer to understand, in many cases where I was forced to rethink my assumptions about edge cases, that using an integer is just a beginning; rather, the important thing to ask is what errors should never happen and what errors should loudly fail?

This article is a write-up of a small Money primitive I ended up creating as an answer to this question. The examples provided are taken from a Kotlin codebase I am working on, however, the ideas presented here are not JVM specific and apply to any codebase allowing movement of money as a primitive integer.

The obvious bad versions

Float or Double is the worst choice.

val price = 19.99

But this is not money, rather an approximation of money. Since binary floating point numbers are unable to represent decimal fractions exactly, sooner or later errors will sneak into the system calculations.

BigDecimal is a better choice, but it is still not enough by itself.

val amount = BigDecimal("19.99")

And to be fair, it does solve one of the main issues, that is, the problem of decimal precision.

However, it fails to solve the problems associated with money. What is the currency here? What is the number of fraction digits? Is 12.345 EUR valid, rounded or rejected? Should I combine it with another BigDecimal, which was obtained from a USD field? Has this value been rounded previously?

So yes, BigDecimal is useful. I like it at boundaries and inside calculations when actual decimal math is needed. But I don’t like it as the primary type used throughout the system. It is a better number but it is still not a money model.

Minor units are not enough

Now the next intuitive thing to do is use minor units with integers:

19.99 EUR -> 1999
100 JPY   -> 100
1.234 KWD -> 1234

We’ve got the representation that we want – it is accurate, efficient to compare, and it respects the minimum unit of the currency.

But when the application continues passing it like this:

val amountMinor: Long = 1999
val currency: String = "EUR"

there is still too much information missing from the type.

What does 1000 mean if the function parameter takes a Long? Does it mean 1000 cents, 1000 yen, a tax value, a gross value, a balance delta, a unit price, or the settlement value of an exchange?

And since there is nothing that forces the currency to stay next to its amount, someone may send an amount without a currency, serialize the amount while forgetting the currency, and add two numbers which happened to belong to different currencies.

And the compiler cannot stop any of this.

This is exactly what gets on my nerves since, at the review stage, the code looks just fine. The problem only appears later on, when the value has been through three different services and no one remembers how, as the context has been lost in the process.

In other words, the smallest meaningful shape is not Long. Instead, it is the combination of an amount and a currency:

data class Money(
    val amountMinor: Long,
    val currency: CurrencyCode,
)

amountMinor always represents the integer number of minor units.

For EUR/USD, that means cents. For JPY, that means yen since JPY does not have any minor fractions. For KWD, that means thousandths since the currency has three fraction digits.

This is a small type indeed, but it has a meaningful effect on the whole system. An amount cannot travel alone anymore.

Currency is part of the value

Once money becomes a real value object, we can use arithmetic to enforce the rules.

operator fun plus(other: Money): Money {
    assertSameCurrency(other)
    return Money(Math.addExact(amountMinor, other.amountMinor), currency)
}

This assertSameCurrency does a bit more work than it appears at first glance.

10 EUR + 10 USD is not a valid operation. Either we have to perform foreign exchange first, or we just say that this cannot be done. There is no way to do something honest and return 20 without the inevitable “20 of what?” question.

So arithmetic and ordering throw when currencies differ:

Money.ofMinor(1000, "EUR") + Money.ofMinor(1000, "USD") // throws
Money.ofMinor(1000, "EUR") > Money.ofMinor(900, "USD")  // throws

Equality is the only exception I make here.

Money.ofMinor(1000, "EUR") == Money.ofMinor(1000, "USD") // false

It allows using normal Kotlin equality without surprises but still rejects invalid operations.

Mismatch of currencies should not be detected during reconciliation. If the system has enough information to reject the operation, it should do that.

I became rather picky about this. If a system is able to detect an error right where it happens, I prefer this more often than a flexible API which allows an error to propagate.

Overflow is still a bug

Using Long gives us exact integer arithmetic. We do not get safe arithmetic from it for free.

The problem is that on JVM, Long.MAX_VALUE + 1 can wrap around if you are not cautious. It would be a far more unpleasant surprise to see an account balance flip its sign than to be unable to perform an action. So all operations need to be checked for overflow:

private fun addExact(a: Long, b: Long): Long =
    try {
        Math.addExact(a, b)
    } catch (e: ArithmeticException) {
        throw MoneyOverflowException("addition overflow: $a + $b")
    }

Similarly for subtraction, multiplication and negation.

Do I expect the balances of regular merchants to approach Long.MAX_VALUE? Of course not! (In fact, I do not have real users :sob:)

But that is not the point. Correct code should make ordinary operations mundane and exceptional states obvious. An amount exceeding Long.MAX_VALUE needs to fail right here, and not infect the next ledger entry with an overflowed value.

Creating money from decimals

Humans do not type minor units. They type this:

19.99 EUR
100 JPY
1.234 KWD

Therefore, the library still needs a way to create Money from major units:

fun ofMajor(
    major: BigDecimal,
    currency: CurrencyCode,
    rounding: RoundingMode = RoundingMode.UNNECESSARY,
): Money {
    val fraction = Currencies.resolve(currency).fraction
    val scaled = major.setScale(fraction, rounding).movePointRight(fraction)
    val minor = scaled.longValueExact()
    return Money(minor, currency)
}

What is important is the default:

RoundingMode.UNNECESSARY

If someone tries to construct EUR from 12.345, then default behavior is to throw. This is by design.

The money primitive must not secretly make any decision regarding where extra precision went. The caller can request rounding if this is what they want:

Money.ofMajor(BigDecimal("12.345"), eur, RoundingMode.HALF_UP)

Now rounding becomes a product decision rather than a side effect.

This rule also makes conversations clearer. Instead of arguing later about why a number rounded a certain way, the code forces the caller to make the rounding choice explicit.

There was also a constructor accepting double-based sources, but it was intentionally named in the following way:

unsafeOfMajorDouble(...)

Sometimes we have unavoidable interoperability with some source giving us a Double. This is fine. However, the name should give an impression that new usage is wrong.

It is not a problem that Double is somehow aesthetically unpleasant, but the real problem is that Double may fake the minor unit.

Splitting money is where bugs hide

And here’s an extremely common payment platform problem: allocate 100 cents between 3 parties

Mathematically:

100 / 3 = 33.333...

If everybody receives 33 cents, one cent goes missing.

That missing cent isn’t a theoretical thing. It will inevitably show up as either a balance mismatch, a payout mismatch or something else that will make the rest of the afternoon even worse.

And that’s one of those cases where I learned the value of such an allocation invariant only through its practical application; when it becomes apparent how much time can be spent dealing with that remainder.

So here is one invariant that allocation must always uphold:

The parts must always sum back to the original amount.

The actual code is fairly straightforward:

Money.ofMinor(100, "GBP").split(3)
// [34 GBP, 33 GBP, 33 GBP]

and relative ratio allocations:

Money.ofMinor(100, "GBP").allocate(30, 30, 30)
// [34 GBP, 33 GBP, 33 GBP]

The base amount should first be allocated. The remaining minor units should be then distributed one by one to the earlier parties. For negative values, the process is reversed with remaining minor negative units.

Does “earliest party gets the extra penny” make sense as a business rule? Maybe not always. But it’s explicit, deterministic and lossless. The unacceptable way is losing that remainder inadvertently.

The wire format should be boring

Now that we have a type, the next step is serialization.

An intuitive, simple solution would be to just annotate Money and be done with it. I decided against it.

In my opinion, the core money library should not care about Kafka, Jackson, HTTP or a specific JSON mapper. It only defines what a money value is. The service boundary defines how the value is serialized.

Still, we need a wire shape for the platform to use:

{
  "amountMinor": 1999,
  "currency": "EUR"
}

instead of

{
  "amount": 19.99,
  "currency": "EUR"
}

or

{
  "amount": 1999,
  "currencyCode": "EUR"
}

This might sound pedantic until multiple services start publishing and consuming the same events. There should be no need to dig through Kafka records to find out what “amount” means.

In my design, Money doesn’t care about JSON at all; it only knows how to represent the value:

private object MoneySerializer : ValueSerializer<Money>() {
    override fun serialize(
        value: Money,
        gen: JsonGenerator,
        ctxt: SerializationContext,
    ) {
        gen.writeStartObject()
        gen.writeNumberProperty("amountMinor", value.amountMinor)
        gen.writeStringProperty("currency", value.currency.value)
        gen.writeEndObject()
    }
}

Same goes for storing the value in the database: two typed columns rather than one formatted string.

amount_minor BIGINT NOT NULL,
currency_code CHAR(3) NOT NULL

The formatting is for humans. The storage must preserve the meaning.

That’s why I love such an approach, as it leaves the basic domain type simple and stupid. The moment it gets aware of all kinds of transports, it’s doomed.

FX is not just multiplication

Foreign exchange is one of the places where many money models sneakily start hand-waving. The naive formula is:

target = source * rate

It is not technically incorrect but just incomplete.

Exchange rate is always expressed in major units:

1 EUR = 1.0857 USD

While our Money is represented in minor units. So conversion has to account for the fraction digits of both currencies.

The helper uses this formula:

targetMinor = sourceMinor x rate x 10^(targetFraction - sourceFraction)

Examples:

  • EUR to USD: both have two fraction digits, so the power-of-ten adjustment is neutral.
  • EUR to JPY: EUR has 2 fraction digits and JPY has 0, so the conversion divides by 100 in the process of converting to target minor units.
  • USD to KWD: USD has 2 fraction digits and KWD has 3 so the conversion multiplies by 10.

The actual code uses BigDecimal for calculations and then rounds to a whole minor units:

val targetMinor =
    BigDecimal.valueOf(amountMinor)
        .multiply(rate.rate)
        .movePointRight(targetFraction - sourceFraction)
        .setScale(0, rounding)

I went with HALF_EVEN rounding mode.

Feel free to argue that HALF_UP, DOWN, bank-specific rules or some other should be applied instead. The key point is that the rounding mode should be explicit and near conversion itself.

Carry the FX evidence

After conversion, I do not want to pass around only the target money.

Let’s say we’ve converted 17.50 EUR into 19.00 USD.

The 19.00 USD is the settlement amount. It’s the thing that needs to go into the ledger. But for audit and debugging purposes, we also need to know what source amount and exchange rate led to the settlement amount.

Therefore, FX gives back a small wrapper:

data class ConvertedMoney(
    val money: Money,       // settlement amount
    val source: Money,      // original foreign amount
    val rate: ExchangeRate, // rate applied
)

Settlement amount is authoritative.

That sentence is doing real work.

If a consumer recomputes source x rate, they may get a value that differs by one minor unit because rounding already happened. The event should not force every consumer to rerun conversion and hope they make the same rounding decision.

So the wire shape carries all three:

{
  "money":  { "amountMinor": 1900, "currency": "USD" },
  "source": { "amountMinor": 1750, "currency": "EUR" },
  "rate":   "1.085714286"
}

The rate is not a number but a string. I don’t want some parser somewhere along the line to convert that string into a floating point number losing its precision or scale in the process.

Also, the ConvertedMoney type does not allow conversions for same-currency types. If both currencies are USD then it’s not foreign exchange. It is either nothing at all or it is something else altogether. This type doesn’t need a rate because it can never make any sense here anyway.

That may seem like nitpicking but little differences like this really matter. I would rather have two boring concepts than one flexible concept that means slightly different things depending on the call site.

What the primitive buys

Of course, none of this makes the whole system magically correct. You can still make a wrong ledger entry, wrong tax treatment, or even wrong exchange rate.

However, this eliminates an entire class of boring errors:

  • You cannot add EUR and USD by accident.
  • You cannot sort amounts between currencies assuming they are comparable.
  • You cannot silently overflow a balance.
  • You cannot silently round 12.345 EUR without explicit opt-in.
  • You cannot split money and silently lose minor currency.
  • You cannot produce a Kafka event with no explicit information on which unit is used in the amount.
  • You do not need to recalculate FX to figure out what got settled.

This is the kind of code that I like in money-adjacent systems. It is not clever or abstract for no reason; rather, it is shaped in such a way as to prevent the wrong thing from happening.

The frustrating bits

There is a cost.

This is more annoying than passing around Long. The tests must create a Money instance. It takes some extra effort to deal with DTOs and persistence mappers when they convert between the wire type and the internal type. Some APIs get a little more verbose. You sometimes need to choose your rounding mode before typing the code you had in mind.

But I believe this is a good trade-off.

Primitive obsession is cheap at the beginning and expensive later. Once raw numbers spread through the codebase, it gets difficult to know which ones you can trust, which ones were already rounded, which ones are minor units, and which ones are only for display.

I would rather pay that cost upfront.