Money Is Not Just a Number

  • Money
  • Fintech
  • Correctness

Money looks simple when it is sitting in one table.

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

That seems fine. Store cents, store the currency, move on.

The problem starts once that value moves through the rest of the system. It gets added to another amount. It gets compared. It goes over Kafka. It gets displayed. It gets converted through FX. It gets split across multiple parties. It gets written into a ledger where “off by one cent” may not just be a cosmetic bug; but it is how you end up explaining a missing cent to the Bundeszentralamt für Steuern, which is already too many syllables for a rounding error.

At that point, money is no longer just a database column, it becomes a contract.

I ran into this while building a payment platform for merchants. From my prior experiences of working at fintech products, I already knew the basic rule: don’t store money as floating point. Duh! that is the obvious part. What took longer to appreciate was that “use integers” is only the beginning. The real question is what mistakes should be impossible, and what mistakes should fail loudly?

This post is about the small money primitive I ended up building around that question. The examples here are from a Kotlin/JVM codebase, but the idea is not language-specific. The same mistakes may show up in any system where money moves as a primitive number.

The obvious bad versions

The worst version is Float or Double.

val price = 19.99

This is not money, rather an approximation of money. Binary floating point cannot represent most decimal fractions exactly, so sooner or later the system starts carrying tiny errors through calculations. That may be fine for graphics, but a no-go for balances.

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

val amount = BigDecimal("19.99")

This fixes decimal precision, but it does not answer the other important questions. What currency is this? How many decimal places are allowed? Was this already rounded? Can I add it to another BigDecimal from a different currency? Should 12.345 EUR be rejected or rounded?

So BigDecimal is useful at the boundaries, but I don’t want it as the main money type flowing through the system. It is a number with better decimal behavior, not a money model.

Minor units are not enough

After ruling out floats and raw BigDecimal, the next move is integer minor units.

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

This is the right storage shape. It is exact and it respects the currency’s smallest unit.

But if we pass it around like this:

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

we still leave too much meaning outside the type.

If a function accepts a Long, what does 1000 mean? Is it 1000 cents? 1000 yen? Is it a tax amount, a gross amount, a balance delta, a unit price, a settlement amount after FX?

If the currency is just a nearby string, nothing forces it to stay nearby. Someone can pass the amount without the currency. Someone can add two longs that came from different currencies. Someone can serialize the amount and forget the currency field. None of that looks wrong to the compiler.

So the smallest useful money shape is not Long. It is the minor-unit amount and the currency as one value:

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

amountMinor is always the integer number of minor units. For EUR/USD, that means cents. For JPY, it means yen because JPY has no fractional minor unit. For KWD, it means thousandths because KWD has three decimal places.

This is a tiny type, but it changes our system. An amount cannot move without its currency anymore.

Currency is part of the value

Once money is a real value object, arithmetic can enforce the rules.

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

That assertSameCurrency is doing more work than it first appears.

Adding 10 EUR + 10 USD is not a valid operation. We either need an FX conversion first, or we need to reject the operation. There is no honest third option where the system returns 20 and hopes nobody asks what the currency is.

I made 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 exception. 10 EUR == 10 USD simply returns false. That keeps normal Kotlin equality usable without surprising exceptions, while still preventing meaningless ordering and arithmetic.

Currency mismatches should not be discovered in reconciliation. This is one of those boring decisions that prevents very expensive bugs that are time consuming to debug.

Overflow should be loud

Using Long gives us exact integer arithmetic, but it does not automatically make arithmetic safe.

Long.MAX_VALUE + 1 wraps around if you are not careful. A balance silently flipping sign is much worse than a failed request, so every arithmetic operation uses the JDK’s checked helpers:

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

Same for subtraction, multiplication and negation.

I don’t expect normal merchant balances to get anywhere near the edge of Long, but that is not the point. Correctness code should make impossible states impossible and suspicious states obvious.

If an amount overflows, I want the system to stop exactly there.

Creating money from decimals

At the edges of our system, humans still speak in major units:

19.99 EUR
100 JPY
1.234 KWD

So the library has a constructor from BigDecimal:

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)
}

The important part is the default rounding mode: UNNECESSARY.

If someone tries to create EUR from 12.345, the default behavior is to throw. That is intentional. A money primitive should not silently decide where the extra precision went.

If a caller wants rounding, they can ask for it:

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

That makes rounding a product decision and not an accident.

There is also an unsafe Double constructor in my code, but it is named like this on purpose:

unsafeOfMajorDouble(...)

I kept it for interop/parity with float-based sources, but the name is meant to make new usage feel wrong. A Double amount is not forbidden because it is ugly, rather it is forbidden because it can fabricate the wrong minor unit.

Losing pennies during allocation

This is the first place where money correctness becomes less obvious.

Suppose we need to split 100 cents across three parties.

100 / 3 = 33.333...

If we give everyone 33 cents, we lost 1 cent. That lost cent is not theoretical. In a payment platform it eventually shows up as a balance mismatch, a payout mismatch or a reconciliation row nobody wants to explain.

So splitting and allocation must preserve the invariant:

the parts must always sum back to the original amount

The implementation is intentionally deterministic:

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

For ratios:

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

The base amount is assigned first, then leftover minor units are distributed one at a time to the earliest parties. Negative amounts work the same way, but with negative leftover units.

You may now ask, is “earliest party gets the extra penny” always the right business rule? Not necessarily. But it is at least explicit, deterministic and lossless. If a product needs a different fairness rule, we can build another allocator. What we should not do is lose the remainder by accident.

The wire shape matters

Once the type existed, the next question was serialization.

It is tempting to put Jackson annotations directly on the Money type and be done. I avoided that.

In my opinion, the core money library should not know about Kafka, Jackson, HTTP or a particular JSON mapper. It should just be a domain value. But the platform still needs one canonical wire shape, otherwise every service invents its own.

The shape I settled on is:

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

Not this:

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

and not this:

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

This sounds pedantic until multiple services start publishing and consuming the same event. A monetary field on Kafka should not require someone to ask “is this amount in cents or major units?”

In my code, the money type itself does not know about JSON. It only defines what money means. The JSON encoder lives at the service boundary, where messages are actually serialized:

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()
    }
}

That separation matters to me. The money type stays clean, but the platform still has one official encoding.

Same idea for database storage: two typed columns instead of one formatted string.

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

Formatting is not storage. Display strings are for humans.

FX is not just multiplication

Foreign exchange is where a lot of money models get hand-wavy.

The naive version is:

target = source * rate

That is only half the story. An exchange rate is usually quoted in major units:

1 EUR = 1.0857 USD

But the money value is stored in minor units. That means conversion has to account for the fraction digits of both currencies.

The formula in my conversion helper is:

targetMinor = sourceMinor × rate × 10^(targetFraction - sourceFraction)
  • For EUR to USD: both have two fraction digits, so the power of ten is 1.
  • For EUR to JPY: EUR has 2 fraction digits and JPY has 0, so we divide by 100 as part of the conversion.
  • For USD to KWD: USD has 2 and KWD has 3, so we multiply by 10.

The implementation uses BigDecimal for the calculation and rounds to a whole minor unit:

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

The default rounding mode is HALF_EVEN. You can argue for different modes depending on product/accounting requirements, but again, the rule needs to be explicit.

Carry the FX evidence

After conversion, I don’t want to pass around only the target money.

If we convert 17.50 EUR into 19.00 USD, the 19.00 USD is the settlement amount. It is the amount that actually matters for the ledger entry. But for audit and support, we also need to know what source amount and rate produced it.

So FX returns a triple:

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

The settlement amount is authoritative.

That sentence matters.

If a consumer recomputes source × rate, they may get a value that differs by one minor unit because rounding already happened. The event should not force every consumer to re-run the 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 a string, not a JSON number, because I don’t want a parser somewhere to turn it into a float and quietly lose precision or scale.

Also, ConvertedMoney rejects same-currency “conversions”. If both amounts are USD, that is not FX. It is either a no-op or a different concept. The type should not carry meaningless rates.

What this buys

None of this makes the system magically correct. You can still create the wrong ledger entry. You can still choose the wrong tax treatment. You can still apply the wrong exchange rate.

But the money primitive removes a whole class of boring mistakes. You

  • cannot add EUR and USD by accident.
  • cannot sort amounts across currencies as if they were comparable.
  • cannot silently overflow a balance.
  • cannot silently round 12.345 EUR into something else unless you opt in.
  • cannot split money and lose the leftover minor unit.
  • cannot publish a Kafka event where a consumer has to guess whether amount means cents or major units.
  • do not need to recompute FX to understand what was settled.

This is the kind of code I prefer in money-adjacent systems. It’s not clever or abstract for the sake of it, rather it is in a shape that makes the wrong thing harder to express.

The tradeoff

There is a tradeoff: this is more annoying than passing around Long.

Tests need to construct Money. DTO mappers need to translate to and from the wire shape. Persistence mappers need to rebuild the value from two columns. Some APIs become more verbose.

I think that is a good trade.

Primitive obsession is cheap at the beginning and expensive later. Money is one of those places where I prefer paying the small cost upfront. Once raw numbers spread through the codebase, it becomes hard to know which ones are safe, which ones are already rounded, which ones are minor units, and which ones are pretending to be money but are really just display values.

The goal is not to build a grand financial framework, it is to make the common path honest.

Wrapping up

The main lesson for me was that “store money as integer minor units” is necessary, but not enough.

Money needs a type. The type needs to carry currency. Arithmetic needs to reject nonsense. Rounding needs to be explicit. Allocation needs to preserve totals. FX needs to carry its evidence. The wire format needs to be boring and consistent.

That is a lot of words for a small data class, but I think that is the point.