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.