Date, Time & Money: java.time and BigDecimal Done Right
Two of the most common sources of production bugs in Java applications are date-time handling and money arithmetic. The old java.util.Date and Calendar classes were mutable (so one method could silently change a date it didn't own), had zero-based months (January is 0, which has confused every Java developer at least once), and mixed up "a moment in time" with "a wall-clock reading" in ways that made timezone bugs nearly inevitable. We mention them once so you can read legacy code — from here on, everything is java.time (Java 8+, current best practice in Java 25) and BigDecimal.
The java.time Toolbox: Pick the Right Type
java.time gives you several distinct types because dates and times are genuinely different concepts. Picking the right one is a decision rule, not a vocabulary test:
LocalDate,LocalTime,LocalDateTime— a calendar date and/or a wall-clock time with no timezone attached. Use them for things that are inherently local: a store's opening hours, a birthday, the date a report covers. ALocalDateTimeis not a moment on the global timeline — it is the same moment everywhere until you pin it to a zone.Instant— a single point on the global timeline (nanoseconds since the Unix epoch). This is what you store in databases and logs for event timestamps. No zone, no ambiguity: two systems on opposite sides of the planet agree on what anInstantmeans.ZonedDateTime— a date-time plus a timezone (a set of rules, e.g.America/Denver). This is what you display to users.
The recipe: store UTC, display local
The standard pattern that prevents nearly every timezone bug:
- Store event timestamps as
Instant(or UTC datetime columns — same thing). - Display them by converting to the user's
ZoneIdwithatZone(). - Never store a wall-clock
LocalDateTimeand assume you'll remember which zone it was in. You won't.
Instant orderPlaced = Instant.now(); // store this
ZonedDateTime denver = orderPlaced.atZone(ZoneId.of("America/Denver"));
System.out.println(denver); // 2026-10-03T13:45:12.123456-06:00[America/Denver]
Zone vs offset: the distinction that bites
A zone is a set of rules ("Mountain Time flips between MST and MDT"). An offset is just a number ("UTC−6"). Two places can share the same offset today and follow different rules tomorrow — which is why hardcoding an offset like UTC-6 breaks when daylight saving starts or ends. Always use a region-based ZoneId (America/Denver, Asia/Kolkata), not a fixed offset.
Duration vs Period: time-based vs date-based
Use Duration for exact amounts of time (seconds, hours, days on the timeline): session timeouts, retry delays, SLA windows. Use Period for calendar-based amounts (years, months, days): subscription terms, "30 days from today". The difference matters because a day isn't always 24 hours — across a DST transition it can be 23 or 25. Duration counts exact seconds; Period counts calendar days.
Duration sessionTimeout = Duration.ofMinutes(30);
Period subscriptionTerm = Period.ofMonths(12);
LocalDate expiry = LocalDate.now().plus(subscriptionTerm); // 2027-10-03
Parsing and formatting: DateTimeFormatter
DateTimeFormatter replaced the old SimpleDateFormat, which was famously not thread-safe — sharing one instance across threads could corrupt dates silently. DateTimeFormatter is immutable and thread-safe, so you can keep one as a static final constant.
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd MMM uuuu");
LocalDate d = LocalDate.parse("03 Oct 2026", fmt);
System.out.println(d.format(fmt)); // 03 Oct 2026
Rule of thumb: use ISO format (LocalDate.parse("2026-10-03")) for anything machines read, and custom patterns only for human-facing display.
What actually breaks: the DST gap
Timezones bite hardest where the clock skips. In America/Denver, clocks spring forward on the second Sunday of March: 2:00 AM jumps straight to 3:00 AM. So 2:30 AM on that day does not exist. If you schedule "every day at 2:30 AM" with a naive local time, java.time resolves the gap by shifting forward to 3:30 AM (documented behavior), but a hand-rolled Calendar-based scheduler would silently fire an hour late or twice. The decision rule: when you need "the same civil time every day regardless of DST" (a daily report, a store opening), model it as a LocalTime plus a ZoneId, and let ZonedDateTime.of(localDate, localTime, zone) handle the gaps and overlaps. When you need "exactly 24 hours from now" (a session expiry, a retry delay), model it as an Instant plus a Duration — no zone involved at all.
Working with legacy code: the one-way bridge
You will still meet java.util.Date in old libraries and APIs. Don't mix the models — convert at the boundary and forget the old type immediately:
Date legacy = someOldApi.getTimestamp();
Instant modern = legacy.toInstant(); // java.util.Date -> Instant
Date back = Date.from(modern); // Instant -> java.util.Date (only if an old API demands it)
Calendar converts via calendar.toInstant(). The pattern is always the same: legacy type comes in, Instant or ZonedDateTime takes over, and the legacy object never travels deeper into your code.
Money: Never double
This one is short because the rule is absolute for the money domain: never use double or float for money. Floating point is binary, and decimal fractions like 0.1 cannot be represented exactly in binary. Watch:
System.out.println(0.1 + 0.2); // 0.30000000000000004
That trailing error is fine for physics simulations; it is a lost penny on every transaction when it compounds through tax, discount, and rounding calculations. Use BigDecimal.
The double-constructor trap
Constructing a BigDecimal from a double inherits the binary error — the damage is already in the argument:
BigDecimal wrong = new BigDecimal(19.99);
System.out.println(wrong); // 19.989999999999998437...
BigDecimal right = new BigDecimal("19.99");
System.out.println(right); // 19.99
Always build BigDecimal from a String. If you already have a double, BigDecimal.valueOf(19.99) is the safer route (it converts via the string representation).
Arithmetic: scale and rounding
BigDecimal is immutable — add and multiply return new values. Division needs an explicit rounding mode or it throws ArithmeticException on non-terminating decimals (like 10 ÷ 3). The industry-standard rounding for money is RoundingMode.HALF_UP ("round half up", the way prices are rounded at a register):
BigDecimal price = new BigDecimal("19.99");
BigDecimal qty = new BigDecimal("3");
BigDecimal subtotal = price.multiply(qty); // 59.97
BigDecimal tax = subtotal.multiply(new BigDecimal("0.0825"))
.setScale(2, RoundingMode.HALF_UP); // 4.95
BigDecimal total = subtotal.add(tax); // 64.92
System.out.println(total); // 64.92
Decision rule: setScale(2, HALF_UP) at each money boundary (line totals, tax, grand total) — not after every intermediate multiplication, or you'll accumulate rounding differences that won't reconcile with the finance team's spreadsheet.
compareTo vs equals: the HashSet surprise
BigDecimal.equals compares both value and scale, while compareTo compares only numeric value:
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.compareTo(b) == 0); // true — numerically equal
System.out.println(a.equals(b)); // false — different scale!
Set<BigDecimal> set = new HashSet<>();
set.add(a); set.add(b);
System.out.println(set.size()); // 2 — the HashSet surprise
Consequence: use compareTo for all money comparisons ("is the payment ≥ the invoice?"), and never use BigDecimal as a HashMap key or in a HashSet unless you've normalized scale first (e.g. with stripTrailingZeros() — with care, since it can also produce scale surprises of its own).
Worked Example: Invoice Line-Item Total
Putting it together — an invoice calculator with timestamps and exact money:
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.ZoneId;
public class Invoice {
public static void main(String[] args) {
Instant issuedAt = Instant.now(); // stored in UTC
BigDecimal unitPrice = new BigDecimal("249.95");
BigDecimal quantity = new BigDecimal("4");
BigDecimal discount = new BigDecimal("0.10"); // 10% off
BigDecimal subtotal = unitPrice.multiply(quantity); // 999.80
BigDecimal discountAmt = subtotal.multiply(discount)
.setScale(2, RoundingMode.HALF_UP); // 99.98
BigDecimal taxable = subtotal.subtract(discountAmt); // 899.82
BigDecimal tax = taxable.multiply(new BigDecimal("0.0825"))
.setScale(2, RoundingMode.HALF_UP); // 74.24
BigDecimal total = taxable.add(tax); // 974.06
System.out.println("Issued: " + issuedAt
.atZone(ZoneId.of("America/Denver")));
System.out.println("Subtotal: " + subtotal); // 999.80
System.out.println("Discount: -" + discountAmt); // 99.98
System.out.println("Tax: " + tax); // 74.24
System.out.println("Total: " + total); // 974.06
}
}
Every amount is exact to the cent, the timestamp is zone-safe, and the display converts to the customer's zone at render time. This pattern — Instant for time, BigDecimal for money, scale and rounding at each boundary — carries straight into Spring and JPA later, where timestamps map to Instant columns and monetary fields map to DECIMAL/BigDecimal.
Quick Recap
LocalDate/LocalDateTime= local civil time, no zone.Instant= the point on the timeline you store.ZonedDateTime= what you display.- Zones are rules; offsets are numbers. Use region
ZoneIds likeAmerica/Denver, never hardcoded offsets. Duration= exact seconds (timeouts).Period= calendar time (subscriptions).DateTimeFormatter= thread-safe formatting.- Never
doublefor money.new BigDecimal("19.99"), notnew BigDecimal(19.99).setScale(2, HALF_UP)at money boundaries.compareTo, notequals, for comparisons.
Continue: Java Learning Roadmap 2026
Comments
Post a Comment