Why You Should Never Use Floating Point for Money

After years of maintaining legacy systems, I have seen more bugs caused by improper currency handling than by actual logic errors. Floating-point arithmetic is designed for scientific calculations where precision is secondary to range, making it fundamentally unfit for financial systems. When you use float or double, you are essentially gambling with rounding errors that will eventually manifest as missing cents in your database.

When talking to stakeholders, believe me, prices problems are always a priority.

To build a price system that remains stable under pressure, you need three pillars: absolute isolation, strict data types, and exhaustive edge-case testing.


Implementing Financial Precision with BigDecimal

The first rule of financial engineering is to treat binary floating-point types as radioactive. Always use BigDecimal (or your language’s equivalent arbitrary-precision library) to ensure that decimal values are represented exactly as they appear.

Unlike double, BigDecimal gives you explicit control over rounding behavior and scale. If you do not define these, the system will eventually throw an exception or, worse, produce a silent error during a high-stakes division.

public final class Price {
private final BigDecimal amount;
private final Currency currency;
public Price(BigDecimal amount, Currency currency) {
if (amount.scale() > currency.getDefaultFractionDigits()) {
throw new IllegalArgumentException("Scale exceeds currency limit");
}
this.amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN);
this.currency = currency;
}
public Price add(Price other) {
validateCurrency(other);
return new Price(this.amount.add(other.amount), this.currency);
}
private void validateCurrency(Price other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException();
}
}
}

Using the HALF_EVEN rounding mode—also known as Bankers’ Rounding—minimizes cumulative error by rounding to the nearest even neighbor. It is the industry standard for a reason: it prevents the upward bias that occurs when you always round .5 up.


Achieving Absolute Domain Isolation

Financial logic should never be scattered across your services or UI components. Price handling belongs in a dedicated Value Object. By isolating the logic, you ensure that rounding rules and currency conversions are applied consistently across the entire application.

  • Immutable State: A Price object should never be modified. Any operation must return a new instance.
  • Encapsulated Validation: The object itself should prevent the creation of “impossible” prices, such as negative values or incorrect scales.
  • Zero Leakage: Database drivers and API controllers should map directly to your Price Value Object, ensuring double never enters the system.

Unit Testing Financial Edge Cases

A price system is only as strong as its test suite. You must test for the “invisible” bugs that occur during division and large-scale summations. If you are splitting a $10.00$ charge between three people, your code must account for the leftover penny.

Critical Scenarios to Test:

  • The Penny Gap: Ensure that when dividing currency, the sum of the parts equals the original total.
  • Rounding Boundaries: Test values exactly at .005 to verify your rounding mode is behaving as expected.
  • Extreme Scales: Verify how the system handles products with six decimal places that must eventually be settled in two.
@Test
void shouldHandleDivisionWithoutLosingPennies() {
BigDecimal total = new BigDecimal("10.00");
BigDecimal parts = new BigDecimal("3");
BigDecimal result = total.divide(parts, 2, RoundingMode.HALF_EVEN);
// 3.33 * 3 = 9.99. Your logic must handle the remaining 0.01.
assertNotEquals(total, result.multiply(parts));
}

Dedicated Solution

I’ve always taken the price system as a dedicated system, isolated and unit tested on its own. If you start adding services or repositories to check the user’s account, discounts or other business rules, you’re adding more and more complexity.

Fetch all the data before entering into the price system. Then, let the black box do its magic.


Discover more from The Dev World – Sergio Lema

Subscribe to get the latest posts sent to your email.


Comments

Leave a comment