I've been writing Go backends for over a decade, and money handling is the one topic where I keep seeing the same bug in codebase after codebase: a Price float64 field somewhere in a struct, a DECIMAL column mapped to float64 by the ORM, and a team that swears the numbers "look fine" — right up until finance asks why the invoice totals are off by a cent.
I got this wrong myself for years. This post is the fix I finally settled on and now bake into every project: a Money value object built on integer minor units, with the currency attached, and validation that makes invalid money literally unconstructible. All code below is real code from my go-ddd template repo (just tagged v1.0.0).
Why float64 money is broken
Not "risky in theory". Broken. Binary floating point cannot represent most decimal fractions exactly. 0.1 doesn't exist in float64 — you get the closest representable value, which is 0.1000000000000000055511151231257827021181583404541015625.
You've seen this one:
package main
import "fmt"
func main() {
fmt.Println(0.1 + 0.2) // 0.30000000000000004
fmt.Println(0.1+0.2 == 0.3) // false
}
Cute party trick, easy to dismiss. The version that actually hurts is drift under accumulation, because real systems don't add two prices — they sum line items, apply percentages, aggregate daily revenue:
package main
import "fmt"
func main() {
var total float64
for i := 0; i < 1000; i++ {
total += 0.10 // a 10-cent fee, a thousand times
}
fmt.Println(total) // 99.9999999999986
fmt.Println(total == 100) // false
}
A thousand 10-cent charges and you've already lost the exact comparison. Now imagine that total feeding a >= threshold check, or a reconciliation job diffing against the payment provider's report. The diff is never zero, someone writes an epsilon comparison, the epsilon is wrong for large amounts, and you're in whack-a-mole territory.
And float64 has a second, quieter problem that has nothing to do with binary representation: it's a bare number. 19.99 of what? EUR? USD? Cents already? I've debugged a production incident where one service sent euros and another interpreted the same field as cents. The type system happily let a 100x pricing error through, because float64 == float64.
So the fix has to solve both: exact arithmetic and an amount that can't be separated from its currency.
The Money value object
The whole thing fits in one small file. Amount in minor units (cents) as int64, currency as a typed string, both fields unexported:
// Currency is the ISO 4217 code of a Money value.
type Currency string
const (
EUR Currency = "EUR"
USD Currency = "USD"
)
var supportedCurrencies = map[Currency]struct{}{
EUR: {},
USD: {},
}
// Money is an immutable value object storing an amount in minor units
// (cents) to avoid floating-point rounding errors.
type Money struct {
cents int64
currency Currency
}
func NewMoney(cents int64, currency Currency) (Money, error) {
if cents < 0 {
return Money{}, fmt.Errorf("%w: amount must not be negative", ErrValidation)
}
if _, ok := supportedCurrencies[currency]; !ok {
return Money{}, fmt.Errorf("%w: unsupported currency %q", ErrValidation, currency)
}
return Money{cents: cents, currency: currency}, nil
}
func (m Money) Cents() int64 { return m.cents }
func (m Money) Currency() Currency { return m.currency }
func (m Money) String() string {
return fmt.Sprintf("%d.%02d %s", m.cents/100, m.cents%100, m.currency)
}
Design decisions worth spelling out:
Unexported fields, constructor with validation. Because cents and currency are unexported, the only way to build a Money outside the package is NewMoney. That means every Money in the system passed the negative check and the currency whitelist. There's no "construct it raw and validate later" path to forget. This is the value-object idea from DDD in its most useful form: make invalid states unrepresentable, then stop re-validating everywhere else.
Integer cents, not big.Rat or a decimal library. int64 cents gives exact addition and comparison for free, is trivially indexable and sortable in the DB, and covers roughly ±92 quadrillion dollars. If you need arbitrary precision (FX rates, interest calc), reach for a decimal type — but for prices and balances, minor units are the boring answer that works.
Currency is part of equality. Money is a comparable struct, so == compares amount and currency. NewMoney(1000, EUR) != NewMoney(1000, USD). The euros-vs-cents-vs-dollars class of bug now fails at the type level instead of on an accountant's spreadsheet.
A whitelist of currencies. Two entries look restrictive, and that's the point. The domain says which currencies the business actually supports. Adding one is a one-line change and forces you to think about it — which is exactly what you want when the alternative is silently accepting "BTC" or "EURO" from a client.
Using it in the domain
The Product entity takes a Money, not a number:
type Product struct {
Id uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
Name string
Price Money
SellerId uuid.UUID
}
func (p *Product) validate() error {
if p.Name == "" {
return fmt.Errorf("%w: name must not be empty", ErrValidation)
}
if p.Price.Cents() == 0 {
return fmt.Errorf("%w: price must be greater than 0", ErrValidation)
}
// ...
}
Notice what the entity does not have to check: negative prices, garbage currencies. NewMoney already refused those. The entity only adds the rule that belongs to products — a price of zero makes no sense for a product, but zero is a perfectly fine Money elsewhere (a discount that bottomed out, an empty balance). Each layer validates its own invariant, once.
Surviving the round trips: DB and JSON
A value object is only as good as its edges. Money enters and leaves your process constantly — database, API, message queue — and every crossing is a chance to smuggle in an invalid value.
Database. The schema mirrors the value object: a BIGINT for cents and a TEXT for the currency. Here's the actual migration from the repo, moving off the old DECIMAL column:
-- Store money as integer minor units plus an ISO 4217 currency code.
-- Floating point (and implicit currency) is how money bugs are born.
ALTER TABLE products ADD COLUMN price_cents BIGINT;
UPDATE products SET price_cents = ROUND(price * 100);
ALTER TABLE products ALTER COLUMN price_cents SET NOT NULL;
ALTER TABLE products DROP COLUMN price;
ALTER TABLE products ADD COLUMN currency TEXT;
UPDATE products SET currency = 'USD';
ALTER TABLE products ALTER COLUMN currency SET NOT NULL;
The UPDATE ... SET currency = 'USD' line is the honest part of this migration: the old schema had no idea what currency those decimals were in. We were only able to backfill because the system happened to be single-currency. If it hadn't been, this migration would have been a data archaeology project. Attach the currency before you need to.
JSON. This is the part most people skip. encoding/json bypasses your constructor — it writes straight into struct fields via reflection. With unexported fields it can't, so we implement the interfaces ourselves and route unmarshaling back through NewMoney:
type moneyJSON struct {
Cents int64 `json:"cents"`
Currency Currency `json:"currency"`
}
func (m Money) MarshalJSON() ([]byte, error) {
return json.Marshal(moneyJSON{Cents: m.cents, Currency: m.currency})
}
// UnmarshalJSON goes through NewMoney so a Money can never be deserialized
// into an invalid state.
func (m *Money) UnmarshalJSON(data []byte) error {
var raw moneyJSON
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
money, err := NewMoney(raw.Cents, raw.Currency)
if err != nil {
return err
}
*m = money
return nil
}
Why bother, when the value was valid when we serialized it? Because JSON doesn't only come from us. It comes from an outbox table written by last year's code, a queue message from another service, a fixture someone hand-edited. Revalidating on the way in costs two comparisons and closes the whole category.
The API contract
At the REST edge I don't expose the domain type — DTOs carry the same shape explicitly:
type CreateProductRequest struct {
IdempotencyKey string `json:"idempotency_key"`
Name string `json:"name"`
PriceCents int64 `json:"price_cents"`
Currency string `json:"currency"`
SellerId string `json:"seller_id"`
}
The field is named price_cents, not price. That naming does real work: no client developer will ever wonder whether to send 19.99 or 1999. The response mirrors it (PriceCents int64, Currency string), and the app layer converts the raw DTO values through NewMoney, so a request with "currency": "GBP" or a negative amount gets a 400 before it ever touches an entity.
Yes, that means the number 1999 on the wire instead of a pretty "19.99". Formatting is a display concern; let the frontend format for locale. Wire formats should be exact.
What this doesn't solve
I'd rather tell you the sharp edges myself:
-
Display formatting is on you. My
String()doescents/100with a two-digit remainder, which is correct for EUR and USD and wrong for JPY (0 decimal places) or KWD (3). If you go properly multi-currency, you need per-currency exponents — steal them from ISO 4217 or use a library. -
Cross-currency arithmetic needs a decision. The moment you add
Add()orSub(), define whatEUR + USDdoes. My answer: it returns an error, and conversion is an explicit domain operation with a rate and a timestamp. Never implicit. - Division and percentages still round. Integer cents make addition exact, but 100 cents split three ways is still 33+33+34. You need an allocation strategy (largest-remainder works fine) — no representation saves you from that.
-
DECIMAL in the DB is fine, actually — if you read it into a decimal type, not a float. If your reporting team lives in SQL and wants
SUM(price)to look like money,NUMERIC(12,2)plus a currency column is a legitimate choice. What's not legitimate isDECIMALin Postgres scanned intofloat64in Go, which is the worst of both worlds and, in my experience, the most common setup out there.
The pattern's core is small enough to remember: integer minor units, currency attached, one constructor, and every deserialization path goes back through it. Everything else — the migration, the DTO naming, the entity validation — falls out of that.
The full implementation lives in my DDD template at https://github.com/sklinkert/go-ddd — alongside the rest of the patterns I keep rebuilding: validated entities, a transactional outbox for domain events, race-safe idempotency keys, and testcontainers-based integration tests.
Top comments (5)
Great writeup â the "make invalid states unrepresentable" framing is exactly right, and the accumulation drift example is the one that actually bites in production.
One thing worth flagging for anyone building the
cents int64+Currencypattern: not all currencies have 2 decimal places. ISO 4217 defines the minor unit exponent per currency, and it ranges from 0 to 4:cents/100formatting would be wrong)String()method really needs to be currency-aware. A lookup table keyed on ISO 4217 minor unit exponent handles all four cases cleanly. The field namecentsis a bit of a trap here â2 minor unitsâ is more accurate once you support JPY or BHD. The trickier boundary is at ingestion from external systems. Most bank APIs (PSD2, Open Banking) return transaction amounts as strings like"1234.56". When you parse those into your Money type, the source currency determines how many decimal places to expect â a JPY transaction of"5000"means 5000 yen (minor exponent 0), not 50 yen. This is where most float-to-int conversion bugs actually originate: not in your own arithmetic, but at the boundary where someone else's string representation meets your integer storage. For theint64range concern: ±NK.9"2 quintillion minor units covers any realistic balance. The real overflow risk is in intermediate calculations (percentage allocations, FX conversions), which is where amath/bigorshopspring/decimaldetour for the computation, then snap back toint64for storage, is the pragmatic approach. The value-object-with-validation pattern is genuinely the boring answer that works. The unported-fields + constructor approach makes the entire codebase's money handling auditable at a glance â you grep forNewMoneyand you've found every entry point.This is the best kind of comment - thank you. You're right on all points, and the JPY/BHD case is exactly why the article calls
String()out as EUR/USD-only. In the template I dodge the problem by whitelisting EUR and USD, so/100holds by construction, but the honest fix for multi-currency is what you describe: an exponent table keyed on the ISO 4217 minor unit, and formatting that asks the currency instead of assuming 2.Your point about the ingestion boundary is the one I'd underline twice. My worst money bug wasn't arithmetic either - it was parsing someone else's string representation with the wrong assumption about decimals.
"5000"JPY meaning 5000 yen, not 50, is a perfect example of why the parse has to be currency-aware before it ever touches the integer.Also conceding the naming:
centsis a trap once exponent-0 or exponent-3 currencies enter.minorUnitsis the better name and I'll likely rename it in the template.Appreciate it. The exponent list that actually bites, for anyone landing here later: 0 (JPY, KRW, CLP, ISK) and 3 (BHD, JOD, KWD, OMR, TND) — everything else is 2, so a static ISO 4217 lookup is the pragmatic floor when the type can't carry the exponent itself.
The ingestion rule is the takeaway I'd underline too: never accept a number without its currency at the edge — parse the pair together and reject early. That's the cheapest place to catch the whole class of bug.
The allocation footnote near the end is the part I'd promote to the headline. Integer cents make the split exact, but "100 split three ways = 33/33/34" hides a trap that bit us in payments: you have to persist where that extra cent landed, because the refund has to reverse the exact same way. Allocate the remainder to the first party on charge and the last party on refund and you leak a penny on every reversal — invisible per transaction, a real reconciliation gap at volume. We ended up storing the allocation instead of recomputing it, so the reversal is a mirror of the original by construction.
Persisting the allocation instead of recomputing it is a great rule, and the refund-mirror framing makes it obvious why: the reversal isn't a new allocation problem, it's the inverse of a decision you already made. Recomputing on refund means you need the exact same inputs, order, and rounding strategy forever - one refactor and you're leaking pennies at volume, like you said.
I kept allocation out of the article scope, but this is exactly the kind of thing that belongs in a follow-up: largest-remainder for the split, then store the split as rows so reversal is a lookup, not math. Thanks for sharing the war story.