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 not a column anymore, it becomes a contract.
I ran into this while building a payment platform for merchants. At the time, I thought I had already internalized the money lesson from previous fintech work: do not store money as floating point. Duh. That part is in every blog post, every code review, every Slack thread where someone types 0.1 + 0.2.
What took me longer to appreciate, mostly through uncomfortable edge cases and a few “wait, why is this off by one?” moments, was that using integers is only the starting point. The more useful 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 are from a Kotlin/JVM codebase, but the concepts here are not Kotlin-specific. These problems show up anywhere money is allowed to move around 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 is fine if you are moving pixels around. It is less fine if you are moving customer balances around.
BigDecimal is better, but it is still not enough by itself.
val amount = BigDecimal("19.99")
And to be fair, it does fix one important problem. Decimal precision is now under control.
But it still does not answer the money questions: What currency is this? How many fraction digits are allowed? Is 12.345 EUR valid, rounded, or rejected? Can I add it to another BigDecimal that came from a USD field? Has this value already been rounded once?
So yes, BigDecimal is useful. I like it at boundaries and inside calculations where decimal math is actually needed. But I do not want raw BigDecimal to be the main type flowing through the system. It is a better number. It is still not a money model.
Minor units are not enough
The usual next step is integer minor units:
19.99 EUR -> 1999
100 JPY -> 100
1.234 KWD -> 1234
This is the right storage shape. It is exact, cheap to compare, and it respects the smallest unit of the currency.
But if the application keeps passing this around:
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? 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 serialize the amount and forget the currency field. Someone can add two longs that happen to come from different currencies.
None of this looks wrong to the compiler.
This is the kind of thing that annoys me because the code still looks clean in review. The bug only becomes obvious later, when the value has crossed three services and nobody remembers where it lost its context.
So the smallest useful shape is not Long. It is the amount and the currency as one value:
data class Money(
val amountMinor: Long,
val currency: CurrencyCode,
)
amountMinor always means the integer number of minor units.
For EUR and USD, that means cents. For JPY, it means yen because there is no fractional minor unit. For KWD, it means thousandths because the currency has three fraction digits.
This is a tiny type, but it changes the system in a useful way. An amount can no longer travel alone.
Currency is part of the value
Once money becomes a real value object, arithmetic gets to enforce the rules.
operator fun plus(other: Money): Money {
assertSameCurrency(other)
return Money(Math.addExact(amountMinor, other.amountMinor), currency)
}
That assertSameCurrency is doing more work than it first appears.
10 EUR + 10 USD is not a valid operation. We either need to do FX first, or we need to reject it. There is no honest third option where the system returns 20 and everyone politely avoids asking “20 what?”
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 like here.
Money.ofMinor(1000, "EUR") == Money.ofMinor(1000, "USD") // false
That keeps normal Kotlin equality usable without surprising exceptions. But ordering and arithmetic still reject nonsense.
Currency mismatches should not be discovered during reconciliation. If the system already has enough information to reject the operation, it should reject it right there.
I have become pretty boring about this over time. If a system can fail at the point where the mistake is made, I usually want that more than a flexible API that lets the mistake travel.
Overflow is still a bug
Using Long gives us exact integer arithmetic. It does not automatically give us safe arithmetic.
On the JVM, Long.MAX_VALUE + 1 can wrap around if you are not careful. A balance silently flipping sign is a much worse outcome than a failed request, so every arithmetic operation should be checked:
private fun addExact(a: Long, b: Long): Long =
try {
Math.addExact(a, b)
} catch (e: ArithmeticException) {
throw MoneyOverflowException("addition overflow: $a + $b")
}
Same idea for subtraction, multiplication and negation.
Do I expect normal merchant balances to get anywhere near Long.MAX_VALUE? No.
But that is not the point. Correctness code should make ordinary operations boring and suspicious states obvious. If an amount overflows, I want the system to stop exactly there, not carry a poisoned balance into the next ledger entry.
Creating money from decimals
Humans do not type minor units. They type this:
19.99 EUR
100 JPY
1.234 KWD
So 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)
}
The important part is the default:
RoundingMode.UNNECESSARY
If someone tries to create EUR from 12.345, the default behavior is to throw. That is intentional.
The 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)
Now rounding is 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.
I also had an unsafe constructor for double-based sources, but it was named like this on purpose:
unsafeOfMajorDouble(...)
Sometimes you need interop with a source that already gives you a Double. Fine. But the name should make new usage feel wrong.
The issue is not that Double is aesthetically unpleasant, but that it can fabricate the wrong minor unit.
Splitting money is where bugs hide
Here is a very normal payment-platform problem: Split 100 cents across 3 parties.
Mathematically:
100 / 3 = 33.333...
If everyone gets 33 cents, one cent disappeared.
That missing cent is not theoretical. It eventually shows up as a balance mismatch, payout mismatch or some reconciliation row that makes the afternoon worse.
This was one of those lessons I only really respected after seeing how much time a tiny remainder can waste. Losing one cent feels harmless until it is the only thing preventing two reports from matching.
So allocation has one non-negotiable invariant:
The parts must always sum back to the original amount.
The implementation can be boring:
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]
First assign the base amount. Then distribute leftover minor units one at a time to the earliest parties. Negative amounts use the same idea with negative leftover units.
Is “earliest party gets the extra penny” always the best business rule? Not necessarily.
But it is explicit, deterministic and lossless. If the product needs a different fairness rule, build another allocator. The unacceptable version is losing the remainder by accident.
The wire format should be boring
Once the type exists, the next question is serialization.
The tempting move is to put Jackson annotations directly on Money and move on. I avoided that.
In my opinion, the core money library should not know about Kafka, Jackson, HTTP or a particular JSON mapper. It should define what money means. The service boundary should define how money is encoded.
But the platform still needs one canonical wire shape:
{
"amountMinor": 1999,
"currency": "EUR"
}
Not this:
{
"amount": 19.99,
"currency": "EUR"
}
or this:
{
"amount": 1999,
"currencyCode": "EUR"
}
This sounds pedantic until multiple services start publishing and consuming the same events.
An amount field on Kafka should not require archaeology. Nobody should have to ask whether amount means cents, major units, tax-inclusive amount, display amount or something else.
In my code, Money itself does not know about JSON. It only defines the value. The serializer lives at the service boundary:
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 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 for humans. Storage should preserve meaning.
I like this separation because it keeps the core type boring. The moment a domain primitive starts knowing about every transport format, it becomes harder to reuse and harder to trust.
FX is not just multiplication
Foreign exchange is where a lot of money models quietly start hand-waving. The naive formula is:
target = source * rate
That is not wrong exactly. It is just incomplete.
An exchange rate is usually quoted in major units:
1 EUR = 1.0857 USD
But our Money value is stored 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 as part of the move to target minor units.
- USD to KWD: USD has 2 and KWD has 3, so the conversion multiplies by 10.
The implementation uses BigDecimal for the calculation and then rounds to a whole minor unit:
val targetMinor =
BigDecimal.valueOf(amountMinor)
.multiply(rate.rate)
.movePointRight(targetFraction - sourceFraction)
.setScale(0, rounding)
I used HALF_EVEN as the default rounding mode.
You can argue for HALF_UP, DOWN, bank-specific rules, card-network rules, or something else depending on the product. That is fine. The important bit is that the rule is explicit and close to the conversion.
Carry the FX evidence
After conversion, I do not want to pass around only the target money.
Suppose we convert 17.50 EUR into 19.00 USD.
The 19.00 USD is the settlement amount. That is what matters for the ledger entry. But for audit, support and debugging, we also need to know what source amount and rate produced it.
So FX returns a small wrapper:
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 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 a string, not a JSON number. I do not want a parser somewhere to turn it into a floating-point value 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 a meaningless rate just because the fields happen to fit.
This may sound fussy, but these small distinctions add up. 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
None of this makes the whole system magically correct. You can still create the wrong ledger entry. You can still apply the wrong tax treatment. You can still use the wrong exchange rate.
But it does remove a class of boring mistakes:
- You cannot add EUR and USD by accident.
- You cannot sort amounts across currencies as if they were comparable.
- You cannot silently overflow a balance.
- You cannot silently round
12.345 EURunless you opt in. - You cannot split money and lose the leftover minor unit.
- You cannot publish a Kafka event where consumers have to guess what unit the amount uses.
- You 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 annoying parts
There is a cost.
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 a bit more verbose. You occasionally have to think about rounding mode before writing the line of code you wanted to write.
I think that is a good trade.
Primitive obsession is cheap at the beginning and expensive later. 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 just display values pretending to be money.
I would rather pay the small cost upfront.
Wrapping up
The lesson for me was that storing 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 in a payment system, that is usually the point.