Skip to content

How to Use BigDecimal for Financial Calculations in Java

Build an invoice calculator in Java 21 with BigDecimal: exact money, half-even rounding, tax and discounts, and a bill split that never loses a cent.

core-java java-tutorials how-to-tutorials
Gopi Gorantala
Reading Progress

On This Page

Start with a total that adds up

This is the invoice you'll have printing by the end. Three line items, a 10% discount, 8.25% sales tax, and a bill split three ways that adds back up to the cent:

subtotal  118.67
discount  11.87
tax       8.81
total     115.61
display   $115.61
split 3:  [38.54, 38.54, 38.53]

That's BigDecimal for financial calculations in Java: money held as exact decimal numbers, rounded once and on purpose, never as a double. You'll build a small invoice calculator with a Money type, a tax and discount model, and a splitter that doesn't lose a cent. It's for you if you've been told "don't use double for money" and want to see exactly what to use instead, with code that compiles on a plain JDK.

I built it on JDK 21 with nothing else installed. The part that cost me time wasn't the arithmetic. It was a static initialiser that blew up on first use, and I've left that bug in the troubleshooting section because you'll probably write it too.

 Line items ─► subtotal ─► minus discount ─► taxable ─► plus tax ─► total ─► split N ways
   (Money)      (Money)       (Money)                    (Money)    (Money)   (List<Money>)
        every arrow rounds to 2 places, half-even, inside Money

What you need to follow along

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 (Ubuntu build) java -version
Build tool none, plain javac javac -version
IntelliJ IDEA or Eclipse optional, see the setup section Help → About

You should be comfortable with classes, records and List. Nothing here needs Java 21 specifically; the record and the instanceof pattern in Money.equals need Java 16 or later. If you need a JDK, SDKMAN (run sdk list java to find the 21 identifier) or any Temurin 21 installer works.

Set up the invoice project from the command line, IntelliJ IDEA or Eclipse

I'll use the command line as the primary path because it removes everything that isn't BigDecimal. Make the folders:

mkdir -p invoice/src/main/java/dev/ggorantala/invoice
cd invoice

The project tree when you're done:

invoice/
└── src/main/java/dev/ggorantala/invoice/
    ├── DoubleTrap.java
    ├── Pitfalls.java
    ├── Money.java
    ├── Invoice.java
    ├── Splitter.java
    ├── Main.java
    └── Checks.java

In IntelliJ IDEA (verified from the docs, not executed by me): File → New → Project, pick Java, choose a JDK 21, name it invoice, then create the package dev.ggorantala.invoice under src. In Eclipse (also docs only): File → New → Java Project, set the execution environment to JavaSE-21, then right-click src → New → Package. Either way the seven files below drop in unchanged. There's no framework, so a Maven or Gradle project works the same: put the files under src/main/java and nothing else changes.

You don't need Podman or Docker for this one. There's no database in sight.

Putting it together: from double to a Money type

Step 1: Watch double fail on something small

Goal: see the problem with your own eyes before you fix it.

src/main/java/dev/ggorantala/invoice/DoubleTrap.java

package dev.ggorantala.invoice;

public class DoubleTrap {
    public static void main(String[] args) {
        double total = 0;
        for (int i = 0; i < 10; i++) {
            total += 0.10;
        }
        System.out.println("10 x 0.10 as double = " + total);
        System.out.println("equals 1.00?        " + (total == 1.00));
        System.out.println("0.1 + 0.2 as double  = " + (0.1 + 0.2));
    }
}

Compile and run:

mkdir out
javac -d out src/main/java/dev/ggorantala/invoice/DoubleTrap.java
java -cp out dev.ggorantala.invoice.DoubleTrap
10 x 0.10 as double = 0.9999999999999999
equals 1.00?        false
0.1 + 0.2 as double  = 0.30000000000000004

Checkpoint: if you see 0.9999999999999999, you're on track. A double stores a binary fraction, and 0.1 has no exact binary form, the same way 1/3 has no exact decimal form. Ten of them don't make one.

Step 2: Learn the five BigDecimal behaviours that bite

Goal: see the constructors, equals, rounding modes, division and the exceptions in one run, so nothing in the later steps surprises you.

src/main/java/dev/ggorantala/invoice/Pitfalls.java

package dev.ggorantala.invoice;

import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.util.HashSet;
import java.util.Set;

public class Pitfalls {
    public static void main(String[] args) {
        System.out.println("new BigDecimal(0.1)        = " + new BigDecimal(0.1));
        System.out.println("BigDecimal.valueOf(0.1)    = " + BigDecimal.valueOf(0.1));
        System.out.println("new BigDecimal(\"0.1\")      = " + new BigDecimal("0.1"));

        BigDecimal a = new BigDecimal("2.0");
        BigDecimal b = new BigDecimal("2.00");
        System.out.println("2.0 equals 2.00?           " + a.equals(b));
        System.out.println("2.0 compareTo 2.00         " + a.compareTo(b));
        Set<BigDecimal> set = new HashSet<>(Set.of(a));
        System.out.println("HashSet contains 2.00?     " + set.contains(b));

        System.out.println("2.5 HALF_UP   -> " + new BigDecimal("2.5").setScale(0, RoundingMode.HALF_UP));
        System.out.println("2.5 HALF_EVEN -> " + new BigDecimal("2.5").setScale(0, RoundingMode.HALF_EVEN));
        System.out.println("3.5 HALF_EVEN -> " + new BigDecimal("3.5").setScale(0, RoundingMode.HALF_EVEN));
        System.out.println("0.125 HALF_UP   -> " + new BigDecimal("0.125").setScale(2, RoundingMode.HALF_UP));
        System.out.println("0.125 HALF_EVEN -> " + new BigDecimal("0.125").setScale(2, RoundingMode.HALF_EVEN));

        BigDecimal hundred = new BigDecimal("100");
        System.out.println("strip 100 toString         = " + hundred.stripTrailingZeros());
        System.out.println("strip 100 toPlainString    = " + hundred.stripTrailingZeros().toPlainString());

        System.out.println("1/3 with MathContext(10)   = " + BigDecimal.ONE.divide(new BigDecimal("3"), MathContext.DECIMAL64));
        System.out.println("1/3 scale 4 HALF_EVEN      = " + BigDecimal.ONE.divide(new BigDecimal("3"), 4, RoundingMode.HALF_EVEN));

        // per-line vs per-invoice tax
        BigDecimal rate = new BigDecimal("0.0825");
        BigDecimal perLine = BigDecimal.ZERO;
        BigDecimal base = BigDecimal.ZERO;
        for (int i = 0; i < 10; i++) {
            BigDecimal price = new BigDecimal("0.10");
            base = base.add(price);
            perLine = perLine.add(price.multiply(rate).setScale(2, RoundingMode.HALF_EVEN));
        }
        System.out.println("10 x 0.10 tax per line     = " + perLine);
        System.out.println("tax on 1.00 once           = " + base.multiply(rate).setScale(2, RoundingMode.HALF_EVEN));

        trap("divide 1/3 no context", () -> BigDecimal.ONE.divide(new BigDecimal("3")));
        trap("setScale(2) needs rounding", () -> new BigDecimal("1.005").setScale(2));
        trap("valueOf(NaN)", () -> BigDecimal.valueOf(Double.NaN));
        trap("new BigDecimal(\"1,5\")", () -> new BigDecimal("1,5"));
        trap("divide by zero", () -> BigDecimal.ONE.divide(BigDecimal.ZERO));
        trap("longValueExact 1.5", () -> new BigDecimal("1.5").longValueExact());
    }

    static void trap(String label, Runnable r) {
        try {
            r.run();
            System.out.println(label + " -> no exception");
        } catch (RuntimeException e) {
            System.out.println(label + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
}
java -cp out dev.ggorantala.invoice.Pitfalls
new BigDecimal(0.1)        = 0.1000000000000000055511151231257827021181583404541015625
BigDecimal.valueOf(0.1)    = 0.1
new BigDecimal("0.1")      = 0.1
2.0 equals 2.00?           false
2.0 compareTo 2.00         0
HashSet contains 2.00?     false
2.5 HALF_UP   -> 3
2.5 HALF_EVEN -> 2
3.5 HALF_EVEN -> 4
0.125 HALF_UP   -> 0.13
0.125 HALF_EVEN -> 0.12
strip 100 toString         = 1E+2
strip 100 toPlainString    = 100
1/3 with MathContext(10)   = 0.3333333333333333
1/3 scale 4 HALF_EVEN      = 0.3333
10 x 0.10 tax per line     = 0.10
tax on 1.00 once           = 0.08
divide 1/3 no context -> ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
setScale(2) needs rounding -> ArithmeticException: Rounding necessary
valueOf(NaN) -> NumberFormatException: Character N is neither a decimal digit number, decimal point, nor "e" notation exponential mark.
new BigDecimal("1,5") -> NumberFormatException: Character , is neither a decimal digit number, decimal point, nor "e" notation exponential mark.
divide by zero -> ArithmeticException: Division by zero
longValueExact 1.5 -> ArithmeticException: Rounding necessary

Read it top to bottom, because each block is a rule:

  • Build from strings, not doubles. new BigDecimal(0.1) faithfully captures the double's error. new BigDecimal("0.1") is exactly 0.1. BigDecimal.valueOf(0.1) also gives 0.1, because it goes through Double.toString, but by then the number has already been a double. For money coming from JSON or a database, keep it a string or an integer count of cents.
  • equals compares scale too. 2.0 and 2.00 are different to equals and to hashCode, so they're different keys in a HashSet. compareTo says they're equal. It's the main reason to wrap amounts in a type that fixes the scale.
  • Pick the rounding mode on purpose. HALF_UP is what school taught you. HALF_EVEN (banker's rounding) rounds a tie to the even digit, so over thousands of rows the errors cancel out instead of drifting upward. Check which one your finance team or regulator expects before choosing; I default to HALF_EVEN and change it when told to.
  • divide without a context throws on 1/3. You must say how many digits you want: either a scale and a rounding mode, or a MathContext.
  • Round once, at the end. Taxing ten 10-cent items one by one gives 0.10 in total, taxing the 1.00 total once gives 0.08. Neither is "wrong"; they're different rules, and the law of the place you're billing in picks one. The code below taxes the invoice once.

Checkpoint: if HashSet contains 2.00? printed false, you've seen the trap that Step 3 removes.

Step 3: Wrap amounts in a Money type that fixes the scale

Goal: make it impossible to hold an unrounded or wrongly-scaled amount.

src/main/java/dev/ggorantala/invoice/Money.java

package dev.ggorantala.invoice;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;

/**
 * An amount in a single currency with exactly two decimal places.
 * Every instance is already rounded, so equals() and hashCode() behave.
 */
public final class Money implements Comparable<Money> {

    private static final RoundingMode MODE = RoundingMode.HALF_EVEN;
    public static final Money ZERO = new Money(BigDecimal.ZERO);

    private final BigDecimal amount;

    private Money(BigDecimal raw) {
        this.amount = raw.setScale(2, MODE);
    }

    public static Money of(String text) {
        return new Money(new BigDecimal(text));
    }

    public static Money ofCents(long cents) {
        return new Money(BigDecimal.valueOf(cents, 2));
    }

    public static Money of(BigDecimal value) {
        return new Money(Objects.requireNonNull(value));
    }

    public Money plus(Money other) {
        return new Money(amount.add(other.amount));
    }

    public Money minus(Money other) {
        return new Money(amount.subtract(other.amount));
    }

    public Money times(int quantity) {
        return new Money(amount.multiply(BigDecimal.valueOf(quantity)));
    }

    /** Multiply by a rate such as 0.0825 and round once, at the end. */
    public Money times(BigDecimal rate) {
        return new Money(amount.multiply(rate));
    }

    public long cents() {
        return amount.movePointRight(2).longValueExact();
    }

    public BigDecimal amount() {
        return amount;
    }

    @Override
    public int compareTo(Money o) {
        return amount.compareTo(o.amount);
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof Money m && amount.equals(m.amount);
    }

    @Override
    public int hashCode() {
        return amount.hashCode();
    }

    @Override
    public String toString() {
        return amount.toPlainString();
    }
}

The three things worth a look. The private constructor calls setScale(2, MODE), so every Money is already rounded and 2.5 and 2.50 become the same object value. Only of(String), ofCents(long) and of(BigDecimal) create one, and there's deliberately no of(double). And times(BigDecimal rate) does the multiplication at full precision and rounds after, which is the "round once" rule from Step 2.

MODE is declared above ZERO on purpose. Swap them and the class won't load; see the troubleshooting section.

Checkpoint: nothing to run yet, but javac -d out src/main/java/dev/ggorantala/invoice/Money.java should print nothing.

Step 4: Model the invoice

Goal: subtotal, discount and tax as methods that each return Money.

src/main/java/dev/ggorantala/invoice/Invoice.java

package dev.ggorantala.invoice;

import java.math.BigDecimal;
import java.util.List;

public record Invoice(List<Line> lines, BigDecimal discountRate, BigDecimal taxRate) {

    public record Line(String sku, int quantity, Money unitPrice) {
        public Money total() {
            return unitPrice.times(quantity);
        }
    }

    public Invoice {
        lines = List.copyOf(lines);
        if (discountRate.signum() < 0 || discountRate.compareTo(BigDecimal.ONE) > 0) {
            throw new IllegalArgumentException("discountRate must be between 0 and 1: " + discountRate);
        }
        if (taxRate.signum() < 0) {
            throw new IllegalArgumentException("taxRate must not be negative: " + taxRate);
        }
    }

    public Money subtotal() {
        return lines.stream().map(Line::total).reduce(Money.ZERO, Money::plus);
    }

    public Money discount() {
        return subtotal().times(discountRate);
    }

    public Money taxableAmount() {
        return subtotal().minus(discount());
    }

    public Money tax() {
        return taxableAmount().times(taxRate);
    }

    public Money total() {
        return taxableAmount().plus(tax());
    }
}

A record keeps it short. The compact constructor rejects a discount outside 0 to 1 and a negative tax rate, and it copies the list so nobody can change the lines after the fact. Rates are BigDecimal too, created from strings like "0.0825", because a double rate would put the problem straight back in.

Step 5: Split a total without losing a cent

Goal: divide a total between N people so the parts add up to the total exactly.

src/main/java/dev/ggorantala/invoice/Splitter.java

package dev.ggorantala.invoice;

import java.util.ArrayList;
import java.util.List;

public final class Splitter {

    private Splitter() {}

    /**
     * Splits a total into n parts that add back up to the total exactly.
     * The first (total % n) parts get one extra cent.
     */
    public static List<Money> split(Money total, int parts) {
        if (parts <= 0) {
            throw new IllegalArgumentException("parts must be positive: " + parts);
        }
        long cents = total.cents();
        long base = cents / parts;
        long extra = cents % parts;
        List<Money> result = new ArrayList<>(parts);
        for (int i = 0; i < parts; i++) {
            result.add(Money.ofCents(base + (i < extra ? 1 : 0)));
        }
        return List.copyOf(result);
    }
}

I work in whole cents here (long) because integer division with a remainder is the clearest way to say "everyone gets the base amount, and the first few get one extra cent". Divide 115.61 by 3 with BigDecimal and round each share, and you get three shares of 38.54 that add up to 115.62. That's the bug this class prevents.

Check it works

src/main/java/dev/ggorantala/invoice/Main.java

package dev.ggorantala.invoice;

import java.math.BigDecimal;
import java.text.NumberFormat;
import java.util.List;
import java.util.Locale;

public class Main {
    public static void main(String[] args) {
        Invoice invoice = new Invoice(
                List.of(
                        new Invoice.Line("NOTEBOOK-A5", 3, Money.of("19.99")),
                        new Invoice.Line("PEN-GEL-BLK", 12, Money.of("1.35")),
                        new Invoice.Line("DESK-LAMP", 1, Money.of("42.50"))),
                new BigDecimal("0.10"),
                new BigDecimal("0.0825"));

        System.out.println("subtotal  " + invoice.subtotal());
        System.out.println("discount  " + invoice.discount());
        System.out.println("tax       " + invoice.tax());
        System.out.println("total     " + invoice.total());

        NumberFormat usd = NumberFormat.getCurrencyInstance(Locale.US);
        System.out.println("display   " + usd.format(invoice.total().amount()));

        System.out.println("split 3:  " + Splitter.split(invoice.total(), 3));
    }
}
javac -d out src/main/java/dev/ggorantala/invoice/*.java
java -cp out dev.ggorantala.invoice.Main
subtotal  118.67
discount  11.87
tax       8.81
total     115.61
display   $115.61
split 3:  [38.54, 38.54, 38.53]

Check the numbers by hand once. Lines: 3 × 19.99 = 59.97, 12 × 1.35 = 16.20, 1 × 42.50, so 118.67. Ten percent is 11.867, which rounds to 11.87. Taxable is 106.80, and 8.25% of that is 8.811, which rounds to 8.81. Total 115.61. If your numbers match, the hard part's done.

The display line uses NumberFormat.getCurrencyInstance(Locale.US). Format at the edge, for people to read, and never parse that string back into a number. It's also worth knowing that the currency symbol and separators depend on the locale you pass.

Thirty-four checks that pin the arithmetic down

I couldn't reach Maven Central from my build environment, so I didn't pull in JUnit. These are hand-rolled checks with a tiny check helper and plain main. They translate one to one into JUnit 5 @Test methods with AssertJ's isEqualTo, which I'd use as soon as you have a build tool, and a JUnit version isn't something I've verified here.

src/main/java/dev/ggorantala/invoice/Checks.java

package dev.ggorantala.invoice;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Checks {
    static int passed = 0;
    static int failed = 0;

    public static void main(String[] args) {
        Invoice inv = new Invoice(
                List.of(new Invoice.Line("NOTEBOOK-A5", 3, Money.of("19.99")),
                        new Invoice.Line("PEN-GEL-BLK", 12, Money.of("1.35")),
                        new Invoice.Line("DESK-LAMP", 1, Money.of("42.50"))),
                new BigDecimal("0.10"), new BigDecimal("0.0825"));

        check("subtotal", inv.subtotal(), Money.of("118.67"));
        check("discount", inv.discount(), Money.of("11.87"));
        check("tax", inv.tax(), Money.of("8.81"));
        check("total", inv.total(), Money.of("115.61"));
        check("total = taxable + tax", inv.total(), inv.taxableAmount().plus(inv.tax()));

        check("0.1 ten times is exactly 1.00", sumTenths(), Money.of("1.00"));
        check("Money.of(\"2.5\") equals Money.of(\"2.50\")", Money.of("2.5"), Money.of("2.50"));
        check("hash lookup ignores input scale", lookup(), "found");
        check("half-even: 0.125 -> 0.12", Money.of("0.125"), Money.of("0.12"));
        check("half-even: 0.135 -> 0.14", Money.of("0.135"), Money.of("0.14"));
        check("ofCents(1999) prints 19.99", Money.ofCents(1999).toString(), "19.99");
        check("cents round trip", Money.of("19.99").cents(), 1999L);
        check("times(rate) rounds once", Money.of("0.10").times(new BigDecimal("0.0825")), Money.of("0.01"));

        for (int parts = 1; parts <= 12; parts++) {
            Money total = inv.total();
            Money sum = Splitter.split(total, parts).stream().reduce(Money.ZERO, Money::plus);
            check("split " + parts + " ways adds back up", sum, total);
        }
        check("split 3 of 115.61", Splitter.split(Money.of("115.61"), 3).toString(), "[38.54, 38.54, 38.53]");
        check("split of 0.01 into 3", Splitter.split(Money.of("0.01"), 3).toString(), "[0.01, 0.00, 0.00]");

        throwsA("split zero ways", IllegalArgumentException.class, () -> Splitter.split(Money.ZERO, 0));
        throwsA("discount above 100%", IllegalArgumentException.class,
                () -> new Invoice(List.of(), new BigDecimal("1.5"), BigDecimal.ZERO));
        throwsA("negative tax", IllegalArgumentException.class,
                () -> new Invoice(List.of(), BigDecimal.ZERO, new BigDecimal("-0.01")));
        throwsA("junk text", NumberFormatException.class, () -> Money.of("12,50"));
        throwsA("null amount", NullPointerException.class, () -> Money.of((BigDecimal) null));
        check("negative money ok", Money.of("5.00").minus(Money.of("7.50")).toString(), "-2.50");
        check("compareTo orders", Money.of("1.10").compareTo(Money.of("1.2")) < 0, true);

        System.out.println();
        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }

    static Money sumTenths() {
        Money m = Money.ZERO;
        for (int i = 0; i < 10; i++) m = m.plus(Money.of("0.10"));
        return m;
    }

    static String lookup() {
        Map<Money, String> prices = new HashMap<>();
        prices.put(Money.of("2.5"), "found");
        return prices.get(Money.of("2.50"));
    }

    static void check(String name, Object actual, Object expected) {
        if (actual.equals(expected)) {
            passed++;
            System.out.println("PASS  " + name);
        } else {
            failed++;
            System.out.println("FAIL  " + name + "  expected=" + expected + " actual=" + actual);
        }
    }

    static void throwsA(String name, Class<? extends Throwable> type, Runnable r) {
        try {
            r.run();
            failed++;
            System.out.println("FAIL  " + name + "  nothing thrown");
        } catch (Throwable t) {
            if (type.isInstance(t)) {
                passed++;
                System.out.println("PASS  " + name);
            } else {
                failed++;
                System.out.println("FAIL  " + name + "  got " + t);
            }
        }
    }
}
javac -Xlint:all -d out src/main/java/dev/ggorantala/invoice/*.java
java -cp out dev.ggorantala.invoice.Checks
PASS  subtotal
PASS  discount
PASS  tax
PASS  total
PASS  total = taxable + tax
PASS  0.1 ten times is exactly 1.00
PASS  Money.of("2.5") equals Money.of("2.50")
PASS  hash lookup ignores input scale
PASS  half-even: 0.125 -> 0.12
PASS  half-even: 0.135 -> 0.14
PASS  ofCents(1999) prints 19.99
PASS  cents round trip
PASS  times(rate) rounds once
PASS  split 1 ways adds back up
[... lines cut: split 2 to 11 ways, same PASS]
PASS  split 12 ways adds back up
PASS  split 3 of 115.61
PASS  split of 0.01 into 3
PASS  split zero ways
PASS  discount above 100%
PASS  negative tax
PASS  junk text
PASS  null amount
PASS  negative money ok
PASS  compareTo orders

34 passed, 0 failed

The loop over 1 to 12 parts is the one I'd keep even if you delete the rest. "Parts add up to the total" is the property that matters, and a loop covers remainders of every size.

The parts that matter: scale, precision and rounding mode

A BigDecimal is two things: an unscaled integer and a scale, the count of digits after the decimal point. 19.99 is 1999 with scale 2. That's why equals cares about scale, and why Money forces it to 2: the same value always has the same representation.

Precision is a different idea: the total count of significant digits. You only meet it in division, through MathContext. For money, I prefer the divide(x, scale, RoundingMode) form because it says what you want, "two decimal places, half-even", instead of "sixteen significant digits".

Add, subtract and multiply never lose information. 1.35 × 12 has scale 2 + 0 and is exact, and 0.0825 × 106.80 has scale 6 and is exact too. Only divide and setScale can lose digits, and Money does the latter in exactly one place. Fewer rounding sites means fewer places for a penny to go missing.

The rule to carry away is short: money is decimal, so keep it decimal. If you want the same "wrap a value in a small immutable type" idea in general form, see How to create an immutable class in Java and How to use Java records. For the equals and hashCode contract that Money relies on, see How to implement equals() and hashCode().

Errors I hit on this build

ExceptionInInitializerError caused by a NullPointerException in setScale. My first Money.java declared ZERO before MODE. Static fields initialise in the order they appear, so when ZERO ran its constructor, MODE was still null:

Exception in thread "main" java.lang.ExceptionInInitializerError
	at dev.ggorantala.invoice.Main.main(Main.java:12)
Caused by: java.lang.NullPointerException: Cannot read field "oldMode" because "roundingMode" is null
	at java.base/java.math.BigDecimal.setScale(BigDecimal.java:2876)
	at dev.ggorantala.invoice.Money.<init>(Money.java:19)
	at dev.ggorantala.invoice.Money.<clinit>(Money.java:13)

Cause: field order. Fix: declare MODE first. The <clinit> frame in the trace is the clue that it's a static initialiser. The line before it, Money.<init>, is the constructor that tripped.

ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result. You called divide(other) on something like 1 and 3. Pass a scale and RoundingMode.

ArithmeticException: Rounding necessary from setScale(2) on a value with more digits. setScale with one argument refuses to round. Add the RoundingMode.

NumberFormatException: Character , is neither a decimal digit number... new BigDecimal("12,50"). The constructor takes . only. Parse locale-formatted input with DecimalFormat set to parse BigDecimal, at the edge, and keep the rest of the code on plain strings.

Prints 1E+2 instead of 100. That's stripTrailingZeros() on a whole number. Use toPlainString() for display and logs.

I didn't test this on Windows. The one thing to watch is console encoding if you format with a non-US locale, because the symbol may print as a question mark.

Taking it to production

  • Store cents or NUMERIC, never FLOAT. In a database, use NUMERIC(19,4) or whatever your ledger needs, or a BIGINT count of minor units. Map it to BigDecimal in JPA and set the scale there.
  • Currencies aren't all two places. Japanese yen has none, and some currencies have three. java.util.Currency.getDefaultFractionDigits() gives the number. This article hard-codes 2; a real Money carries a Currency and refuses to add USD to EUR.
  • JSON. Untyped JSON parsing often hands you a double for a number. Bind to a BigDecimal field or a string, and keep the value as text on the wire if precision matters. I didn't test a JSON library in this build.
  • Version note. RoundingMode replaced the old BigDecimal.ROUND_HALF_EVEN int constants years ago, and they're deprecated. I compiled only on JDK 21. The code should work on Java 16 and later by the rules above; on older versions you'd replace the record and the instanceof pattern.
  • Next reads: How to implement the builder pattern in Java if your invoice needs many optional fields, and, once published, How to handle time zones correctly in Java for due dates and How to write unit tests with JUnit 5 in Java to turn the checks above into real tests.

Recap

  • Hold money as BigDecimal built from strings, or as whole cents. Never from a double.
  • Give every amount one scale, in one type, so equals and hashCode work.
  • Multiply at full precision and round once, with a rounding mode you chose.
  • Divide only with a scale and RoundingMode, and split by cents so the parts add up.
  • Initialise static fields in dependency order.

BigDecimal questions people ask

Why shouldn't I use double for money in Java? A double can't represent most decimal fractions exactly, so errors creep in: ten additions of 0.10 gave 0.9999999999999999 above. Use BigDecimal or integer cents.

Should I use BigDecimal.valueOf or new BigDecimal(String)? Use the string constructor when you have text. Use valueOf(double) only when a double is all you've got, and know the value passed through Double.toString first.

Which rounding mode should I use for money? HALF_EVEN reduces cumulative bias across many roundings; HALF_UP matches what people expect on a single receipt. Your accounting rules decide, so ask before assuming.

Why does BigDecimal.equals return false for 2.0 and 2.00? equals compares value and scale. Use compareTo for numeric comparison, or normalise the scale in a wrapper type as Money does.

References

core-javajava-tutorialshow-to-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments