Why double Will Break Your Fintech App (and How We Handle Money in Dart)
Floating-point money bugs aren't exotic edge cases — they're a predictable consequence of how double works, and they show up in almost every fintech codebase at least once. Here's the pattern that prevents them permanently.
Every fintech engineer eventually meets the same bug, usually the same way: a balance is off by a fraction of a cent, nobody can reproduce it locally, and the eventual root cause is a double that quietly accumulated rounding error somewhere upstream. It's not a rare mistake — it's the default outcome of storing money as double and doing arithmetic on it, and it happens to careful engineers as often as careless ones.
Why this happens, specifically #
double in Dart (and in nearly every language) is IEEE-754 binary floating point. Binary floating point can represent powers of two exactly, and almost nothing else exactly. 0.1 isn't 0.1 in memory — it's the closest binary approximation to it. Most of the time that error is too small to notice. Do enough arithmetic on enough values, though — sum a list of transactions, apply a percentage fee, split a bill three ways — and the error compounds into something visible, right around the point where a user is looking at their actual balance.
print(0.1 + 0.2 == 0.3); // false
print(0.1 + 0.2); // 0.30000000000000004This isn't a Dart bug. It's correct IEEE-754 behavior, and it's exactly why every serious payments system — regardless of language — stores money as an integer count of the smallest unit, never as a floating-point major-unit value.
The pattern: minor units, always #
Store every amount as an integer number of minor units — cents, paise, whatever your currency's smallest denomination is. A ₹10.00 balance is stored as 1000, not 10.0.
extension type Money._(int minorUnits) {
factory Money.fromMinorUnits(int minorUnits) = Money._;
Money operator +(Money other) => Money._(minorUnits + other.minorUnits);
Money operator -(Money other) => Money._(minorUnits - other.minorUnits);
/// Only for display. Never feed this back into arithmetic.
double get asMajorUnits => minorUnits / 100;
@override
String toString() => (minorUnits / 100).toStringAsFixed(2);
}Integer arithmetic has no rounding error — 1000 + 250 is exactly 1250, every time, with no representation ambiguity. The only place a double is allowed to exist is at the display boundary, converting minor units to a formatted string for the UI, and it never flows back into a calculation afterward. See the companion extension type pattern for how to make that boundary a compile-time guarantee instead of a convention people can forget.
Where the bug actually survives, even with this pattern #
Switching to integer minor units closes the most common failure mode. It doesn't close all of them:
Where teams still get caught
Splitting an amount three ways (1000 ~/ 3 = 333, three times, loses a paisa) needs an explicit remainder-distribution rule, decided once and applied consistently — not left to whichever engineer touches that code path next. Percentage fees need a stated rounding rule (round half up, round half to even, always round in the platform's favor) written down somewhere a reviewer can check against, not implied by whatever round() happens to do by default.
Both of those are correctness decisions that belong to product and finance, not to Dart's arithmetic. Minor units make the type trustworthy; they don't make the business rule automatic.
Currency mixing is a second, separate bug class #
None of the above stops someone from adding a Money in USD to a Money in INR and getting a nonsensical result — an integer sum across two different units of value. If your app touches more than one currency, Money needs a currency tag as part of its identity, and the + operator needs to refuse to combine two different currencies rather than silently summing the minor units:
extension type MultiCurrencyMoney._((int minorUnits, String currency) _value) {
factory MultiCurrencyMoney(int minorUnits, String currency) =
MultiCurrencyMoney._((minorUnits, currency));
int get minorUnits => _value.$1;
String get currency => _value.$2;
MultiCurrencyMoney operator +(MultiCurrencyMoney other) {
if (other.currency != currency) {
throw ArgumentError('Cannot add ${other.currency} to $currency');
}
return MultiCurrencyMoney(minorUnits + other.minorUnits, currency);
}
}Throwing here is deliberate — a currency mismatch is a logic error, not a runtime condition to handle gracefully. It should surface immediately, in testing, not get quietly summed into a number that's confidently wrong.
TL;DR #
doubleisn't unreliable "sometimes" — floating-point rounding error is a predictable, guaranteed consequence of how it's represented, not an edge case.- Store money as an integer count of minor units. Convert to
doubleonly at the final display step, never back into arithmetic. - Wrap it in a type (an extension type in Dart, at zero runtime cost) so the compiler — not code review — catches accidental raw-number arithmetic.
- Minor units fix representation error. They don't fix rounding policy (splits, fee rounding) or currency mixing — those need explicit rules, decided once, enforced everywhere.
Stuck on this in your own app?
This is the kind of problem I help fintech teams get right the first time — see how App Architecture & Consulting engagements work, or just tell me what you're building.