mattone
dopo mattoneTHE BOOK SERIES
IT/EN
← Java guide

Java 25 · 26/39

26. Time, localization and exact numbers

Java 25 · Complete guide · Draft under review

This guide retains the book’s draft status. Editorial review and comprehension checks with independent readers remain to be completed.

Search the whole guide →

A due date and a payment are not the same kind of data

A library may say that a loan is due on November 1. A payment log, on the other hand, must say at which instant a confirmation arrived. The two pieces of information look like “dates”, but answer different questions. If we store both as strings, the program does not know which operations make sense. If we store both as milliseconds since the epoch, we force a civil due date to pretend to be an instant. Choosing the type is the chapter's first brick: describing the meaning of information before computing it.

Java's epoch is the reference point 1970-01-01T00:00:00Z on the timeline. Instant represents a point on that line; it suits recording when an event happened and comparing two events. LocalDate represents a calendar date without time or zone. LocalTime represents a civil time without a date; LocalDateTime combines civil date and time, but still does not necessarily identify one instant. In figure 26.1, the domain question leads to the type, rather than to a list of classes to memorize.

Figure 26.1 – Choosing the temporal type starts from the application's question.

A ZoneOffset such as +02:00 is a distance from UTC at a particular moment. A ZoneId such as Europe/Rome instead identifies the rules of a geographical area, including transitions that change the offset. OffsetDateTime binds a civil date and time to an offset; ZonedDateTime binds them to a zone with rules. If we must show a reader the local time of an event recorded as an Instant, apply a zone when producing the view: event.atZone(zone). The chosen zone is part of the display request and does not change the recorded instant.

Key concept – A local value is not incomplete by definition. “The library closes at 18:00” is naturally a civil time; “the payment arrived at 18:00” is insufficient to compare it with a payment made elsewhere. In one case the zone may be a property of the location; in the other we must record an instant or enough information to reconstruct it. A type's completeness depends on the question the application must answer.

Calendar hours and elapsed durations

Duration measures an amount of time along the timeline of instants, for example ninety minutes actually elapsed. Period instead expresses calendar years, months and days, for example “one month from the issue date”. ChronoUnit allows naming units in various operations. The distinction appears when daylight saving time changes: plusDays(1) on a zoned value preserves civil time and may cross twenty-three or twenty-five real hours; plus(Duration.ofHours(24)) preserves twenty-four elapsed hours and may change the displayed civil time. Neither operation is universally correct. A deadline “tomorrow at the same local time” needs the civil rule; a twenty-four-hour timer needs the duration.

A local date and time may fall in a gap, when the clock jumps forward and that time does not exist, or an overlap, when the clock goes back and the same local time appears twice. ZoneRules.getValidOffsets(localDateTime) returns zero, one or two valid offsets. Figure 26.2 represents the two cases. This is a check to perform when a user chooses a local date and time for an event that must occur at a precise instant. Choosing the first available offset without showing the decision to the user may schedule the appointment an hour earlier than intended.

Figure 26.2 – A civil time may be absent or correspond to two instants.

In the test program, 2026-10-25T02:30 in Rome is an overlap. Consulting the rules returns +02:00 and +01:00. We construct two ZonedDateTime values with the same civil time but different preferred offsets; their Instant values are sixty minutes apart. The result does not depend on the computer's current clock. Zone rules are runtime data and can be updated: for historical processing or distant deadlines, record the data version and resolution policy if exact reproducibility is a requirement.

A controllable clock

Instant.now() is convenient, but calling it at the heart of invoice logic makes testing depend on execution time. Clock separates reading the time from calculation. Clock.fixed supplies a constant instant, useful for an example and a test; Clock.system(zone) reads the system clock. The program uses a clock fixed at October 2, 2026 at 10:00 UTC with the Rome zone. LocalDate.now(testClock) produces the civil issue date, while Instant.now(testClock) records the event. The due date is issued.plusDays(30), which in the example prints 2026-11-01. If the contract said “thirty periods of 24 hours”, we would use an instant and a duration instead of LocalDate.

Formatting must not change the value. DateTimeFormatter.ISO_LOCAL_DATE produces a stable date representation; a custom pattern requires attention to symbols with different meanings, such as calendar year and week-based year. Parsing external input should use a declared format and handle DateTimeParseException by showing the invalid field. Readers must distinguish LocalDate.parse("2026-11-01"), which interprets an ISO format, from trying to use English as a general rule for an ambiguous date such as 01/11/26.

A date received as 31/02/2026 is not merely written in an unusual format: it does not exist in the ISO calendar. The parser must report it. For input declaring day, month and year, we can create a DateTimeFormatter with the expected pattern and a strict resolution strategy; do not silently choose March 3 as a “correction” for February 31. A missing zone must also be treated as missing information when the requested result is an instant. In an interface, locale may determine the displayed field order, but the interchange contract between processes must declare an unambiguous format.

Testing a temporal calculation has at least two levels. A unit test uses Clock.fixed to reproduce the same date and time. An integration test checks that the application's configured zone is the one required by the domain, especially when software runs on a server set to UTC but serves users in different cities. If we implicitly read ZoneId.systemDefault() in a hidden part of the logic, the test may pass on the developer's machine and change outcome on the server. The zone must be chosen or injected as a dependency, rather than left to the computer's geography.

Why a price is not a double

The double type represents binary floating-point numbers. Many decimal fractions have no finite binary representation; this makes double excellent for many scientific computations but unsuitable as the default choice for amounts with decimal rounding rules. BigDecimal represents an unscaled integer and a scale. In 19.90, the unscaled integer is 1990 and the scale is 2: the value is 1990 × 10⁻². The × symbol means multiplication and 10⁻² means dividing by one hundred. This model preserves exactly the decimal written in the string.

Decimal precision does not solve concurrency by itself. If two requests change the same balance, a BigDecimal formula can calculate each amount exactly and still lose an update. Chapter 20A teaches identifying the atomic decision in JVM memory; when the balance is stored in an archive, operation consistency must also be guaranteed by the persistent transaction. “Exact number” and “indivisible update” are different properties, both necessary depending on the case.

We therefore construct new BigDecimal("19.90"). new BigDecimal(0.1) instead starts from the binary approximation already contained in the double: it does not recover the ideal decimal 0.1. BigDecimal.valueOf(0.1) uses the canonical textual representation of the double and may be useful at a boundary, but when data originates as decimal text, the string constructor preserves the intention directly. Monetary amounts also need a currency unit: the number 19.90 alone does not say whether it means euros or dollars.

Figure 26.3 connects price, rate and rounding. In the case study, 19.90 × 0.22 produces 4.378: round the tax to two decimal places with RoundingMode.HALF_UP, obtaining 4.38; the total is 24.28. This is a declared teaching policy, rather than a universal tax rule. If law or contract imposes rounding per line, per document or per currency, where we round changes the result. MathContext instead limits an operation's overall precision: it is not synonymous with “two digits after the decimal point”.

Figure 26.3 – The total depends on the declared rounding point and rule.

A decimal division may have an infinite expansion, such as 1 / 3. BigDecimal.ONE.divide(new BigDecimal("3")) without a rounding policy throws ArithmeticException; it does not silently return a truncated number. The solution requires a scale and RoundingMode, or a MathContext appropriate to the domain. BigInteger solves a different problem: integers exceeding primitive ranges. It does not by itself handle a decimal point; for the same work, arbitrary-precision objects and operations may cost more than primitives.

To see the difference between scale and precision, consider 123.456. Its scale is three because there are three digits after the point; its precision is six because there are six significant digits. setScale(2, HALF_UP) produces 123.46. A MathContext with precision four would produce 123.5, meaning four significant digits in total. Saying “I want two decimal places” and using precision two is a conceptual error: it yields a number with two significant digits, potentially losing even the desired integer part.

BigInteger also requires a justified decision. A loan counter that will never exceed long limits gains nothing from its added cost; a numerical identifier generated by a formula growing without a predefined bound may require it. BigInteger is immutable: total.add(unit) returns a new object and does not modify total. The same rule applies to BigDecimal; forgetting to assign the result leaves the value unchanged, just as with String.

Complete case: invoice date and amount

The following program brings the two boundaries together. The issue date and due date are civil dates. Recording is an instant. The total comes from decimal text and an explicit rounding rule. Compile with javac --release 25 InvoiceTime.java and run with java InvoiceTime.

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Locale;

public class InvoiceTime {
    public static void main(String[] args) {
        ZoneId zone = ZoneId.of("Europe/Rome");
        Clock testClock = Clock.fixed(Instant.parse("2026-10-02T10:00:00Z"), zone);
        LocalDate issued = LocalDate.now(testClock);
        LocalDate due = issued.plusDays(30);
        System.out.println("Civil due date: " + due);
        System.out.println("UTC recording: " + Instant.now(testClock));

        LocalDateTime overlap = LocalDateTime.of(2026, 10, 25, 2, 30);
        System.out.println("Possible offsets: " + zone.getRules()
                .getValidOffsets(overlap));
        ZonedDateTime first = ZonedDateTime.ofLocal(
                overlap, zone, ZoneOffset.ofHours(2));
        ZonedDateTime second = ZonedDateTime.ofLocal(
                overlap, zone, ZoneOffset.ofHours(1));
        System.out.println("Distinct instants: " +
                Duration.between(first.toInstant(), second.toInstant()).toMinutes()
                + " minutes");

        BigDecimal price = new BigDecimal("19.90");
        BigDecimal rate = new BigDecimal("0.22");
        BigDecimal tax = price.multiply(rate)
                .setScale(2, RoundingMode.HALF_UP);
        BigDecimal total = price.add(tax);
        System.out.println("Total: " + total.toPlainString());
        System.out.println("Print locale: " + Locale.UK);
        System.out.println("Same number, different scales: "
                + (new BigDecimal("2.0").compareTo(new BigDecimal("2.00")) == 0));
    }
}

Expected output includes Civil due date: 2026-11-01, Distinct instants: 60 minutes and Total: 24.28. The overlap example demonstrates that the same local date and time is not enough to identify the instant. The final line numerically compares 2.0 and 2.00: compareTo returns zero, while equals would return false because it also considers scale. In a HashSet and a TreeSet, this difference may produce different uniqueness behavior; chapter 17 on collections explains why. Before using BigDecimal as a key, fix the semantics required by the domain and normalize if necessary.

Presenting values to different people

Locale governs presentation conventions, such as decimal separator, digit grouping and date names; it does not change the number or instant. NumberFormat can format an amount for a locale, while Currency identifies an ISO currency and some of its properties. Formatting 24.28 for an Italian reader may produce 24,28; in the interchange file instead use the agreed format, rather than the one of the machine running the program. Translatable texts need separate language resources; putting English words inside calculation logic would prevent changing language without touching the domain.

The displayed symbol is not sufficient currency identity: the same symbol may appear in different contexts. An invoice retains a currency code and an amount under the applicable rules, then decides how to present them to a recipient. NumberFormat.getCurrencyInstance(locale) supplies locale-oriented formatting; the program must also set or know the document's actual currency, rather than assume an Italian locale always means euros. During data transfer, do not save the formatted string as a numerical value to recalculate: it incorporates separators and symbols for people, rather than an arithmetic contract.

Integration with existing systems may impose Date, Calendar or TimeZone. They are not the first model to teach for new code, but we must know the boundary: Date.toInstant() retrieves an instant; choosing a ZoneId is another necessary decision for showing the civil date. Migrating a local date recorded as a timestamp requires understanding which zone created it, rather than merely changing the class name.

Verification and transfer

Now imagine a booking at 02:30 on a night when the clocks change. The program must ask whether that time exists and, if it corresponds to two instants, which one the user wants. A simple conversion choosing the API's default behavior may be acceptable for an informational screen, but not for a booking on which payment depends. The problem indicates the type and the necessary check.

Then suppose three invoice lines have fractional taxes. Calculating tax on each line and adding already rounded amounts may produce a different result from adding taxable bases and rounding only once. Readers must write the domain rule, apply it at a recognizable point in code and construct an example where the two results diverge. This variant checks that they have done more than learn to type setScale(2, ...).

Essential references. The Java 25 ZoneRules documentation defines cases with zero, one and two offsets; it does not decide which one the application should choose. The BigDecimal specification defines representation, scale, division, comparison and rounding; the applicable economic rule must be supplied by the domain.

Try the examples

Running the programs requires JDK 25. Download the individual Java files linked in the chapter or the complete example project, which includes instructions and a launcher script. The explanations also compare expected output: predict it before running the program.

Massimiliano Tarquini · CC BY-NC 4.0

Back to the top ↑