Closing the previous lesson we flagged a debt you have been carrying since module 3: BiblioTech's dates are integers.

Loan stores an int loanDay and an int dueDay. FineCalculator subtracts those two integers and calls the result "days late". The dashboard histogram computes the day of the week with loanDay % 7, which works by pure coincidence. Nobody can answer "does this loan fall due in a month?" without arbitrarily deciding whether a month is thirty days or thirty-one. The CSV exports numbers that mean absolutely nothing outside BiblioTech. And the file timestamps, which in 07-06 you displayed as FileTime without being able to do anything with them, are still there waiting.

All of that ends in this lesson.

And it is worth knowing why it arrived so late: for almost twenty years, Java had no decent date API. java.util.Date (1996) and java.util.Calendar (1997) were two of the most famous design mistakes in the standard library, to the point that the recommended practice in the enterprise world was not to use them and to reach for an external library, Joda-Time. In 2014, Java 8 incorporated java.time, designed by the author of Joda-Time himself (Stephen Colebourne) on the lessons learned, and standardised as JSR-310.

The result is an API that does well what the old one did badly: it is immutable, thread-safe, fluent and explicit about whether there is a time zone or not. It is worth learning calmly, because dates are the domain where the subtlest bugs are made: daylight saving time, leap years, time zones, months of variable length and users in different zones.

By the end, Loan will have LocalDate loanDate and LocalDate dueDate, fines will be computed with ChronoUnit.DAYS.between, notices will land on a working day thanks to TemporalAdjusters, and the CSV will carry ISO-8601 dates that any system in the world understands.

Contents

  1. Why the problem existed: Date and Calendar
  2. The classic bug: a shared SimpleDateFormat
  3. The design principles of java.time
  4. The core classes and how to choose
  5. Instant versus LocalDateTime: the golden rule
  6. Creation: now, of, parse
  7. Querying: getting parts and properties
  8. Manipulation: methods that return copies
  9. Comparison
  10. Duration versus Period
  11. ChronoUnit.between
  12. Temporal adjusters: TemporalAdjusters
  13. Time zones: ZoneId and ZoneOffset
  14. Daylight saving time and its two dangerous cases
  15. Formatting and parsing with DateTimeFormatter
  16. Locale: month and day names in English
  17. Interoperability with the old API and with FileTime
  18. Clock: the injectable time source
  19. BiblioTech: the complete migration
  20. Common Mistakes and Tips
  21. Exercises

  1. Why the problem existed: Date and Calendar

To appreciate java.time it helps to see what it escapes from. This code uses the old API:

import java.util.Calendar;
import java.util.Date;

public class LegacyApi {

    public static void main(String[] args) {

        // Create 15 March 2026
        Calendar c = Calendar.getInstance();
        c.set(2026, 2, 15);          // 2 is MARCH! Months start at 0
        Date date = c.getTime();

        System.out.println(date);

        // Add 30 days: it MUTATES the object
        c.add(Calendar.DAY_OF_MONTH, 30);
        System.out.println(c.getTime());

        // And the original object has changed too, because it is the same one
        Date anotherReference = date;
        System.out.println(anotherReference);
    }
}

The flaws, one by one:

Flaw Consequence
Months from 0 c.set(2026, 2, 15) is March, not February. A one-month error in production
Years from 1900 in Date new Date(126, 2, 15) is 2026. Absurd
Mutable Passing a Date to a method may give it back modified
Not thread-safe Shared Calendar and SimpleDateFormat corrupt data
No separation of concepts A Date is not a date: it is an instant. There is no way to express "15 March" with no time and no zone
Confusing API Date has methods deprecated since Java 1.1 that are still there
Date does not represent a date Internally it is a long of milliseconds since 1970 UTC
Poor arithmetic Adding a month requires Calendar, and the result depends on the object's state

A direct comparison:

Task Old API java.time
Today new Date() LocalDate.now()
15 March 2026 cal.set(2026, 2, 15) LocalDate.of(2026, 3, 15)
Add 30 days cal.add(Calendar.DAY_OF_MONTH, 30) (mutates) date.plusDays(30) (returns a copy)
Is it earlier? d1.before(d2) d1.isBefore(d2)
Days between two dates Manual arithmetic with milliseconds ChronoUnit.DAYS.between(d1, d2)
Format new SimpleDateFormat("dd/MM/yyyy") (unsafe) DateTimeFormatter.ofPattern("dd/MM/yyyy") (safe)
Time only Impossible without tricks LocalTime.of(9, 30)
Month and year only Impossible YearMonth.of(2026, 3)

  1. The classic bug: a shared SimpleDateFormat

This one deserves its own section because it is, literally, one of the most frequent bugs in the history of enterprise Java.

package com.nexussoftware.bibliotech;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.*;

public class SimpleDateFormatBug {

    // It looks reasonable: a reusable formatter, so as not to create one per call
    private static final SimpleDateFormat FORMAT = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss");

    public static void main(String[] args) throws Exception {

        ExecutorService pool = Executors.newFixedThreadPool(10);
        Date date = new Date();

        for (int i = 0; i < 20; i++) {
            pool.submit(() -> {
                try {
                    System.out.println(Thread.currentThread().getName()
                            + " -> " + FORMAT.format(date));
                } catch (Exception e) {
                    System.out.println("EXCEPTION: " + e);
                }
            });
        }
        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);
    }
}

Output (different on every run):

pool-1-thread-1 -> 05/08/2026 14:32:07
pool-1-thread-3 -> 05/08/2026 14:32:07
pool-1-thread-2 -> 05/08/2026 04:32:07     <-- wrong time
pool-1-thread-5 -> 05/08/2020 14:32:07     <-- wrong year
EXCEPTION: java.lang.NumberFormatException: multiple points
pool-1-thread-4 -> 05/08/2026 14:32:07

Why it happens: SimpleDateFormat keeps mutable internal state (a Calendar and a buffer) while it formats. If two threads format at the same time, they trample that state. The result is not a clear exception but silently incorrect data, which is infinitely worse.

And it is a treacherous bug because:

  • It does not fail in development, where a single thread formats at a time.
  • It fails under load, in production, intermittently.
  • It produces bad data, not errors: a date with the wrong year is stored in the database and nobody notices.
  • It looks like good practice. Declaring the formatter as static final is exactly what you would do to avoid creating unnecessary objects.

The solution with the old API was to create one per call (wasting memory) or to use ThreadLocal (which introduces another problem, as you will see in 10-07). With java.time:

// DateTimeFormatter is IMMUTABLE and THREAD-SAFE.
// Sharing it as a static final constant is the RIGHT thing to do.
private static final DateTimeFormatter FORMAT =
        DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss");

With no risk at all, with any number of threads.

  1. The design principles of java.time

Four decisions explain the whole API:

1. Immutability. No java.time object ever changes. Every "modification" method returns a new object:

LocalDate today = LocalDate.of(2026, 8, 5);
LocalDate future = today.plusDays(30);

System.out.println(today);    // 2026-08-05   unchanged
System.out.println(future);   // 2026-09-04

The second property follows from that automatically:

2. Thread safety. An immutable object can be shared between any number of threads with no synchronisation (08-04). LocalDate, DateTimeFormatter, ZoneId: all safe.

3. Fluent API. Methods chain because each one returns a new object:

LocalDateTime dueDate = LocalDateTime.now()
        .plusDays(21)
        .withHour(18)
        .withMinute(0)
        .withSecond(0)
        .withNano(0);

4. Explicit about the time zone. This is the most important decision and the one that prevents the most problems. In the old API, a Date was always a UTC instant displayed in the JVM's default zone, and it was impossible to say "15 March" without dragging along a time and a zone. In java.time, the class name tells you exactly what you are carrying:

Prefix Meaning
Local... No time zone. It is a "wall calendar" date or time
Zoned... / Offset... With a time zone or with an offset from UTC
Instant A point on the timeline, with no calendar and no zone

You can never get confused, because the type declares it.

  1. The core classes and how to choose

graph TD
    A["What do you need to represent?"] --> B{"Does the time<br/>zone matter?"}
    B -->|"No: calendar date"| C{"Date, time<br/>or both?"}
    C -->|"Date only"| D["LocalDate<br/>2026-08-05"]
    C -->|"Time only"| E["LocalTime<br/>09:30"]
    C -->|"Both"| F["LocalDateTime<br/>2026-08-05T09:30"]
    B -->|"Yes: a real moment"| G{"For a person<br/>or for a machine?"}
    G -->|"Person: display"| H["ZonedDateTime<br/>with DST rules"]
    G -->|"Machine: record"| I["Instant<br/>UTC timestamp"]
    G -->|"Exchange with a fixed offset"| J["OffsetDateTime<br/>+02:00"]
Class Represents Example Use case in BiblioTech
LocalDate A date with no time and no zone 2026-08-05 Loan date, due date, birthday
LocalTime A time with no date and no zone 09:30:00 The library's opening time
LocalDateTime Date and time with no zone 2026-08-05T09:30 Room booking (interpreted in the local zone)
ZonedDateTime Date, time and full zone 2026-08-05T09:30+02:00[Europe/Madrid] A scheduled notice that must respect daylight saving
OffsetDateTime Date, time and a fixed offset 2026-08-05T09:30+02:00 Exchange with APIs, database columns
Instant A point on the timeline (UTC) 2026-08-05T07:30:00Z Log entry, auditing, measurement
Year A year 2026 Yearly statistics
YearMonth Year and month 2026-08 Monthly loan report
MonthDay Month and day, no year --12-25 Holidays that repeat every year
DayOfWeek Day of the week (enum) WEDNESDAY Working days
Month Month (enum) AUGUST Statistics by month
Duration An amount of time (machine) PT48H Length of a session
Period An amount of time (human) P21D Loan term

How to choose, in three questions:

  1. Is there only a date, with no time?LocalDate. That is the case for most business fields: due dates, sign-up dates, terms.
  2. Is it a moment that has to be recorded or compared across systems?Instant.
  3. Does it have to be shown to a user in their zone, or scheduled respecting daylight saving?ZonedDateTime.

Year, YearMonth and MonthDay are small classes that get forgotten and are very useful:

YearMonth august = YearMonth.of(2026, 8);
System.out.println(august.lengthOfMonth());        // 31
System.out.println(august.atDay(15));              // 2026-08-15
System.out.println(august.atEndOfMonth());         // 2026-08-31

MonthDay christmas = MonthDay.of(12, 25);
System.out.println(christmas.atYear(2026));        // 2026-12-25

Year year = Year.of(2026);
System.out.println(year.isLeap());                 // false
System.out.println(year.length());                 // 365

YearMonth is exactly the right type for "the August 2026 report", and it avoids the hack of storing day 1 in a LocalDate and remembering to ignore it.

  1. Instant versus LocalDateTime: the golden rule

This distinction causes more bugs than any other, and it deserves to be understood properly.

LocalDateTime does not represent a concrete moment. 2026-08-05T09:30 identifies no point on the timeline: in Madrid it happens at one time, in Tokyo at another, and in New York at another. It is a wall calendar: "when the clock reads 9:30 on 5 August, wherever you happen to be".

Instant does represent a concrete moment. 2026-08-05T07:30:00Z is a unique, unambiguous point in the history of the universe. Everybody agrees on when it happened; they only disagree about what their wall clock read.

LocalDateTime local = LocalDateTime.of(2026, 8, 5, 9, 30);

// The SAME LocalDateTime in two zones is two different INSTANTS
ZonedDateTime inMadrid = local.atZone(ZoneId.of("Europe/Madrid"));
ZonedDateTime inTokyo  = local.atZone(ZoneId.of("Asia/Tokyo"));

System.out.println(inMadrid);              // 2026-08-05T09:30+02:00[Europe/Madrid]
System.out.println(inTokyo);               // 2026-08-05T09:30+09:00[Asia/Tokyo]

System.out.println(inMadrid.toInstant());  // 2026-08-05T07:30:00Z
System.out.println(inTokyo.toInstant());   // 2026-08-05T00:30:00Z

// Seven hours apart between two "same" times
System.out.println(Duration.between(inTokyo.toInstant(), inMadrid.toInstant()));  // PT7H

The golden rule

Store Instant (or OffsetDateTime in UTC). Display ZonedDateTime. Store absolute moments and convert them to the user's zone only at the edge of the application, when presenting them.

Situation Correct type Why
A "created_at" column in a database Instant / TIMESTAMP WITH TIME ZONE Unambiguous, comparable, sortable
A timestamp in a log Instant Correlatable across servers in different zones
Measuring how long something takes Instant + Duration No daylight saving jumps
The due date of a loan LocalDate It is a calendar date, not an instant
The library's opening time LocalTime 9:00 is 9:00 in the library's zone
Showing "created on ..." to the user ZonedDateTime in their zone Everyone sees it in their local time
Scheduling a notice for 8:00 on Monday ZonedDateTime It must respect the clock change

The classic mistake is storing LocalDateTime in the database. It works perfectly while the server, the database and every user are in the same zone. The day it is deployed on a UTC server or a user logs in from another country, the times shift and the data already stored is unrecoverable, because nobody knows which zone it was written in.

  1. Creation: now, of, parse

The three routes, uniform across every class:

// --- now(): the current moment ---
LocalDate today = LocalDate.now();
LocalTime now = LocalTime.now();
LocalDateTime fullNow = LocalDateTime.now();
ZonedDateTime inMadrid = ZonedDateTime.now(ZoneId.of("Europe/Madrid"));
Instant instant = Instant.now();

// --- of(): build with explicit values ---
LocalDate date = LocalDate.of(2026, 8, 5);
LocalDate date2 = LocalDate.of(2026, Month.AUGUST, 5);       // with the enum: more readable
LocalTime time = LocalTime.of(9, 30);
LocalTime timeWithSeconds = LocalTime.of(9, 30, 45);
LocalDateTime full = LocalDateTime.of(2026, 8, 5, 9, 30);
LocalDateTime combined = LocalDateTime.of(date, time);        // combining

// --- parse(): from ISO-8601 text ---
LocalDate p1 = LocalDate.parse("2026-08-05");
LocalTime p2 = LocalTime.parse("09:30:00");
LocalDateTime p3 = LocalDateTime.parse("2026-08-05T09:30:00");
ZonedDateTime p4 = ZonedDateTime.parse("2026-08-05T09:30:00+02:00[Europe/Madrid]");
Instant p5 = Instant.parse("2026-08-05T07:30:00Z");

// --- Conversions between types ---
LocalDateTime fromDate = date.atTime(9, 30);
LocalDateTime fromDate2 = date.atStartOfDay();               // 2026-08-05T00:00
LocalDate dateOnly = full.toLocalDate();
LocalTime timeOnly = full.toLocalTime();
ZonedDateTime withZone = full.atZone(ZoneId.of("Europe/Madrid"));
Instant toInstant = withZone.toInstant();

of validates. An impossible value throws DateTimeException immediately; it does not produce a strange date:

LocalDate.of(2026, 2, 30);
Exception in thread "main" java.time.DateTimeException:
    Invalid date 'FEBRUARY 30'

Compare it with Calendar, which is "lenient" by default and silently turned 30 February into 2 March.

Months start at 1. LocalDate.of(2026, 8, 5) is August. And for maximum clarity there is the enum Month:

LocalDate.of(2026, Month.AUGUST, 5);

  1. Querying: getting parts and properties

LocalDate date = LocalDate.of(2026, 8, 5);

System.out.println(date.getYear());             // 2026
System.out.println(date.getMonthValue());       // 8
System.out.println(date.getMonth());            // AUGUST (enum Month)
System.out.println(date.getDayOfMonth());       // 5
System.out.println(date.getDayOfWeek());        // WEDNESDAY (enum DayOfWeek)
System.out.println(date.getDayOfYear());        // 217

System.out.println(date.lengthOfMonth());       // 31
System.out.println(date.lengthOfYear());        // 365
System.out.println(date.isLeapYear());          // false

LocalTime time = LocalTime.of(9, 30, 45, 123_000_000);
System.out.println(time.getHour());             // 9
System.out.println(time.getMinute());           // 30
System.out.println(time.getSecond());           // 45
System.out.println(time.getNano());             // 123000000

Month and DayOfWeek are enums (04-07), which opens up possibilities:

DayOfWeek day = date.getDayOfWeek();

// switch expression (02-03, and you will see it modernised in 10-06)
String kind = switch (day) {
    case SATURDAY, SUNDAY -> "weekend";
    default -> "working day";
};

// Localised name
System.out.println(day.getDisplayName(TextStyle.FULL, Locale.forLanguageTag("en-GB")));  // Wednesday

// Day arithmetic
System.out.println(day.plus(3));                // SATURDAY
System.out.println(day.getValue());             // 3 (1=Monday ... 7=Sunday, ISO)

Month month = date.getMonth();
System.out.println(month.length(false));        // 31 (false = not a leap year)
System.out.println(month.getDisplayName(TextStyle.FULL, Locale.forLanguageTag("en-GB")));  // August

And for generic cases, get(TemporalField):

System.out.println(date.get(ChronoField.DAY_OF_WEEK));        // 3
System.out.println(date.get(ChronoField.ALIGNED_WEEK_OF_YEAR)); // 31

// ISO week number (the one European systems use)
System.out.println(date.get(WeekFields.ISO.weekOfWeekBasedYear()));  // 32

  1. Manipulation: methods that return copies

Three families of methods, all returning new objects:

Prefix What it does Example
plusX Adds plusDays(30), plusMonths(1), plusYears(1)
minusX Subtracts minusWeeks(2), minusHours(3)
withX Replaces one component withDayOfMonth(1), withYear(2027)
LocalDate today = LocalDate.of(2026, 8, 5);

System.out.println(today.plusDays(21));         // 2026-08-26
System.out.println(today.plusWeeks(3));         // 2026-08-26
System.out.println(today.plusMonths(1));        // 2026-09-05
System.out.println(today.minusYears(1));        // 2025-08-05

System.out.println(today.withDayOfMonth(1));    // 2026-08-01
System.out.println(today.withMonth(12));        // 2026-12-05
System.out.println(today.withYear(2030));       // 2030-08-05

// Fluent chaining
LocalDateTime dueDate = LocalDateTime.of(2026, 8, 5, 14, 23, 51)
        .plusDays(21)
        .withHour(23)
        .withMinute(59)
        .withSecond(59);
System.out.println(dueDate);                    // 2026-08-26T23:59:59

The classic mistake: ignoring the returned value

LocalDate dueDate = LocalDate.of(2026, 8, 5);
dueDate.plusDays(21);                            // DOES NOTHING!
System.out.println(dueDate);                     // 2026-08-05

The objects are immutable: plusDays cannot change dueDate. It returns a new date which, if you do not assign it, is discarded. It is exactly the same mistake as with String:

String s = "hello";
s.toUpperCase();                                 // does nothing
System.out.println(s);                           // hello

s = s.toUpperCase();                             // this way it works

IDEs warn about this ("result of method is ignored"), and it is worth listening to them.

Month arithmetic is not trivial

LocalDate endOfJanuary = LocalDate.of(2026, 1, 31);
System.out.println(endOfJanuary.plusMonths(1));  // 2026-02-28  not the 31st!
System.out.println(endOfJanuary.plusMonths(3));  // 2026-04-30  not the 31st!

// And it is not reversible
System.out.println(endOfJanuary.plusMonths(1).minusMonths(1));  // 2026-01-28

java.time adjusts to the last valid day of the target month when the day does not exist. It is the reasonable solution, but it means that adding and subtracting a month does not always take you back to the starting point. If your business logic depends on that, make the criterion explicit in the code; do not take it for granted.

Leap years do the same:

LocalDate leapDay = LocalDate.of(2024, 2, 29);
System.out.println(leapDay.plusYears(1));        // 2025-02-28

  1. Comparison

LocalDate a = LocalDate.of(2026, 8, 5);
LocalDate b = LocalDate.of(2026, 8, 26);

System.out.println(a.isBefore(b));               // true
System.out.println(a.isAfter(b));                // false
System.out.println(a.isEqual(b));                // false
System.out.println(a.equals(b));                 // false

// They are also Comparable (05-09), so they sort
List<LocalDate> dates = new ArrayList<>(List.of(b, a));
Collections.sort(dates);
System.out.println(dates);                       // [2026-08-05, 2026-08-26]

// And with streams (10-04)
Optional<LocalDate> mostRecent = dates.stream().max(Comparator.naturalOrder());

isEqual versus equals: for LocalDate they are equivalent. The difference matters in ZonedDateTime and ChronoLocalDate, where isEqual compares the instant and equals compares every field including the zone:

ZonedDateTime madrid = ZonedDateTime.of(2026, 8, 5, 9, 30, 0, 0, ZoneId.of("Europe/Madrid"));
ZonedDateTime london = madrid.withZoneSameInstant(ZoneId.of("Europe/London"));

System.out.println(madrid.isEqual(london));      // true: it is the SAME instant
System.out.println(madrid.equals(london));       // false: different zone and local time

Rule: to know whether two moments are the same moment, use isEqual or compare the Instants. equals on ZonedDateTime is almost never what you want.

  1. Duration versus Period

Two classes to express "an amount of time", and the difference is conceptual, not technical.

Duration Period
Concept Machine time Human time
Units Seconds and nanoseconds Years, months and days
Applies to Instant, LocalTime, LocalDateTime LocalDate, LocalDateTime
"One day" is Exactly 86,400 seconds A calendar day (it can have 23 or 25 hours)
ISO format PT48H30M P1Y2M3D
// DURATION: machine time
Duration d1 = Duration.ofHours(48);
Duration d2 = Duration.ofMinutes(90);
Duration d3 = Duration.ofSeconds(3600);
Duration d4 = Duration.between(Instant.now(), Instant.now().plusSeconds(7200));

System.out.println(d1);                          // PT48H
System.out.println(d1.toDays());                 // 2
System.out.println(d1.toHours());                // 48
System.out.println(d2.toMinutes());              // 90
System.out.println(d2.toHoursPart() + "h " + d2.toMinutesPart() + "m");   // 1h 30m

// PERIOD: human time
Period p1 = Period.ofDays(21);
Period p2 = Period.of(1, 2, 3);                  // 1 year, 2 months, 3 days
Period p3 = Period.between(LocalDate.of(2026, 1, 15), LocalDate.of(2026, 8, 5));

System.out.println(p1);                          // P21D
System.out.println(p3);                          // P6M21D
System.out.printf("%d years, %d months, %d days%n",
        p3.getYears(), p3.getMonths(), p3.getDays());   // 0 years, 6 months, 21 days

Why "a month" is not a fixed number of days

LocalDate january = LocalDate.of(2026, 1, 1);
LocalDate february = LocalDate.of(2026, 2, 1);

System.out.println(ChronoUnit.DAYS.between(january, february));   // 31

LocalDate february2 = LocalDate.of(2026, 2, 1);
LocalDate march = LocalDate.of(2026, 3, 1);
System.out.println(ChronoUnit.DAYS.between(february2, march));    // 28

// And in a leap year
System.out.println(ChronoUnit.DAYS.between(
        LocalDate.of(2024, 2, 1), LocalDate.of(2024, 3, 1)));     // 29

A month is 28, 29, 30 or 31 days. That is why Period.ofMonths(1) cannot be converted to days without a reference date:

Period oneMonth = Period.ofMonths(1);
System.out.println(oneMonth.getDays());          // 0, not 30: there are no days in this period

// To know how many days it is, you need a concrete date
LocalDate from = LocalDate.of(2026, 1, 31);
LocalDate to = from.plus(oneMonth);
System.out.println(ChronoUnit.DAYS.between(from, to));            // 28

The difference with daylight saving time

This is where Duration and Period really diverge:

ZoneId madrid = ZoneId.of("Europe/Madrid");

// The night the clocks go forward: 29 March 2026
ZonedDateTime before = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, madrid);

ZonedDateTime withPeriod = before.plus(Period.ofDays(1));
ZonedDateTime withDuration = before.plus(Duration.ofDays(1));

System.out.println("Origin:        " + before);
System.out.println("+ Period 1D:   " + withPeriod);
System.out.println("+ Duration 1D: " + withDuration);
Origin:        2026-03-28T12:00+01:00[Europe/Madrid]
+ Period 1D:   2026-03-29T12:00+02:00[Europe/Madrid]
+ Duration 1D: 2026-03-29T13:00+02:00[Europe/Madrid]

One hour of difference. Period.ofDays(1) says "the same moment of the following day" and respects the calendar: it is still midday. Duration.ofDays(1) says "86,400 seconds later", and since that night only had 23 hours, the result is one in the afternoon.

Which to use:

  • "Return the book within 21 days"Period. It is a calendar term; if the clocks change, it is still the same day at the same time.
  • "The session expires in 30 minutes"Duration. It is real elapsed time.
  • "Birthday in a year"Period.
  • "The process took 4.2 seconds"Duration.

  1. ChronoUnit.between

For "how many units are there between two dates", ChronoUnit is the direct tool:

LocalDate loan = LocalDate.of(2026, 8, 5);
LocalDate dueDate = LocalDate.of(2026, 8, 26);
LocalDate today = LocalDate.of(2026, 9, 3);

System.out.println(ChronoUnit.DAYS.between(loan, dueDate));          // 21
System.out.println(ChronoUnit.DAYS.between(dueDate, today));         // 8  (late)
System.out.println(ChronoUnit.WEEKS.between(loan, today));           // 4
System.out.println(ChronoUnit.MONTHS.between(loan, today));          // 0
System.out.println(ChronoUnit.YEARS.between(loan, today));           // 0

The truncation surprises people. MONTHS.between from 5 August to 3 September gives 0, not 1: a full month has not passed. between always truncates towards zero; it never rounds.

System.out.println(ChronoUnit.MONTHS.between(
        LocalDate.of(2026, 8, 5), LocalDate.of(2026, 9, 4)));     // 0
System.out.println(ChronoUnit.MONTHS.between(
        LocalDate.of(2026, 8, 5), LocalDate.of(2026, 9, 5)));     // 1

And the sign indicates the direction:

System.out.println(ChronoUnit.DAYS.between(today, loan));         // -29 (negative)

With hours and moments:

Instant start = Instant.now();
Instant end = start.plusSeconds(9_045);

System.out.println(ChronoUnit.HOURS.between(start, end));         // 2
System.out.println(ChronoUnit.MINUTES.between(start, end));       // 150
System.out.println(ChronoUnit.SECONDS.between(start, end));       // 9045

ChronoUnit.between versus Period.between:

LocalDate a = LocalDate.of(2026, 1, 15);
LocalDate b = LocalDate.of(2027, 3, 20);

// ChronoUnit: ONE unit, total value
System.out.println(ChronoUnit.DAYS.between(a, b));      // 429
System.out.println(ChronoUnit.MONTHS.between(a, b));    // 14

// Period: BROKEN DOWN into years, months and days
Period p = Period.between(a, b);
System.out.printf("%d years, %d months, %d days%n",
        p.getYears(), p.getMonths(), p.getDays());      // 1 years, 2 months, 5 days

Use ChronoUnit to compute ("it is 8 days late") and Period to present ("1 year, 2 months and 5 days ago").

  1. Temporal adjusters: TemporalAdjusters

A TemporalAdjuster is an operation that transforms a date according to a rule. TemporalAdjusters brings the most useful ones ready-made, and they solve computations that are awkward and error-prone by hand.

LocalDate today = LocalDate.of(2026, 8, 5);      // a Wednesday

System.out.println(today.with(TemporalAdjusters.firstDayOfMonth()));      // 2026-08-01
System.out.println(today.with(TemporalAdjusters.lastDayOfMonth()));       // 2026-08-31
System.out.println(today.with(TemporalAdjusters.firstDayOfNextMonth()));  // 2026-09-01
System.out.println(today.with(TemporalAdjusters.firstDayOfYear()));       // 2026-01-01
System.out.println(today.with(TemporalAdjusters.lastDayOfYear()));        // 2026-12-31

// Days of the week
System.out.println(today.with(TemporalAdjusters.next(DayOfWeek.MONDAY)));         // 2026-08-10
System.out.println(today.with(TemporalAdjusters.nextOrSame(DayOfWeek.WEDNESDAY))); // 2026-08-05
System.out.println(today.with(TemporalAdjusters.previous(DayOfWeek.FRIDAY)));     // 2026-07-31

// The first and last specific day of the month
System.out.println(today.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY))); // 2026-08-03
System.out.println(today.with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY)));  // 2026-08-28
System.out.println(today.with(TemporalAdjusters.dayOfWeekInMonth(2, DayOfWeek.TUESDAY))); // 2026-08-11

The difference between next and nextOrSame matters. next(WEDNESDAY) on a Wednesday returns the following Wednesday; nextOrSame returns the same day. Choosing wrong shifts every due date by a week.

Custom adjusters: working days in BiblioTech

When the rule comes from the business, you write your own adjuster. TemporalAdjuster is a functional interface (04-06), so it can be a lambda:

package com.nexussoftware.bibliotech.service;

import java.time.*;
import java.time.temporal.*;
import java.util.Set;

/**
 * Nexus Software's calendar adjusters.
 */
public final class BiblioTechAdjusters {

    private BiblioTechAdjusters() { }

    /** The library's fixed holidays (MonthDay: they repeat every year). */
    private static final Set<MonthDay> HOLIDAYS = Set.of(
            MonthDay.of(1, 1),     // New Year's Day
            MonthDay.of(1, 6),     // Epiphany
            MonthDay.of(5, 1),     // Labour Day
            MonthDay.of(8, 15),    // Assumption
            MonthDay.of(10, 12),   // National Day
            MonthDay.of(11, 1),    // All Saints
            MonthDay.of(12, 6),    // Constitution Day
            MonthDay.of(12, 8),    // Immaculate Conception
            MonthDay.of(12, 25));  // Christmas

    public static boolean isHoliday(LocalDate date) {
        return HOLIDAYS.contains(MonthDay.from(date));
    }

    public static boolean isWorkingDay(LocalDate date) {
        DayOfWeek day = date.getDayOfWeek();
        return day != DayOfWeek.SATURDAY
            && day != DayOfWeek.SUNDAY
            && !isHoliday(date);
    }

    /**
     * Adjuster: moves the date to the next working day if it is not one.
     * TemporalAdjuster is functional: it can be written as a lambda.
     */
    public static TemporalAdjuster nextWorkingDay() {
        return temporal -> {
            LocalDate date = LocalDate.from(temporal);
            while (!isWorkingDay(date)) {
                date = date.plusDays(1);
            }
            return temporal.with(date);
        };
    }

    /** Adds N WORKING days, skipping weekends and holidays. */
    public static TemporalAdjuster plusWorkingDays(int days) {
        return temporal -> {
            LocalDate date = LocalDate.from(temporal);
            int remaining = days;
            while (remaining > 0) {
                date = date.plusDays(1);
                if (isWorkingDay(date)) {
                    remaining--;
                }
            }
            return temporal.with(date);
        };
    }

    /** Counts working days between two dates (for fair fines). */
    public static long workingDaysBetween(LocalDate from, LocalDate to) {
        return from.datesUntil(to)                      // Stream<LocalDate> (Java 9)
                   .filter(BiblioTechAdjusters::isWorkingDay)
                   .count();
    }
}
LocalDate friday = LocalDate.of(2026, 8, 14);

System.out.println("Friday the 14th:        " + friday);
System.out.println("+ 1 calendar day:       " + friday.plusDays(1));
System.out.println("Adjusted to working:    "
        + friday.plusDays(1).with(BiblioTechAdjusters.nextWorkingDay()));
System.out.println("+ 5 working days:       "
        + friday.with(BiblioTechAdjusters.plusWorkingDays(5)));
System.out.println("Working days in August: "
        + BiblioTechAdjusters.workingDaysBetween(
                LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)));
Friday the 14th:        2026-08-14
+ 1 calendar day:       2026-08-15
Adjusted to working:    2026-08-17
+ 5 working days:       2026-08-24
Working days in August: 20

The 15th of August is a Saturday and a holiday, so the adjuster jumps to Monday the 17th. And notice datesUntil, added in Java 9: it returns a Stream<LocalDate> with every date in the range, which connects directly to 10-04.

  1. Time zones: ZoneId and ZoneOffset

ZoneId identifies a zone with its historical and future rules: Europe/Madrid knows when daylight saving starts and ends each year, and it knows that in 1975 the rules were different.

ZoneOffset is only a fixed offset from UTC: +02:00. It knows nothing about rules.

// ZoneId: IANA identifier "Region/City"
ZoneId madrid = ZoneId.of("Europe/Madrid");
ZoneId tokyo = ZoneId.of("Asia/Tokyo");
ZoneId newYork = ZoneId.of("America/New_York");
ZoneId utc = ZoneId.of("UTC");
ZoneId byDefault = ZoneId.systemDefault();

// ZoneOffset: fixed offset
ZoneOffset plusTwo = ZoneOffset.ofHours(2);
ZoneOffset zero = ZoneOffset.UTC;

// How many zones there are
System.out.println(ZoneId.getAvailableZoneIds().size());    // about 600

The IANA database (also called the tz database or Olson database) is the worldwide registry of time zones, maintained collaboratively and updated several times a year because governments change the rules. Java includes it and it is updated with JDK releases and with the tzupdater tool.

Always use Region/City identifiers. The three-letter abbreviations (CST, IST, EST) are obsolete and ambiguous: CST can be Central Standard Time (US), China Standard Time or Cuba Standard Time.

Converting between zones

ZonedDateTime inMadrid = ZonedDateTime.of(2026, 8, 5, 9, 30, 0, 0, ZoneId.of("Europe/Madrid"));

// SAME instant, another zone: the local time CHANGES
ZonedDateTime inTokyo = inMadrid.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));

// SAME local time, another zone: the instant CHANGES
ZonedDateTime anotherInstant = inMadrid.withZoneSameLocal(ZoneId.of("Asia/Tokyo"));

System.out.println("Madrid:              " + inMadrid);
System.out.println("Same instant:        " + inTokyo);
System.out.println("Same local time:     " + anotherInstant);
System.out.println("Same instant?        " + inMadrid.isEqual(inTokyo));
System.out.println("Same instant?        " + inMadrid.isEqual(anotherInstant));
Madrid:              2026-08-05T09:30+02:00[Europe/Madrid]
Same instant:        2026-08-05T16:30+09:00[Asia/Tokyo]
Same local time:     2026-08-05T09:30+09:00[Asia/Tokyo]
Same instant?        true
Same instant?        false

The two methods do opposite things and both are necessary:

  • withZoneSameInstant: "what time was it in Tokyo when it was 9:30 in Madrid?" That is what you use to display a moment to a user in another zone.
  • withZoneSameLocal: "the meeting is at 9:30 Tokyo time". It changes the real moment.

99% of the time you want withZoneSameInstant.

BiblioTech's world clock

package com.nexussoftware.bibliotech.presentation;

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;

public class BiblioTechWorldClock {

    private static final DateTimeFormatter FORMAT =
            DateTimeFormatter.ofPattern("EEEE dd/MM/yyyy HH:mm", Locale.forLanguageTag("en-GB"));

    public static void main(String[] args) {

        Instant now = Instant.now();

        List<String> offices = List.of(
                "Europe/Madrid", "Europe/London", "America/New_York",
                "Asia/Tokyo", "Australia/Sydney", "UTC");

        System.out.println("Nexus Software offices -- instant: " + now);
        System.out.println("-".repeat(62));

        offices.stream()
               .map(ZoneId::of)
               .map(zone -> now.atZone(zone))
               .sorted(java.util.Comparator.comparing(z -> z.getOffset().getTotalSeconds()))
               .forEach(z -> System.out.printf("  %-20s %-32s %s%n",
                       z.getZone(), z.format(FORMAT), z.getOffset()));
    }
}
Nexus Software offices -- instant: 2026-08-05T12:47:31.204Z
--------------------------------------------------------------
  America/New_York     Wednesday 05/08/2026 08:47       -04:00
  UTC                  Wednesday 05/08/2026 12:47       Z
  Europe/London        Wednesday 05/08/2026 13:47       +01:00
  Europe/Madrid        Wednesday 05/08/2026 14:47       +02:00
  Asia/Tokyo           Wednesday 05/08/2026 21:47       +09:00
  Australia/Sydney     Wednesday 05/08/2026 22:47       +10:00

One single Instant, six different wall clocks. That is exactly the golden rule of section 5 in action.

  1. Daylight saving time and its two dangerous cases

The clock change produces two anomalies that break code written without thinking about them. java.time handles them, but you have to know what it does.

Case 1: the time that does not exist (spring forward)

In Spain, in the early hours of the last Sunday in March, at 2:00 the clocks jump to 3:00. 2:30 that night does not exist.

package com.nexussoftware.bibliotech;

import java.time.*;

public class NonExistentTime {

    public static void main(String[] args) {

        ZoneId madrid = ZoneId.of("Europe/Madrid");

        // 29 March 2026: switch to summer time
        LocalDateTime nonExistent = LocalDateTime.of(2026, 3, 29, 2, 30);

        System.out.println("LocalDateTime:  " + nonExistent);

        ZonedDateTime result = nonExistent.atZone(madrid);
        System.out.println("atZone():       " + result);
        System.out.println("Offset:         " + result.getOffset());

        // Check it explicitly
        var rules = madrid.getRules();
        System.out.println("Is it a gap?    " + (rules.getTransition(nonExistent) != null));
        System.out.println("Gap length:     "
                + rules.getTransition(nonExistent).getDuration());
    }
}
LocalDateTime:  2026-03-29T02:30
atZone():       2026-03-29T03:30+02:00[Europe/Madrid]
Offset:         +02:00
Is it a gap?    true
Gap length:     PT1H

atZone does not throw an exception: it shifts the time forward by the size of the gap. 2:30 becomes 3:30.

Practical consequence for BiblioTech: a notice scheduled for 2:30 that night will run at 3:30. If the system schedules nightly tasks, it is best to avoid the 2:00 to 3:00 window in zones with a clock change.

Case 2: the time that happens twice (fall back)

On the last Sunday in October, at 3:00 the clocks go back to 2:00. 2:30 happens twice, with different offsets.

package com.nexussoftware.bibliotech;

import java.time.*;

public class DuplicatedTime {

    public static void main(String[] args) {

        ZoneId madrid = ZoneId.of("Europe/Madrid");

        // 25 October 2026: back to winter time
        LocalDateTime ambiguous = LocalDateTime.of(2026, 10, 25, 2, 30);

        System.out.println("LocalDateTime:      " + ambiguous);

        // By default it picks the FIRST occurrence (summer, +02:00)
        ZonedDateTime byDefault = ambiguous.atZone(madrid);
        System.out.println("atZone() by default:  " + byDefault);

        // Explicit choice
        ZonedDateTime first = ambiguous.atZone(madrid).withEarlierOffsetAtOverlap();
        ZonedDateTime second = ambiguous.atZone(madrid).withLaterOffsetAtOverlap();

        System.out.println("First (summer):       " + first);
        System.out.println("Second (winter):      " + second);
        System.out.println("Real difference:      "
                + Duration.between(first.toInstant(), second.toInstant()));

        var rules = madrid.getRules();
        System.out.println("Valid offsets:        " + rules.getValidOffsets(ambiguous));
    }
}
LocalDateTime:      2026-10-25T02:30
atZone() by default:  2026-10-25T02:30+02:00[Europe/Madrid]
First (summer):       2026-10-25T02:30+02:00[Europe/Madrid]
Second (winter):      2026-10-25T02:30+01:00[Europe/Madrid]
Real difference:      PT1H
Valid offsets:        [+02:00, +01:00]

Two real instants an hour apart, with the same wall clock. getValidOffsets returns two offsets, and that is the unmistakable sign that the time is ambiguous.

Real consequences:

  • A loan recorded at 2:30 that night could look as if it happened before another recorded at 2:15... an hour later.
  • Sorting by LocalDateTime gives the wrong order; sorting by Instant gives the real one.
  • A task scheduled for 2:30 may run twice.

The operational conclusion

Rule Reason
Store Instant, not LocalDateTime Instants are neither ambiguous nor non-existent
Sort and compare by Instant The order of wall clocks can lie
Measure durations with Instant + Duration Subtracting LocalDateTimes includes the clock jump
Avoid scheduling tasks between 2:00 and 3:00 That is the window of the two anomalies
Use Period for calendar terms It survives the clock change without shifting

And the final reason BiblioTech will use LocalDate for loan dates: calendar dates do not have this problem. The 5th of August is the 5th of August; there are no missing or repeated hours.

  1. Formatting and parsing with DateTimeFormatter

DateTimeFormatter replaces SimpleDateFormat and is immutable and thread-safe.

Predefined formats

LocalDate date = LocalDate.of(2026, 8, 5);
LocalDateTime full = LocalDateTime.of(2026, 8, 5, 9, 30, 45);
ZonedDateTime withZone = full.atZone(ZoneId.of("Europe/Madrid"));

System.out.println(date.format(DateTimeFormatter.ISO_DATE));            // 2026-08-05
System.out.println(full.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); // 2026-08-05T09:30:45
System.out.println(withZone.format(DateTimeFormatter.ISO_ZONED_DATE_TIME));
// 2026-08-05T09:30:45+02:00[Europe/Madrid]
System.out.println(withZone.format(DateTimeFormatter.ISO_INSTANT));     // 2026-08-05T07:30:45Z
System.out.println(date.format(DateTimeFormatter.BASIC_ISO_DATE));      // 20260805

ISO-8601 is the interchange format. 2026-08-05 is unambiguous worldwide; 05/08/2026 is 5 August in Britain and 8 May in the United States. To persist and exchange data, always ISO. Localised formats are only for display.

And the toString() of every java.time class already produces ISO-8601, which makes serialising trivial.

Custom patterns

DateTimeFormatter british = DateTimeFormatter.ofPattern("dd/MM/yyyy");
DateTimeFormatter withTime = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss");
DateTimeFormatter readable = DateTimeFormatter.ofPattern("EEEE, d MMMM yyyy",
                                                         Locale.forLanguageTag("en-GB"));

System.out.println(date.format(british));        // 05/08/2026
System.out.println(full.format(withTime));       // 05/08/2026 09:30:45
System.out.println(date.format(readable));       // Wednesday, 5 August 2026

The most commonly used pattern letters:

Letter Meaning Example
y Year yyyy → 2026, yy → 26
M Month M → 8, MM → 08, MMM → Aug, MMMM → August
d Day of the month d → 5, dd → 05
E Day of the week EEE → Wed, EEEE → Wednesday
H Hour 0-23 HH → 09
h Hour 1-12 hh → 09
m Minute mm → 30
s Second ss → 45
S Fraction of a second SSS → 123
a AM/PM a → am
z Zone name z → CEST
Z Offset Z → +0200
X ISO offset XXX → +02:00
'text' Literal 'at' → at

The classic mistake: confusing MM with mm. MM is the month, mm is minutes. "dd/mm/yyyy" produces 05/30/2026, with the minutes where the month should be.

And the second one: YYYY instead of yyyy. Y is the "week-based year", which differs from the calendar year in the last days of December and the first days of January. Using YYYY produces the famous bug where 31 December 2026 is formatted as 2027. Always use yyyy.

Parsing and DateTimeParseException

DateTimeFormatter format = DateTimeFormatter.ofPattern("dd/MM/yyyy");

LocalDate date = LocalDate.parse("05/08/2026", format);
System.out.println(date);                        // 2026-08-05

// Without a formatter: ISO-8601 is expected
LocalDate iso = LocalDate.parse("2026-08-05");

A failed parse throws DateTimeParseException, which is unchecked (it extends RuntimeException), so the compiler does not force you to catch it:

package com.nexussoftware.bibliotech.presentation;

import java.time.LocalDate;
import java.time.format.*;
import java.util.Optional;

public class DateReader {

    private static final DateTimeFormatter INPUT =
            DateTimeFormatter.ofPattern("dd/MM/yyyy");

    /** Returns Optional instead of throwing: bad user input is expected (10-04). */
    public static Optional<LocalDate> read(String text) {
        if (text == null || text.isBlank()) {
            return Optional.empty();
        }
        try {
            return Optional.of(LocalDate.parse(text.strip(), INPUT));
        } catch (DateTimeParseException e) {
            System.out.printf("  Invalid date: \"%s\" (position %d: %s)%n",
                    e.getParsedString(), e.getErrorIndex(), e.getMessage());
            return Optional.empty();
        }
    }

    public static void main(String[] args) {
        for (String input : new String[] {
                "05/08/2026", "31/02/2026", "2026-08-05", "the fifth of August", "  05/08/2026  " }) {

            System.out.println("Input: \"" + input + "\"");
            read(input).ifPresentOrElse(
                    d  -> System.out.println("  -> " + d + " (" + d.getDayOfWeek() + ")"),
                    () -> System.out.println("  -> discarded"));
        }
    }
}
Input: "05/08/2026"
  -> 2026-08-05 (WEDNESDAY)
Input: "31/02/2026"
  Invalid date: "31/02/2026" (position 0: Text '31/02/2026' could not be parsed:
    Invalid date 'FEBRUARY 31')
  -> discarded
Input: "2026-08-05"
  Invalid date: "2026-08-05" (position 0: Text '2026-08-05' could not be parsed at index 2)
  -> discarded
Input: "the fifth of August"
  Invalid date: "the fifth of August" (position 0: ...)
  -> discarded
Input: "  05/08/2026  "
  -> 2026-08-05 (WEDNESDAY)

DateTimeParseException carries getParsedString() and getErrorIndex(), which allow precise error messages — far better than SimpleDateFormat's generic one.

Building complex formatters

For cases a pattern does not cover, there is DateTimeFormatterBuilder:

DateTimeFormatter lenient = new DateTimeFormatterBuilder()
        .parseCaseInsensitive()                       // accepts "AUG" and "aug"
        .appendPattern("dd/MM/yyyy")
        .optionalStart()                              // the time is optional
        .appendPattern(" HH:mm")
        .optionalEnd()
        .parseDefaulting(ChronoField.HOUR_OF_DAY, 0)  // if missing, midnight
        .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
        .toFormatter(Locale.forLanguageTag("en-GB"));

System.out.println(LocalDateTime.parse("05/08/2026", lenient));           // 2026-08-05T00:00
System.out.println(LocalDateTime.parse("05/08/2026 14:30", lenient));     // 2026-08-05T14:30

A single formatter that accepts both forms. It is exactly what a CSV importer receiving data from several sources needs.

  1. Locale: month and day names in English

Without a Locale, the names come out in the JVM's default language, which on a server is often not the one you expect:

LocalDate date = LocalDate.of(2026, 8, 5);

DateTimeFormatter noLocale = DateTimeFormatter.ofPattern("EEEE d MMMM");
DateTimeFormatter inEnglish = DateTimeFormatter.ofPattern("EEEE d MMMM",
                                                          Locale.forLanguageTag("en-GB"));
DateTimeFormatter inSpanish = DateTimeFormatter.ofPattern("EEEE d 'de' MMMM",
                                                          Locale.forLanguageTag("es-ES"));

System.out.println(date.format(noLocale));       // miércoles 5 agosto  (if the JVM is set to Spanish)
System.out.println(date.format(inEnglish));      // Wednesday 5 August
System.out.println(date.format(inSpanish));      // miércoles 5 de agosto

Never rely on the default Locale in production code. It changes between the development machine and the server, and it produces different output with no warning at all.

There are also predefined localised formats, which respect each country's conventions:

Locale en = Locale.forLanguageTag("en-GB");

System.out.println(date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(en)));
// 05/08/2026
System.out.println(date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(en)));
// 5 Aug 2026
System.out.println(date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(en)));
// 5 August 2026
System.out.println(date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(en)));
// Wednesday 5 August 2026

And TextStyle for individual names:

Locale en = Locale.forLanguageTag("en-GB");

System.out.println(DayOfWeek.WEDNESDAY.getDisplayName(TextStyle.FULL, en));    // Wednesday
System.out.println(DayOfWeek.WEDNESDAY.getDisplayName(TextStyle.SHORT, en));   // Wed
System.out.println(Month.AUGUST.getDisplayName(TextStyle.FULL, en));           // August
System.out.println(Month.AUGUST.getDisplayName(TextStyle.SHORT, en));          // Aug

  1. Interoperability with the old API and with FileTime

In real code you will run into Date, Calendar and FileTime. The conversions are straightforward.

Date and Calendar

// Date -> Instant -> java.time
Date legacyDate = new Date();
Instant instant = legacyDate.toInstant();
LocalDateTime local = instant.atZone(ZoneId.systemDefault()).toLocalDateTime();
LocalDate dateOnly = instant.atZone(ZoneId.systemDefault()).toLocalDate();

// java.time -> Date
Instant backAgain = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant();
Date legacyDate2 = Date.from(backAgain);

// Calendar
Calendar cal = Calendar.getInstance();
ZonedDateTime fromCalendar = ((GregorianCalendar) cal).toZonedDateTime();
GregorianCalendar toCalendar = GregorianCalendar.from(ZonedDateTime.now());

// java.sql
java.sql.Date sqlDate = java.sql.Date.valueOf(LocalDate.now());
LocalDate fromSql = sqlDate.toLocalDate();
java.sql.Timestamp ts = java.sql.Timestamp.valueOf(LocalDateTime.now());
LocalDateTime fromTs = ts.toLocalDateTime();

The universal bridge is Instant. Almost every conversion goes through it, because it is the only type that represents the same thing as a Date (a point on the timeline).

Beware of the asymmetry: a Date is an instant; a LocalDateTime is not. Converting from LocalDateTime to Date requires choosing a zone, and the default choice (systemDefault()) is an implicit decision that can bite you.

FileTime: picking up 07-06

In 07-06 you read a file's attributes and the timestamps came out as FileTime, a type you could do nothing with. Now you can:

package com.nexussoftware.bibliotech.persistence;

import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.*;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;

public class BackupInventory {

    private static final DateTimeFormatter FORMAT =
            DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", Locale.forLanguageTag("en-GB"));

    private static final ZoneId ZONE = ZoneId.of("Europe/Madrid");

    public record Backup(Path path, LocalDateTime modified, long bytes) {

        public long daysOld(LocalDate today) {
            return java.time.temporal.ChronoUnit.DAYS.between(modified.toLocalDate(), today);
        }
    }

    /** Lists the backups with their real date, not with an opaque FileTime. */
    public List<Backup> inventory(Path directory) throws IOException {
        try (Stream<Path> paths = Files.list(directory)) {
            return paths
                    .filter(Files::isRegularFile)
                    .filter(p -> p.getFileName().toString().endsWith(".bak"))
                    .map(this::describe)
                    .sorted(java.util.Comparator.comparing(Backup::modified).reversed())
                    .toList();
        }
    }

    private Backup describe(Path path) {
        try {
            BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class);

            // FileTime -> Instant -> LocalDateTime in the library's zone
            FileTime modified = attributes.lastModifiedTime();
            LocalDateTime date = modified.toInstant().atZone(ZONE).toLocalDateTime();

            return new Backup(path, date, attributes.size());

        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    /** Stamps a file with a specific date: java.time -> FileTime. */
    public void stampAs(Path file, LocalDateTime when) throws IOException {
        Instant instant = when.atZone(ZONE).toInstant();
        Files.setLastModifiedTime(file, FileTime.from(instant));
    }

    /** Deletes the backups older than the cut-off date. */
    public int purgeOlderThan(Path directory, LocalDate cutOff) throws IOException {
        List<Backup> expired = inventory(directory).stream()
                .filter(b -> b.modified().toLocalDate().isBefore(cutOff))
                .toList();

        for (Backup b : expired) {
            Files.delete(b.path());
        }
        return expired.size();
    }

    public static void main(String[] args) throws IOException {

        BackupInventory inventory = new BackupInventory();
        LocalDate today = LocalDate.now(ZONE);

        System.out.printf("%-28s %-18s %10s %8s%n",
                "FILE", "MODIFIED", "SIZE", "DAYS");
        System.out.println("-".repeat(68));

        inventory.inventory(Path.of("backups")).forEach(b ->
                System.out.printf("%-28s %-18s %8d KB %8d%n",
                        b.path().getFileName(),
                        b.modified().format(FORMAT),
                        b.bytes() / 1024,
                        b.daysOld(today)));

        LocalDate cutOff = today.minusMonths(3).with(
                java.time.temporal.TemporalAdjusters.firstDayOfMonth());
        System.out.println("\nPurging backups older than " + cutOff + "...");
        System.out.println("Deleted: " + inventory.purgeOlderThan(Path.of("backups"), cutOff));
    }
}
FILE                         MODIFIED                 SIZE     DAYS
--------------------------------------------------------------------
catalogue-20260804.bak       04/08/2026 03:00         1284 KB        1
catalogue-20260728.bak       28/07/2026 03:00         1271 KB        8
catalogue-20260630.bak       30/06/2026 03:00         1198 KB       36
catalogue-20260401.bak       01/04/2026 03:00         1044 KB      126

Purging backups older than 2026-05-01...
Deleted: 1

FileTime.toInstant() and FileTime.from(Instant) are the bridge in both directions. The debt from 07-06 is settled.

  1. Clock: the injectable time source

This section looks technical and is, in fact, one of the most important in the lesson.

The problem: this code is impossible to test reliably.

public class FineCalculator {

    public double calculate(Loan loan) {
        LocalDate today = LocalDate.now();                  // <-- the problem
        long late = ChronoUnit.DAYS.between(loan.getDueDate(), today);
        return late <= 0 ? 0.0 : late * 0.25;
    }
}

LocalDate.now() reads the system clock. To test "a loan 30 days late" you would have to:

  • Change the system time (unworkable in continuous integration).
  • Create the loan with a due date 30 days ago, which makes the test dependent on the day it runs — and it will fail the day it lands on an edge case.
  • Not test the case at all, which is what usually happens.

The solution: Clock, an abstraction of the time source that is injected instead of read globally.

// System clock in the default zone
Clock system = Clock.systemDefaultZone();

// System clock in a specific zone
Clock madrid = Clock.system(ZoneId.of("Europe/Madrid"));

// FIXED clock: it always returns the same instant
Clock fixed = Clock.fixed(Instant.parse("2026-08-05T10:00:00Z"), ZoneId.of("Europe/Madrid"));

// Clock offset from another one
Clock in30Days = Clock.offset(system, Duration.ofDays(30));

// Clock with reduced granularity (useful for deterministic tests)
Clock bySeconds = Clock.tickSeconds(ZoneId.of("Europe/Madrid"));

Every now() accepts a Clock:

LocalDate.now(clock);
LocalDateTime.now(clock);
ZonedDateTime.now(clock);
Instant.now(clock);

The testable version of the calculator:

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.Loan;

import java.time.*;
import java.time.temporal.ChronoUnit;

/**
 * Fine calculator with an INJECTED clock.
 * In production it receives the system clock; in tests, a fixed one.
 */
public class FineCalculator {

    private static final double EURO_PER_DAY = 0.25;
    private static final double CAP = 20.0;
    private static final int GRACE_DAYS = 2;

    private final Clock clock;

    /** Production constructor. */
    public FineCalculator() {
        this(Clock.system(ZoneId.of("Europe/Madrid")));
    }

    /** Constructor for tests and for scenarios with an explicit zone. */
    public FineCalculator(Clock clock) {
        this.clock = java.util.Objects.requireNonNull(clock, "clock");
    }

    public LocalDate today() {
        return LocalDate.now(clock);        // never a bare LocalDate.now()
    }

    public long daysLate(Loan loan) {
        long days = ChronoUnit.DAYS.between(loan.getDueDate(), today());
        return Math.max(0, days);
    }

    public double calculate(Loan loan) {
        long late = daysLate(loan);
        if (late <= GRACE_DAYS) {
            return 0.0;
        }
        return Math.min((late - GRACE_DAYS) * EURO_PER_DAY, CAP);
    }

    public boolean isOverdue(Loan loan) {
        return today().isAfter(loan.getDueDate());
    }

    public boolean isDueWithin(Loan loan, int days) {
        LocalDate limit = today().plusDays(days);
        LocalDate dueDate = loan.getDueDate();
        return !dueDate.isBefore(today()) && !dueDate.isAfter(limit);
    }
}

And now the test is deterministic and does not depend on the day it runs:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.domain.Loan;
import com.nexussoftware.bibliotech.service.FineCalculator;

import java.time.*;

public class FineCalculatorTest {

    private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");

    public static void main(String[] args) {

        // The test's "today" is FIXED: 5 August 2026
        Clock fixedToday = Clock.fixed(Instant.parse("2026-08-05T10:00:00Z"), MADRID);
        FineCalculator calculator = new FineCalculator(fixedToday);

        System.out.printf("%-24s %-12s %8s %10s%n",
                "CASE", "DUE DATE", "LATE", "FINE");
        System.out.println("-".repeat(58));

        check(calculator, "Not yet due",            LocalDate.of(2026, 8, 20));
        check(calculator, "Due today",              LocalDate.of(2026, 8,  5));
        check(calculator, "1 day (grace)",          LocalDate.of(2026, 8,  4));
        check(calculator, "2 days (grace)",         LocalDate.of(2026, 8,  3));
        check(calculator, "3 days (first fine)",    LocalDate.of(2026, 8,  2));
        check(calculator, "30 days",                LocalDate.of(2026, 7,  6));
        check(calculator, "1 year (cap)",           LocalDate.of(2025, 8,  5));

        // And now we "travel" 100 days into the future without touching the system clock
        System.out.println("\n--- The same loan, 100 days later ---");
        Clock future = Clock.offset(fixedToday, Duration.ofDays(100));
        FineCalculator inTheFuture = new FineCalculator(future);
        System.out.println("Today for the calculator: " + inTheFuture.today());
        check(inTheFuture, "Not yet due (before)", LocalDate.of(2026, 8, 20));
    }

    private static void check(FineCalculator c, String label, LocalDate dueDate) {
        Loan l = new Loan("LN-TEST", "978-0000000001", "Marta Ruiz",
                          dueDate.minusDays(21), dueDate);
        System.out.printf("%-24s %-12s %8d %8.2f EUR%n",
                label, dueDate, c.daysLate(l), c.calculate(l));
    }
}
CASE                     DUE DATE         LATE       FINE
----------------------------------------------------------
Not yet due              2026-08-20          0     0.00 EUR
Due today                2026-08-05          0     0.00 EUR
1 day (grace)            2026-08-04          1     0.00 EUR
2 days (grace)           2026-08-03          2     0.00 EUR
3 days (first fine)      2026-08-02          3     0.25 EUR
30 days                  2026-07-06         30     7.00 EUR
1 year (cap)             2025-08-05        365    20.00 EUR

--- The same loan, 100 days later ---
Today for the calculator: 2026-11-13
Not yet due (before)     2026-08-20         85    20.00 EUR

This test will give the same result in five years' time, because it does not depend on the system clock. The edge cases —due today, one day late, exactly the grace days, the cap— are checked explicitly and reproducibly.

The rule: in any class whose logic depends on "now", inject a Clock and never call LocalDate.now() with no argument. In 11-04 you will see that this is exactly what makes it possible to test code with dates, and in 11-02 that Spring can inject the Clock like any other dependency.

  1. BiblioTech: the complete migration

We apply everything. Starting with Loan:

package com.nexussoftware.bibliotech.domain;

import com.nexussoftware.bibliotech.annotations.CsvField;

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;

/**
 * Loan with REAL dates.
 *
 * BEFORE: private final int loanDay;
 *         private final int dueDay;
 * NOW:    LocalDate, because a loan is a CALENDAR fact,
 *         not an instant: daylight saving does not affect it.
 */
public class Loan implements Identifiable {

    /** Standard loan term at Nexus Software. */
    public static final Period STANDARD_TERM = Period.ofDays(21);

    /** ISO-8601 for persistence: unambiguous in any country and system. */
    private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;

    @CsvField(name = "Reference", order = 1)
    private final String reference;

    @CsvField(name = "ISBN", order = 2)
    private final String isbn;

    @CsvField(name = "Employee", order = 3, sensitive = true)
    private final String employee;

    @CsvField(name = "Loan date", order = 4)
    private final LocalDate loanDate;

    @CsvField(name = "Due date", order = 5)
    private final LocalDate dueDate;

    /** The EXACT instant of the record: for auditing we store Instant, not LocalDate. */
    private final Instant recordedAt;

    private LocalDate returnDate;                   // null while it is still on loan

    private final List<Incident> incidents = new ArrayList<>();

    public Loan(String reference, String isbn, String employee,
                LocalDate loanDate, LocalDate dueDate) {
        this.reference = Objects.requireNonNull(reference, "reference");
        this.isbn = Objects.requireNonNull(isbn, "isbn");
        this.employee = Objects.requireNonNull(employee, "employee");
        this.loanDate = Objects.requireNonNull(loanDate, "loanDate");
        this.dueDate = Objects.requireNonNull(dueDate, "dueDate");

        if (dueDate.isBefore(loanDate)) {
            throw new IllegalArgumentException(
                    "The due date (" + dueDate + ") cannot be earlier "
                    + "than the loan date (" + loanDate + ")");
        }
        this.recordedAt = Instant.now();
    }

    /** Factory that applies the standard term in WORKING days. */
    public static Loan withStandardTerm(String reference, String isbn,
                                        String employee, LocalDate loanDate) {
        LocalDate dueDate = loanDate.plus(STANDARD_TERM)
                .with(com.nexussoftware.bibliotech.service
                        .BiblioTechAdjusters.nextWorkingDay());
        return new Loan(reference, isbn, employee, loanDate, dueDate);
    }

    // --- Queries that depend on "today": they ALWAYS receive the date ---

    /** Days late. 0 if it has not fallen due yet. */
    public long daysLate(LocalDate today) {
        LocalDate reference = returnDate != null ? returnDate : today;
        return Math.max(0, ChronoUnit.DAYS.between(dueDate, reference));
    }

    public boolean isOverdue(LocalDate today) {
        return daysLate(today) > 0;
    }

    public boolean isDueWithin(LocalDate today, int days) {
        LocalDate limit = today.plusDays(days);
        return !dueDate.isBefore(today) && !dueDate.isAfter(limit);
    }

    /** Human breakdown of the elapsed time. */
    public Period age(LocalDate today) {
        return Period.between(loanDate, today);
    }

    public void returnItem(LocalDate when) {
        if (returnDate != null) {
            throw new IllegalStateException(
                    "Loan " + reference + " was already returned on " + returnDate);
        }
        if (when.isBefore(loanDate)) {
            throw new IllegalArgumentException("An item cannot be returned before it is lent");
        }
        this.returnDate = when;
    }

    // --- Serialisation: ALWAYS ISO-8601 ---

    public String toCsvLine() {
        return String.join(";",
                reference, isbn, employee,
                loanDate.format(ISO),
                dueDate.format(ISO),
                returnDate == null ? "" : returnDate.format(ISO),
                recordedAt.toString());            // Instant.toString() is already ISO
    }

    public static Loan fromCsvLine(String line) {
        String[] c = line.split(";", -1);
        Loan l = new Loan(c[0], c[1], c[2],
                LocalDate.parse(c[3], ISO),
                LocalDate.parse(c[4], ISO));
        if (!c[5].isEmpty()) {
            l.returnItem(LocalDate.parse(c[5], ISO));
        }
        return l;
    }

    // --- Getters ---
    @Override public String getId() { return reference; }
    public String getIsbn() { return isbn; }
    public String getEmployee() { return employee; }
    public LocalDate getLoanDate() { return loanDate; }
    public LocalDate getDueDate() { return dueDate; }
    public Optional<LocalDate> getReturnDate() { return Optional.ofNullable(returnDate); }
    public Instant getRecordedAt() { return recordedAt; }
    public List<Incident> getIncidents() { return List.copyOf(incidents); }

    public record Incident(String description, Severity severity, LocalDate when) { }
}

The notice service, now with TemporalAdjusters:

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.Loan;

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.*;
import java.util.stream.Collectors;

public class NoticeService {

    private static final DateTimeFormatter READABLE =
            DateTimeFormatter.ofPattern("EEEE d MMMM", Locale.forLanguageTag("en-GB"));

    private final Clock clock;

    public NoticeService(Clock clock) {
        this.clock = Objects.requireNonNull(clock);
    }

    private LocalDate today() {
        return LocalDate.now(clock);
    }

    /** Loans falling due in the next N days, grouped by employee. */
    public Map<String, List<Loan>> dueNotices(List<Loan> loans, int days) {
        LocalDate limit = today().plusDays(days);
        return loans.stream()
                .filter(l -> l.getReturnDate().isEmpty())
                .filter(l -> !l.getDueDate().isBefore(today()))
                .filter(l -> !l.getDueDate().isAfter(limit))
                .collect(Collectors.groupingBy(Loan::getEmployee, TreeMap::new,
                         Collectors.toList()));
    }

    /** The notice goes out on the next WORKING day: nobody reads email on a Sunday. */
    public LocalDate whenToSendNotice(Loan loan, int daysInAdvance) {
        return loan.getDueDate()
                .minusDays(daysInAdvance)
                .with(BiblioTechAdjusters.nextWorkingDay());
    }

    /** Date of the monthly report: the last Friday of the month. */
    public LocalDate monthlyReportDate() {
        return today().with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY));
    }

    /** Date of the quarterly stocktake: the first Monday of the next quarter. */
    public LocalDate quarterlyStocktakeDate() {
        LocalDate today = today();
        int currentMonth = today.getMonthValue();
        int firstMonthOfNextQuarter = ((currentMonth - 1) / 3 + 1) * 3 + 1;

        LocalDate base = firstMonthOfNextQuarter > 12
                ? LocalDate.of(today.getYear() + 1, 1, 1)
                : LocalDate.of(today.getYear(), firstMonthOfNextQuarter, 1);

        return base.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY));
    }

    public String composeNotice(String employee, List<Loan> loans) {
        StringBuilder sb = new StringBuilder();
        sb.append("Dear ").append(employee).append(",\n\n");
        sb.append("This is a reminder that you have ").append(loans.size())
          .append(loans.size() == 1 ? " item" : " items")
          .append(" still to return:\n\n");

        loans.stream()
                .sorted(Comparator.comparing(Loan::getDueDate))
                .forEach(l -> {
                    long days = java.time.temporal.ChronoUnit.DAYS
                            .between(today(), l.getDueDate());
                    sb.append(String.format("  - %s   due on %s (%s)%n",
                            l.getIsbn(),
                            l.getDueDate().format(READABLE),
                            days == 0 ? "today" : "in " + days + (days == 1 ? " day" : " days")));
                });

        sb.append("\nBiblioTech -- Nexus Software\n");
        sb.append("Generated on ").append(today().format(READABLE)).append('\n');
        return sb.toString();
    }
}

And the configuration fixes the zone, instead of depending on the system one:

package com.nexussoftware.bibliotech.persistence;

import java.time.*;
import java.util.Properties;

/**
 * Configuration (07-07) with the library's time zone made EXPLICIT.
 * Depending on ZoneId.systemDefault() means the behaviour
 * changes when deploying on a server configured in UTC.
 */
public class TimeConfiguration {

    private final ZoneId zone;
    private final Clock clock;
    private final LocalTime libraryOpening;
    private final LocalTime libraryClosing;
    private final Period loanTerm;

    public TimeConfiguration(Properties properties) {
        this.zone = ZoneId.of(properties.getProperty("bibliotech.zone", "Europe/Madrid"));
        this.clock = Clock.system(zone);
        this.libraryOpening = LocalTime.parse(
                properties.getProperty("bibliotech.opening", "09:00"));
        this.libraryClosing = LocalTime.parse(
                properties.getProperty("bibliotech.closing", "20:00"));
        this.loanTerm = Period.parse(
                properties.getProperty("bibliotech.term", "P21D"));
    }

    public ZoneId zone() { return zone; }
    public Clock clock() { return clock; }
    public Period loanTerm() { return loanTerm; }

    public boolean isOpen(LocalDateTime when) {
        DayOfWeek day = when.getDayOfWeek();
        if (day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY) {
            return false;
        }
        LocalTime time = when.toLocalTime();
        return !time.isBefore(libraryOpening) && time.isBefore(libraryClosing);
    }

    public boolean isOpenNow() {
        return isOpen(LocalDateTime.now(clock));
    }
}
# bibliotech.properties
bibliotech.zone=Europe/Madrid
bibliotech.opening=09:00
bibliotech.closing=20:00
bibliotech.term=P21D

Notice Period.parse("P21D"): the ISO-8601 format for durations lets you configure the term from a file without inventing a format of your own. P1M would be one month, P2W two weeks.

A table of what changed in each class

Class Before Now
Loan int loanDay, int dueDay LocalDate loanDate, LocalDate dueDate, Optional<LocalDate> returnDate, Instant recordedAt
Loan.Incident String description, Severity Adds LocalDate when
FineCalculator Integer subtraction ChronoUnit.DAYS.between + an injected Clock
NoticeService Integer comparisons TemporalAdjusters for working days, LocalDate for ranges
BiblioTechAdjusters Did not exist Working days, holidays with MonthDay, datesUntil
CsvWriter / CsvReader Meaningless numbers ISO-8601 with DateTimeFormatter.ISO_LOCAL_DATE
Configuration No zone Explicit ZoneId, Clock, opening LocalTime, term Period
OperationLog System.currentTimeMillis() Instant
BackupInventory Opaque FileTime FileTime.toInstant().atZone(...)
MeetingRoom Slots as integers Start and end LocalTime, Duration
Reservation Day as an integer Start LocalDateTime + Duration

And the result in the CSV:

Reference;ISBN;Employee;Loan date;Due date;Return date;Recorded at
LN-2026-0041;978-0000000001;Marta Ruiz;2026-07-15;2026-08-05;;2026-07-15T09:14:22.481Z
LN-2026-0042;978-0000000003;Diego Alonso;2026-07-06;2026-07-27;2026-08-03;2026-07-06T11:02:17.903Z

Any system in the world understands that file. It used to contain the numbers 196 and 217, which only meant something inside BiblioTech.

Common Mistakes and Tips

1. Ignoring the returned value. date.plusDays(21); does nothing: the objects are immutable. You have to assign the result. IDEs warn you; listen to them.

2. Using Date, Calendar or SimpleDateFormat in new code. There has been no reason to do so since 2014. And a SimpleDateFormat shared between threads corrupts data silently.

3. Storing LocalDateTime when you meant an instant. It works until the server changes zone or a user from another country logs in, and then the data already stored is unrecoverable. Store Instant.

4. Confusing MM with mm in patterns. MM is the month, mm is minutes. "dd/mm/yyyy" puts the minutes where the month should be.

5. Using YYYY instead of yyyy. Y is the week-based year, and 31 December 2026 is formatted as 2027. It is a bug that shows up every New Year's Eve.

6. Depending on the default Locale or zone. They differ between your machine and the server. Be explicit: Locale.forLanguageTag("en-GB"), ZoneId.of("Europe/Madrid").

7. Calling LocalDate.now() inside business logic. It makes the class impossible to test deterministically. Inject a Clock.

8. Confusing Duration with Period. Duration.ofDays(1) is 86,400 seconds; Period.ofDays(1) is "the next day". On the night of the clock change they differ by an hour.

9. Expecting plusMonths to be reversible. 31 January + 1 month − 1 month = 28 January. Adjusting to the last valid day is correct but not symmetric.

10. Using three-letter zone abbreviations. CST is ambiguous between three different zones. Always use Region/City.

11. Expecting ChronoUnit.MONTHS.between to round. It truncates towards zero: from 5 August to 4 September there are 0 months.

12. Assuming the local time always exists and is unique. Twice a year there is a time that does not exist and another that happens twice.

13. Storing the offset instead of the zone. +02:00 does not know that in winter it will be +01:00. For future events, store the ZoneId.

Tip 1: use the most restrictive type that does the job. If there is only a date, LocalDate. Adding a time and a zone "just in case" introduces ambiguity and errors. YearMonth for monthly reports, MonthDay for yearly holidays.

Tip 2: ISO-8601 to persist, a localised format to display. And do not mix the two: the CSV carries 2026-08-05, the user's screen carries Wednesday, 5 August 2026.

Tip 3: declare your DateTimeFormatters as static final. They are immutable, thread-safe and their construction has a cost (10-07). One per format, reused always.

Tip 4: in methods that depend on "today", pass the date as a parameter. daysLate(LocalDate today) is testable; daysLate() that internally calls now() is not. It is the same idea as Clock, applied at method level.

Tip 5: validate ranges in the constructor. That the due date is not before the loan date, that the return is not before the loan. An object born incoherent propagates the error through the whole application (03-04).

Tip 6: make the most of datesUntil. It returns a Stream<LocalDate> and connects java.time with everything from 10-04: from.datesUntil(to).filter(...).count().

Exercises

Exercise 1: the library calendar

Write a BiblioTechCalendar with an injected Clock that offers:

  1. boolean isWorkingDay(LocalDate): not a Saturday, a Sunday or a holiday (use MonthDay for the fixed holidays and compute Easter with Butcher's algorithm for the moveable ones).
  2. LocalDate nextWorkingDay(LocalDate) and LocalDate plusWorkingDays(LocalDate, int).
  3. long workingDaysBetween(LocalDate, LocalDate) using datesUntil.
  4. Map<Month, Long> workingDaysByMonth(int year) with streams.
  5. List<LocalDate> holidaysOf(int year) sorted, distinguishing fixed from moveable ones.
  6. String monthCalendar(YearMonth) that prints the month in calendar layout with working days and holidays marked.

Test it with 2026 and check that Easter falls where it should.

Exercise 2: room bookings across time zones

Nexus Software has offices in Madrid, New York and Tokyo. Model a room booking system that works across zones:

  1. record Reservation(String id, String room, String employee, ZonedDateTime start, Duration length).
  2. A derived ZonedDateTime end().
  3. boolean overlapsWith(Reservation) that works even if the bookings are in different zones (it compares instants).
  4. A ReservationManager with Result<Reservation> book(...) (the Result<T> from 10-01) that rejects overlaps, bookings outside the working hours of the room's office, and bookings in the past.
  5. String scheduleFor(String employee, ZoneId employeeZone) that shows all their bookings in their zone.
  6. Demonstrate with code the case of a booking created in Tokyo that an employee in Madrid sees on the previous day.
  7. Add a case that falls on the night of the clock change and explain the result.

Exercise 3: temporal loan report

With Loan already migrated to LocalDate, write a TemporalReport that, combining java.time with the streams of 10-04, produces:

  1. Loans by YearMonth, in chronological order.
  2. The real average length of returned loans, in days, using ChronoUnit.
  3. The distribution by day of the week of the loan (DayOfWeek), with a histogram.
  4. The 5 most overdue loans as of the injected Clock's date.
  5. Loans falling due this week (Monday to Sunday of the current week), using TemporalAdjusters.
  6. A "heat map" by month and day of the week: Map<Month, Map<DayOfWeek, Long>>.
  7. The on-time return rate (returned on or before the due date) by quarter.

All with a fixed Clock so that the results are reproducible.

Solutions

Solution 1

package com.nexussoftware.bibliotech.service;

import java.time.*;
import java.time.format.TextStyle;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

/**
 * Working calendar of the Nexus Software library.
 * Injected clock: testable deterministically.
 */
public class BiblioTechCalendar {

    private static final Locale EN = Locale.forLanguageTag("en-GB");

    /** FIXED-date holidays: MonthDay, because they repeat every year. */
    private static final Map<MonthDay, String> FIXED_HOLIDAYS = Map.ofEntries(
            Map.entry(MonthDay.of(1, 1),   "New Year's Day"),
            Map.entry(MonthDay.of(1, 6),   "Epiphany"),
            Map.entry(MonthDay.of(5, 1),   "Labour Day"),
            Map.entry(MonthDay.of(8, 15),  "Assumption"),
            Map.entry(MonthDay.of(10, 12), "National Day"),
            Map.entry(MonthDay.of(11, 1),  "All Saints"),
            Map.entry(MonthDay.of(12, 6),  "Constitution Day"),
            Map.entry(MonthDay.of(12, 8),  "Immaculate Conception"),
            Map.entry(MonthDay.of(12, 25), "Christmas"));

    private final Clock clock;

    /** Cache of moveable holidays by year: the computation never changes. */
    private final Map<Integer, Map<LocalDate, String>> moveableByYear = new HashMap<>();

    public BiblioTechCalendar(Clock clock) {
        this.clock = Objects.requireNonNull(clock, "clock");
    }

    public LocalDate today() {
        return LocalDate.now(clock);
    }

    // ------------------------------------------------------------------
    // 1. Working days and holidays
    // ------------------------------------------------------------------

    public boolean isWeekend(LocalDate date) {
        DayOfWeek d = date.getDayOfWeek();
        return d == DayOfWeek.SATURDAY || d == DayOfWeek.SUNDAY;
    }

    public Optional<String> holidayName(LocalDate date) {
        String fixed = FIXED_HOLIDAYS.get(MonthDay.from(date));
        if (fixed != null) {
            return Optional.of(fixed);
        }
        return Optional.ofNullable(moveableHolidays(date.getYear()).get(date));
    }

    public boolean isHoliday(LocalDate date) {
        return holidayName(date).isPresent();
    }

    public boolean isWorkingDay(LocalDate date) {
        return !isWeekend(date) && !isHoliday(date);
    }

    /**
     * Butcher's algorithm (or Meeus/Jones/Butcher) for Easter Sunday
     * in the Gregorian calendar. The rest are derived from it.
     */
    private Map<LocalDate, String> moveableHolidays(int year) {
        return moveableByYear.computeIfAbsent(year, y -> {
            LocalDate easter = easterSunday(y);
            Map<LocalDate, String> moveable = new LinkedHashMap<>();
            moveable.put(easter.minusDays(3), "Maundy Thursday");
            moveable.put(easter.minusDays(2), "Good Friday");
            moveable.put(easter.plusDays(1),  "Easter Monday");
            return moveable;
        });
    }

    public static LocalDate easterSunday(int year) {
        int a = year % 19;
        int b = year / 100;
        int c = year % 100;
        int d = b / 4;
        int e = b % 4;
        int f = (b + 8) / 25;
        int g = (b - f + 1) / 3;
        int h = (19 * a + b - d - g + 15) % 30;
        int i = c / 4;
        int k = c % 4;
        int l = (32 + 2 * e + 2 * i - h - k) % 7;
        int m = (a + 11 * h + 22 * l) / 451;
        int month = (h + l - 7 * m + 114) / 31;
        int day = ((h + l - 7 * m + 114) % 31) + 1;
        return LocalDate.of(year, month, day);
    }

    // ------------------------------------------------------------------
    // 2. Navigating working days
    // ------------------------------------------------------------------

    public LocalDate nextWorkingDay(LocalDate from) {
        LocalDate date = from;
        while (!isWorkingDay(date)) {
            date = date.plusDays(1);
        }
        return date;
    }

    public LocalDate plusWorkingDays(LocalDate from, int days) {
        if (days < 0) {
            throw new IllegalArgumentException("Negative days: " + days);
        }
        LocalDate date = from;
        int remaining = days;
        while (remaining > 0) {
            date = date.plusDays(1);
            if (isWorkingDay(date)) {
                remaining--;
            }
        }
        return date;
    }

    // ------------------------------------------------------------------
    // 3-5. Aggregate queries with streams (10-04)
    // ------------------------------------------------------------------

    /** datesUntil (Java 9) returns a Stream<LocalDate>: the bridge to 10-04. */
    public long workingDaysBetween(LocalDate from, LocalDate toExclusive) {
        return from.datesUntil(toExclusive)
                   .filter(this::isWorkingDay)
                   .count();
    }

    public Map<Month, Long> workingDaysByMonth(int year) {
        return LocalDate.of(year, 1, 1)
                .datesUntil(LocalDate.of(year + 1, 1, 1))
                .filter(this::isWorkingDay)
                .collect(Collectors.groupingBy(LocalDate::getMonth,
                         () -> new EnumMap<>(Month.class),
                         Collectors.counting()));
    }

    public List<Map.Entry<LocalDate, String>> holidaysOf(int year) {
        return LocalDate.of(year, 1, 1)
                .datesUntil(LocalDate.of(year + 1, 1, 1))
                .filter(this::isHoliday)
                .map(d -> Map.entry(d, holidayName(d).orElse("?")))
                .sorted(Map.Entry.comparingByKey())
                .toList();
    }

    // ------------------------------------------------------------------
    // 6. Visual calendar
    // ------------------------------------------------------------------

    public String monthCalendar(YearMonth month) {
        StringBuilder sb = new StringBuilder();

        String title = month.getMonth().getDisplayName(TextStyle.FULL, EN).toUpperCase()
                + " " + month.getYear();
        sb.append(String.format("%s%n", centre(title, 28)));
        sb.append(" Mo  Tu  We  Th  Fr  Sa  Su\n");

        LocalDate first = month.atDay(1);
        // getValue(): 1 = Monday ... 7 = Sunday (ISO)
        int leadingGap = first.getDayOfWeek().getValue() - 1;
        sb.append("    ".repeat(leadingGap));

        for (int day = 1; day <= month.lengthOfMonth(); day++) {
            LocalDate date = month.atDay(day);

            String mark;
            if (isHoliday(date))          mark = "*";     // holiday
            else if (isWeekend(date))     mark = ".";     // weekend
            else                          mark = " ";     // working day

            sb.append(String.format("%3d%s", day, mark));

            if (date.getDayOfWeek() == DayOfWeek.SUNDAY) {
                sb.append('\n');
            }
        }
        if (month.atEndOfMonth().getDayOfWeek() != DayOfWeek.SUNDAY) {
            sb.append('\n');
        }

        long working = workingDaysBetween(month.atDay(1), month.plusMonths(1).atDay(1));
        sb.append(String.format("%nWorking days: %d of %d   (* holiday, . weekend)%n",
                working, month.lengthOfMonth()));

        holidaysOf(month.getYear()).stream()
                .filter(e -> YearMonth.from(e.getKey()).equals(month))
                .forEach(e -> sb.append(String.format("  %s  %s%n",
                        e.getKey().getDayOfMonth(), e.getValue())));

        return sb.toString();
    }

    private static String centre(String text, int width) {
        int left = Math.max(0, (width - text.length()) / 2);
        return " ".repeat(left) + text;
    }
}

A test:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.service.BiblioTechCalendar;

import java.time.*;

public class CalendarTest {

    public static void main(String[] args) {

        Clock fixed = Clock.fixed(Instant.parse("2026-08-05T10:00:00Z"),
                                  ZoneId.of("Europe/Madrid"));
        BiblioTechCalendar calendar = new BiblioTechCalendar(fixed);

        System.out.println("Today: " + calendar.today() + " ("
                + calendar.today().getDayOfWeek() + "), working day: "
                + calendar.isWorkingDay(calendar.today()));

        System.out.println("\nEaster 2026: " + BiblioTechCalendar.easterSunday(2026));
        System.out.println("Easter 2027: " + BiblioTechCalendar.easterSunday(2027));

        System.out.println("\n--- Holidays of 2026 ---");
        calendar.holidaysOf(2026).forEach(e ->
                System.out.printf("  %s  %-18s (%s)%n",
                        e.getKey(), e.getValue(), e.getKey().getDayOfWeek()));

        System.out.println("\n--- Working days per month in 2026 ---");
        calendar.workingDaysByMonth(2026).forEach((month, n) ->
                System.out.printf("  %-12s %2d %s%n", month, n, "#".repeat(n.intValue())));

        System.out.println("\n" + calendar.monthCalendar(YearMonth.of(2026, 4)));
        System.out.println(calendar.monthCalendar(YearMonth.of(2026, 8)));

        System.out.println("--- Navigation ---");
        LocalDate friday = LocalDate.of(2026, 8, 14);
        System.out.println("Friday 14/08:               " + friday);
        System.out.println("Next working day from 15th: "
                + calendar.nextWorkingDay(friday.plusDays(1)));
        System.out.println("+10 working days:           "
                + calendar.plusWorkingDays(friday, 10));
    }
}
Today: 2026-08-05 (WEDNESDAY), working day: true

Easter 2026: 2026-04-05
Easter 2027: 2027-03-28

--- Holidays of 2026 ---
  2026-01-01  New Year's Day     (THURSDAY)
  2026-01-06  Epiphany           (TUESDAY)
  2026-04-02  Maundy Thursday    (THURSDAY)
  2026-04-03  Good Friday        (FRIDAY)
  2026-04-06  Easter Monday      (MONDAY)
  2026-05-01  Labour Day         (FRIDAY)
  2026-08-15  Assumption         (SATURDAY)
  2026-10-12  National Day       (MONDAY)
  2026-11-01  All Saints         (SUNDAY)
  2026-12-06  Constitution Day   (SUNDAY)
  2026-12-08  Immaculate Conception (TUESDAY)
  2026-12-25  Christmas          (FRIDAY)

--- Working days per month in 2026 ---
  JANUARY      20 ####################
  FEBRUARY     20 ####################
  MARCH        22 ######################
  APRIL        19 ###################
  MAY          20 ####################
  JUNE         22 ######################
  JULY         23 #######################
  AUGUST       21 #####################
  SEPTEMBER    22 ######################
  OCTOBER      21 #####################
  NOVEMBER     21 #####################
  DECEMBER     21 #####################

         APRIL 2026
 Mo  Tu  We  Th  Fr  Sa  Su
              1   2*  3*  4.  5.
  6*  7   8   9  10  11.  12.
 13  14  15  16  17  18.  19.
 20  21  22  23  24  25.  26.
 27  28  29  30

Working days: 19 of 30   (* holiday, . weekend)
  2  Maundy Thursday
  3  Good Friday
  6  Easter Monday

        AUGUST 2026
 Mo  Tu  We  Th  Fr  Sa  Su
                  1.  2.
  3   4   5   6   7   8.  9.
 10  11  12  13  14  15*  16.
 17  18  19  20  21  22.  23.
 24  25  26  27  28  29.  30.
 31

Working days: 21 of 31   (* holiday, . weekend)
  15  Assumption

--- Navigation ---
Friday 14/08:               2026-08-14
Next working day from 15th: 2026-08-17
+10 working days:           2026-08-31

Comments.

MonthDay is exactly the right type for fixed holidays. A LocalDate would force you to repeat the list every year or to build it dynamically. MonthDay.of(12, 25) says "the 25th of December, any year", which is what a fixed holiday is.

The cache of moveable holidays matters more than it looks. workingDaysByMonth(2026) calls isWorkingDay 365 times, and every call needs that year's moveable holidays. Without computeIfAbsent, Butcher's algorithm would run 365 times for the same result.

datesUntil connects the two lessons. LocalDate.datesUntil(end) returns a Stream<LocalDate>, and from there all of 10-04 applies: filter, groupingBy, counting. The 12 months with their working days come out in a single expression.

The 15th of August 2026 is both a Saturday and a holiday, which illustrates why isWorkingDay has to check both things and why August's count gives 21 and not 20.

Solution 2

package com.nexussoftware.bibliotech.domain;

import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Objects;

/**
 * Room booking with an explicit time zone.
 * ZonedDateTime because we have to respect the LOCAL working hours
 * of each office and the clock change.
 */
public record Reservation(String id, String room, String employee,
                          ZonedDateTime start, Duration length) {

    private static final DateTimeFormatter READABLE =
            DateTimeFormatter.ofPattern("EEE dd/MM HH:mm", Locale.forLanguageTag("en-GB"));

    public Reservation {
        Objects.requireNonNull(id, "id");
        Objects.requireNonNull(room, "room");
        Objects.requireNonNull(employee, "employee");
        Objects.requireNonNull(start, "start");
        Objects.requireNonNull(length, "length");
        if (length.isNegative() || length.isZero()) {
            throw new IllegalArgumentException("The length must be positive: " + length);
        }
        if (length.toHours() > 8) {
            throw new IllegalArgumentException("A booking cannot last more than 8 hours");
        }
    }

    public ZonedDateTime end() {
        return start.plus(length);
    }

    /**
     * It compares INSTANTS, not local times.
     * That way it works even if the two bookings are in different zones
     * and even if there is a clock change in between.
     */
    public boolean overlapsWith(Reservation other) {
        if (!room.equals(other.room)) {
            return false;
        }
        Instant myStart = start.toInstant();
        Instant myEnd = end().toInstant();
        Instant itsStart = other.start.toInstant();
        Instant itsEnd = other.end().toInstant();

        return myStart.isBefore(itsEnd) && itsStart.isBefore(myEnd);
    }

    /** The same booking seen from another zone: SAME instant, another clock. */
    public Reservation seenFrom(ZoneId zone) {
        return new Reservation(id, room, employee, start.withZoneSameInstant(zone), length);
    }

    public String describe() {
        return String.format("%-8s %-12s %-14s %s -- %s (%dh%02dm) [%s]",
                id, room, employee,
                start.format(READABLE), end().format(READABLE),
                length.toHours(), length.toMinutesPart(),
                start.getZone());
    }
}
package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.Reservation;

import java.time.*;
import java.util.*;
import java.util.stream.Collectors;

public class ReservationManager {

    /** The office each room belongs to: it defines its local working hours. */
    private static final Map<String, ZoneId> OFFICE_OF_THE_ROOM = Map.of(
            "Madrid-A",  ZoneId.of("Europe/Madrid"),
            "Madrid-B",  ZoneId.of("Europe/Madrid"),
            "NewYork-1", ZoneId.of("America/New_York"),
            "Tokyo-1",   ZoneId.of("Asia/Tokyo"));

    private static final LocalTime OPENING = LocalTime.of(8, 0);
    private static final LocalTime CLOSING = LocalTime.of(20, 0);

    private final List<Reservation> reservations = new ArrayList<>();
    private final Clock clock;

    public ReservationManager(Clock clock) {
        this.clock = Objects.requireNonNull(clock);
    }

    public Result<Reservation> book(String id, String room, String employee,
                                    ZonedDateTime start, Duration length) {

        ZoneId office = OFFICE_OF_THE_ROOM.get(room);
        if (office == null) {
            return Result.failure("There is no room '" + room + "'");
        }

        // 1. No booking in the past: comparison of INSTANTS
        if (start.toInstant().isBefore(Instant.now(clock))) {
            return Result.failure("Cannot book in the past (" + start + ")");
        }

        Reservation candidate;
        try {
            candidate = new Reservation(id, room, employee, start, length);
        } catch (IllegalArgumentException e) {
            return Result.failure(e.getMessage());
        }

        // 2. Working hours IN THE ROOM'S ZONE, not in the booker's
        ZonedDateTime localStart = start.withZoneSameInstant(office);
        ZonedDateTime localEnd = candidate.end().withZoneSameInstant(office);

        if (isWeekend(localStart)) {
            return Result.failure(String.format(
                    "Room %s is closed: %s is %s in %s",
                    room, localStart.toLocalDate(), localStart.getDayOfWeek(), office));
        }
        if (localStart.toLocalTime().isBefore(OPENING)
                || localEnd.toLocalTime().isAfter(CLOSING)
                || !localStart.toLocalDate().equals(localEnd.toLocalDate())) {
            return Result.failure(String.format(
                    "Outside the opening hours of %s (%s-%s local time): %s to %s",
                    room, OPENING, CLOSING,
                    localStart.toLocalTime(), localEnd.toLocalTime()));
        }

        // 3. Overlaps, comparing instants
        Optional<Reservation> clash = reservations.stream()
                .filter(candidate::overlapsWith)
                .findFirst();

        if (clash.isPresent()) {
            return Result.failure("Overlaps with " + clash.get().id()
                    + " (" + clash.get().employee() + ")");
        }

        reservations.add(candidate);
        return Result.success(candidate);
    }

    private boolean isWeekend(ZonedDateTime z) {
        DayOfWeek d = z.getDayOfWeek();
        return d == DayOfWeek.SATURDAY || d == DayOfWeek.SUNDAY;
    }

    /** An employee's schedule in THEIR time zone. */
    public String scheduleFor(String employee, ZoneId employeeZone) {
        List<Reservation> theirs = reservations.stream()
                .filter(r -> r.employee().equals(employee))
                .map(r -> r.seenFrom(employeeZone))
                .sorted(Comparator.comparing(r -> r.start().toInstant()))
                .toList();

        if (theirs.isEmpty()) {
            return employee + " has no bookings.";
        }

        StringBuilder sb = new StringBuilder();
        sb.append("Schedule for ").append(employee)
          .append(" (times in ").append(employeeZone).append(")\n");

        theirs.stream()
              .collect(Collectors.groupingBy(r -> r.start().toLocalDate(),
                       TreeMap::new, Collectors.toList()))
              .forEach((day, ofTheDay) -> {
                  sb.append("  ").append(day).append(":\n");
                  ofTheDay.forEach(r -> sb.append("    ").append(r.describe()).append('\n'));
              });

        return sb.toString();
    }
}

The demonstration:

package com.nexussoftware.bibliotech;

import com.nexussoftware.bibliotech.service.*;

import java.time.*;

public class MultiZoneBookingTest {

    public static void main(String[] args) {

        ZoneId madrid = ZoneId.of("Europe/Madrid");
        ZoneId tokyo  = ZoneId.of("Asia/Tokyo");
        ZoneId nyc    = ZoneId.of("America/New_York");

        Clock clock = Clock.fixed(Instant.parse("2026-08-03T06:00:00Z"), madrid);
        ReservationManager manager = new ReservationManager(clock);

        System.out.println("Now: " + ZonedDateTime.now(clock));
        System.out.println();

        // 1. A normal booking in Madrid
        show(manager.book("R-001", "Madrid-A", "Marta Ruiz",
                ZonedDateTime.of(2026, 8, 5, 10, 0, 0, 0, madrid), Duration.ofHours(2)));

        // 2. An overlap in the same room
        show(manager.book("R-002", "Madrid-A", "Diego Alonso",
                ZonedDateTime.of(2026, 8, 5, 11, 0, 0, 0, madrid), Duration.ofHours(1)));

        // 3. Another room at the same time: NO overlap
        show(manager.book("R-003", "Madrid-B", "Diego Alonso",
                ZonedDateTime.of(2026, 8, 5, 11, 0, 0, 0, madrid), Duration.ofHours(1)));

        // 4. A Tokyo room booked FROM MADRID at 3 in the morning Madrid time
        //    = 10:00 Tokyo time: CORRECT working hours there
        show(manager.book("R-004", "Tokyo-1", "Nuria Vidal",
                ZonedDateTime.of(2026, 8, 6, 3, 0, 0, 0, madrid), Duration.ofHours(1)));

        // 5. A Tokyo room at 10:00 MADRID time = 17:00 in Tokyo: still open
        show(manager.book("R-005", "Tokyo-1", "Marta Ruiz",
                ZonedDateTime.of(2026, 8, 6, 10, 0, 0, 0, madrid), Duration.ofHours(1)));

        // 6. A Tokyo room at 16:00 Madrid time = 23:00 in Tokyo: CLOSED
        show(manager.book("R-006", "Tokyo-1", "Diego Alonso",
                ZonedDateTime.of(2026, 8, 6, 16, 0, 0, 0, madrid), Duration.ofHours(1)));

        // 7. New York on a Saturday local time
        show(manager.book("R-007", "NewYork-1", "Marta Ruiz",
                ZonedDateTime.of(2026, 8, 8, 15, 0, 0, 0, madrid), Duration.ofHours(1)));

        // 8. In the past
        show(manager.book("R-008", "Madrid-A", "Nuria Vidal",
                ZonedDateTime.of(2026, 8, 1, 10, 0, 0, 0, madrid), Duration.ofHours(1)));

        System.out.println();
        System.out.println(manager.scheduleFor("Marta Ruiz", madrid));
        System.out.println(manager.scheduleFor("Marta Ruiz", tokyo));

        // --- The clock-change case ---
        System.out.println("--- Booking on the night of the clock change ---");
        ZonedDateTime changeNight = ZonedDateTime.of(2026, 10, 25, 1, 30, 0, 0, madrid);
        System.out.println("Start:   " + changeNight);
        System.out.println("+2 hours (Duration): " + changeNight.plus(Duration.ofHours(2)));
        System.out.println("Valid offsets at 02:30: "
                + madrid.getRules().getValidOffsets(LocalDateTime.of(2026, 10, 25, 2, 30)));
    }

    private static void show(Result<com.nexussoftware.bibliotech.domain.Reservation> r) {
        if (r.isSuccess()) {
            System.out.println("OK      " + r.value().describe());
        } else {
            System.out.println("REJECT  " + r.error());
        }
    }
}
Now: 2026-08-03T08:00+02:00[Europe/Madrid]

OK      R-001    Madrid-A     Marta Ruiz     Wed 05/08 10:00 -- Wed 05/08 12:00 (2h00m) [Europe/Madrid]
REJECT  Overlaps with R-001 (Marta Ruiz)
OK      R-003    Madrid-B     Diego Alonso   Wed 05/08 11:00 -- Wed 05/08 12:00 (1h00m) [Europe/Madrid]
OK      R-004    Tokyo-1      Nuria Vidal    Thu 06/08 03:00 -- Thu 06/08 04:00 (1h00m) [Europe/Madrid]
OK      R-005    Tokyo-1      Marta Ruiz     Thu 06/08 10:00 -- Thu 06/08 11:00 (1h00m) [Europe/Madrid]
REJECT  Outside the opening hours of Tokyo-1 (08:00-20:00 local time): 23:00 to 00:00
REJECT  Room NewYork-1 is closed: 2026-08-08 is SATURDAY in America/New_York
REJECT  Cannot book in the past (2026-08-01T10:00+02:00[Europe/Madrid])

Schedule for Marta Ruiz (times in Europe/Madrid)
  2026-08-05:
    R-001    Madrid-A     Marta Ruiz     Wed 05/08 10:00 -- Wed 05/08 12:00 (2h00m) [Europe/Madrid]
  2026-08-06:
    R-005    Tokyo-1      Marta Ruiz     Thu 06/08 10:00 -- Thu 06/08 11:00 (1h00m) [Europe/Madrid]

Schedule for Marta Ruiz (times in Asia/Tokyo)
  2026-08-05:
    R-001    Madrid-A     Marta Ruiz     Wed 05/08 17:00 -- Wed 05/08 19:00 (2h00m) [Asia/Tokyo]
  2026-08-06:
    R-005    Tokyo-1      Marta Ruiz     Thu 06/08 17:00 -- Thu 06/08 18:00 (1h00m) [Asia/Tokyo]

--- Booking on the night of the clock change ---
Start:   2026-10-25T01:30+02:00[Europe/Madrid]
+2 hours (Duration): 2026-10-25T02:30+01:00[Europe/Madrid]
Valid offsets at 02:30: [+02:00, +01:00]

Comments. Five points.

Case R-004 is the heart of the exercise. Nuria books the Tokyo room at 3 in the morning Madrid time. It sounds absurd until you see that it is 10 in the morning in Tokyo, comfortably within that office's working hours. The system accepts it because it validates the hours in the room's zone, not in the booker's.

R-006 is the opposite. Diego tries to book Tokyo at 16:00 Madrid time, which seems perfectly reasonable to him, and it is 23:00 in Tokyo. Rejected, with a message explaining exactly why.

R-007 shows that the day of the week also depends on the zone. The 8th of August is a Saturday in both zones, but it might not be near midnight. Comparing the DayOfWeek of the wrong zone produces wrong rejections and wrong acceptances.

Marta's two schedules show the same booking with a different clock. withZoneSameInstant does not change when the meeting happens: it changes what her clock will read. 10:00 in Madrid is 17:00 in Tokyo, and both are correct.

And the clock-change case: adding Duration.ofHours(2) to 1:30 gives 2:30 with offset +01:00, not +02:00. Exactly two hours of real time have passed, but the clock only advanced one apparent hour because at 3:00 it went back to 2:00. getValidOffsets returning two offsets is the sign that this local time is ambiguous.

Solution 3

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.Loan;

import java.time.*;
import java.time.format.TextStyle;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
import java.util.*;
import java.util.stream.Collectors;

/**
 * Temporal report of loans.
 * java.time (10-05) + streams (10-04) + an injected Clock.
 */
public class TemporalReport {

    private static final Locale EN = Locale.forLanguageTag("en-GB");

    private final Clock clock;
    private final List<Loan> loans;

    public TemporalReport(Clock clock, List<Loan> loans) {
        this.clock = Objects.requireNonNull(clock);
        this.loans = List.copyOf(loans);
    }

    private LocalDate today() {
        return LocalDate.now(clock);
    }

    // --- 1. Loans by month ---
    public Map<YearMonth, Long> byMonth() {
        return loans.stream()
                .collect(Collectors.groupingBy(
                        l -> YearMonth.from(l.getLoanDate()),
                        TreeMap::new,                      // chronological order
                        Collectors.counting()));
    }

    // --- 2. Real average length of the returned ones ---
    public OptionalDouble averageLengthOfReturned() {
        return loans.stream()
                .filter(l -> l.getReturnDate().isPresent())
                .mapToLong(l -> ChronoUnit.DAYS.between(
                        l.getLoanDate(), l.getReturnDate().orElseThrow()))
                .average();
    }

    // --- 3. Distribution by day of the week ---
    public Map<DayOfWeek, Long> byDayOfWeek() {
        return loans.stream()
                .collect(Collectors.groupingBy(
                        l -> l.getLoanDate().getDayOfWeek(),
                        () -> new EnumMap<>(DayOfWeek.class),
                        Collectors.counting()));
    }

    // --- 4. The 5 most overdue ---
    public List<Loan> mostOverdue(int n) {
        LocalDate today = today();
        return loans.stream()
                .filter(l -> l.getReturnDate().isEmpty())
                .filter(l -> l.isOverdue(today))
                .sorted(Comparator.comparingLong((Loan l) -> l.daysLate(today)).reversed())
                .limit(n)
                .toList();
    }

    // --- 5. Due this week (Monday to Sunday) ---
    public List<Loan> dueThisWeek() {
        LocalDate monday = today().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
        LocalDate sunday = today().with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY));

        return loans.stream()
                .filter(l -> l.getReturnDate().isEmpty())
                .filter(l -> !l.getDueDate().isBefore(monday))
                .filter(l -> !l.getDueDate().isAfter(sunday))
                .sorted(Comparator.comparing(Loan::getDueDate))
                .toList();
    }

    // --- 6. Heat map: month x day of the week ---
    public Map<Month, Map<DayOfWeek, Long>> heatMap() {
        return loans.stream()
                .collect(Collectors.groupingBy(
                        l -> l.getLoanDate().getMonth(),
                        () -> new EnumMap<>(Month.class),
                        Collectors.groupingBy(
                                l -> l.getLoanDate().getDayOfWeek(),
                                () -> new EnumMap<>(DayOfWeek.class),
                                Collectors.counting())));
    }

    // --- 7. Punctuality by quarter ---
    public record Punctuality(long onTime, long total) {
        public double percentage() {
            return total == 0 ? 0.0 : onTime * 100.0 / total;
        }
    }

    public Map<String, Punctuality> punctualityByQuarter() {
        return loans.stream()
                .filter(l -> l.getReturnDate().isPresent())
                .collect(Collectors.groupingBy(
                        this::quarterOf,
                        TreeMap::new,
                        Collectors.collectingAndThen(
                                Collectors.toList(),
                                list -> new Punctuality(
                                        list.stream().filter(this::wasReturnedOnTime).count(),
                                        list.size()))));
    }

    private String quarterOf(Loan l) {
        LocalDate d = l.getReturnDate().orElseThrow();
        return d.getYear() + "-Q" + ((d.getMonthValue() - 1) / 3 + 1);
    }

    private boolean wasReturnedOnTime(Loan l) {
        return !l.getReturnDate().orElseThrow().isAfter(l.getDueDate());
    }

    // ------------------------------------------------------------------

    public String build() {
        StringBuilder sb = new StringBuilder();
        LocalDate today = today();

        sb.append("=".repeat(64)).append('\n');
        sb.append("  TEMPORAL LOAN REPORT -- ")
          .append(today.format(java.time.format.DateTimeFormatter
                  .ofPattern("d MMMM yyyy", EN))).append('\n');
        sb.append("=".repeat(64)).append('\n');

        sb.append("\n1. LOANS PER MONTH\n");
        byMonth().forEach((month, n) -> sb.append(String.format("   %s  %3d  %s%n",
                month, n, "▇".repeat(n.intValue()))));

        sb.append("\n2. AVERAGE LENGTH OF THE RETURNED ONES\n");
        averageLengthOfReturned().ifPresentOrElse(
                d -> sb.append(String.format("   %.1f days%n", d)),
                () -> sb.append("   (no returns yet)\n"));

        sb.append("\n3. DAY OF THE WEEK OF THE LOAN\n");
        Map<DayOfWeek, Long> byDay = byDayOfWeek();
        long maximum = byDay.values().stream().mapToLong(Long::longValue).max().orElse(1);
        Arrays.stream(DayOfWeek.values()).forEach(d -> {
            long n = byDay.getOrDefault(d, 0L);
            sb.append(String.format("   %-11s %3d %s%n",
                    d.getDisplayName(TextStyle.FULL, EN), n,
                    "▇".repeat((int) (n * 24 / maximum))));
        });

        sb.append("\n4. THE 5 MOST OVERDUE\n");
        List<Loan> overdue = mostOverdue(5);
        if (overdue.isEmpty()) {
            sb.append("   (no overdue loans)\n");
        } else {
            overdue.forEach(l -> sb.append(String.format("   %-14s %-14s due %s  %3d days%n",
                    l.getId(), l.getEmployee(), l.getDueDate(), l.daysLate(today))));
        }

        sb.append("\n5. DUE THIS WEEK\n");
        List<Loan> week = dueThisWeek();
        if (week.isEmpty()) {
            sb.append("   (none)\n");
        } else {
            week.forEach(l -> sb.append(String.format("   %-14s %-14s %s (%s)%n",
                    l.getId(), l.getEmployee(), l.getDueDate(),
                    l.getDueDate().getDayOfWeek().getDisplayName(TextStyle.FULL, EN))));
        }

        sb.append("\n6. HEAT MAP (month x day)\n");
        sb.append("            Mo  Tu  We  Th  Fr  Sa  Su\n");
        heatMap().forEach((month, byDayOfMonth) -> {
            sb.append(String.format("   %-9s",
                    month.getDisplayName(TextStyle.SHORT, EN)));
            Arrays.stream(DayOfWeek.values()).forEach(d ->
                    sb.append(String.format("%4d", byDayOfMonth.getOrDefault(d, 0L))));
            sb.append('\n');
        });

        sb.append("\n7. PUNCTUALITY BY QUARTER\n");
        punctualityByQuarter().forEach((quarter, p) ->
                sb.append(String.format("   %-9s %3d/%-3d  %5.1f %%  %s%n",
                        quarter, p.onTime(), p.total(), p.percentage(),
                        "▇".repeat((int) (p.percentage() / 5)))));

        return sb.toString();
    }
}
================================================================
  TEMPORAL LOAN REPORT -- 5 August 2026
================================================================

1. LOANS PER MONTH
   2026-04    7  ▇▇▇▇▇▇▇
   2026-05   11  ▇▇▇▇▇▇▇▇▇▇▇
   2026-06    9  ▇▇▇▇▇▇▇▇▇
   2026-07   14  ▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   2026-08    4  ▇▇▇▇

2. AVERAGE LENGTH OF THE RETURNED ONES
   18.4 days

3. DAY OF THE WEEK OF THE LOAN
   Monday       12 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   Tuesday       8 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   Wednesday     9 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   Thursday      7 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   Friday        9 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   Saturday      0
   Sunday        0

4. THE 5 MOST OVERDUE
   LN-2026-0012   Diego Alonso   due 2026-05-22   75 days
   LN-2026-0019   Marta Ruiz     due 2026-06-15   51 days
   LN-2026-0027   Nuria Vidal    due 2026-07-10   26 days
   LN-2026-0031   Diego Alonso   due 2026-07-24   12 days
   LN-2026-0035   Marta Ruiz     due 2026-08-03    2 days

5. DUE THIS WEEK
   LN-2026-0035   Marta Ruiz     2026-08-03 (Monday)
   LN-2026-0038   Nuria Vidal    2026-08-06 (Thursday)
   LN-2026-0040   Diego Alonso   2026-08-07 (Friday)

6. HEAT MAP (month x day)
            Mo  Tu  We  Th  Fr  Sa  Su
   Apr         2   1   2   1   1   0   0
   May         3   2   2   2   2   0   0
   Jun         2   2   2   1   2   0   0
   Jul         4   2   2   2   4   0   0
   Aug         1   1   1   1   0   0   0

7. PUNCTUALITY BY QUARTER
   2026-Q2    14/18    77.8 %  ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
   2026-Q3     9/13    69.2 %  ▇▇▇▇▇▇▇▇▇▇▇▇▇

Comments.

YearMonth as the TreeMap key gives chronological order for free. YearMonth implements Comparable, so 2026-04 comes before 2026-05 with no comparator at all. With strings like "April 2026" you would have to sort by hand and it would come out alphabetically.

EnumMap for DayOfWeek and Month (05-05) is not a detail: it is the right structure for enum keys, faster and with the keys already in natural order. A HashMap<DayOfWeek, Long> would give the days in arbitrary order.

Not a single loan on a Saturday or Sunday, which confirms that the test data respects the working calendar and incidentally validates the BiblioTechCalendar from exercise 1.

The fixed Clock makes the report reproducible. "Due this week" depends on what day today is, and with LocalDate.now() the report would give different results every day — impossible to verify in an automated test. With Clock.fixed, the report for 5 August 2026 is always the same. This is what will make it possible to test BiblioTech in 11-04.

And previousOrSame(MONDAY) with nextOrSame(SUNDAY) delimit the ISO week exactly, with no arithmetic on getDayOfWeek().getValue() prone to boundary errors. When today is Monday, previousOrSame returns today, which is the correct answer.

Conclusion

BiblioTech's dates have stopped being integers.

You know why the problem existed: Date and Calendar were a design disaster with months from zero, years from 1900, mutable objects that get modified when you pass them to a method, a total absence of separation between "a date" and "an instant", and —the costliest bug— a SimpleDateFormat shared between threads, which does not throw exceptions but produces silently incorrect data under load, in production, intermittently, after working perfectly in development.

And you know why java.time is different: immutable (every method returns a copy), thread-safe as a direct consequence of that, fluent (methods chain) and explicit about the time zone — with the class name declaring exactly what you carry: Local... with no zone, Zoned.../Offset... with one, Instant as a point on the timeline.

You know the core classes and the criteria for choosing: LocalDate for calendar dates (most business fields), LocalTime for times of day, LocalDateTime for both with no zone, ZonedDateTime for moments that must be shown or scheduled respecting daylight saving, OffsetDateTime for interchange, Instant for recording and measuring, and the small ones that get forgotten and are perfect for their case: YearMonth for monthly reports, MonthDay for yearly holidays, Year, Month and DayOfWeek as enums with localisable names.

You are completely clear on the golden rule: store Instant, display ZonedDateTime. A LocalDateTime identifies no moment —9:30 on 5 August happens at different times in Madrid and in Tokyo— and storing it in a database works until the day the server changes zone or a user from another country logs in, and then the data already stored is unrecoverable because nobody knows which zone it was written in.

You create with now, of (which validates: LocalDate.of(2026, 2, 30) throws DateTimeException instead of silently becoming 2 March) and parse. You query with getYear, getDayOfWeek, lengthOfMonth, isLeapYear. You manipulate with plusX, minusX and withX, remembering to assign the result because the objects are immutable and date.plusDays(21); does absolutely nothing. And you know that month arithmetic adjusts to the last valid day and is therefore not reversible: 31 January plus a month minus a month is 28 January.

You distinguish Duration from Period: machine time versus human time, seconds versus years-months-days — and you have seen the real difference on the night of the clock change, where Period.ofDays(1) keeps the time and Duration.ofDays(1) shifts it by sixty minutes. You compute with ChronoUnit.between, knowing that it truncates towards zero and that from 5 August to 4 September there are zero months. And you understand why "a month" is not a fixed number of days: it is 28, 29, 30 or 31, and Period.ofMonths(1) cannot be converted to days without a reference date.

You use TemporalAdjusters for what is awkward and error-prone by hand —firstDayOfMonth, lastInMonth(FRIDAY), next versus nextOrSame— and you write your own, because TemporalAdjuster is a functional interface and a lambda is enough: BiblioTech's next working day skipping weekends and holidays.

You handle time zones with ZoneId (which knows the historical and future rules from the IANA database) versus ZoneOffset (which is only a fixed offset), with Region/City identifiers and never ambiguous three-letter abbreviations. You know that withZoneSameInstant and withZoneSameLocal do opposite things and which one you want 99% of the time. And you know the two dangerous daylight-saving cases, demonstrated: the time that does not exist —where atZone silently shifts 2:30 to 3:30— and the time that happens twice —where getValidOffsets returns two offsets, two real instants an hour apart share the same wall clock, and a scheduled task may run twice or a later record look earlier.

You format and parse with DateTimeFormatter, which is immutable and thread-safe and therefore declared static final with no risk at all. You know the pattern letters and the two classic traps: MM is the month and mm is minutes, and YYYY is the week-based year that turns 31 December 2026 into 2027. You use ISO-8601 to persist and exchange and localised formats with an explicit Locale to display, never depending on the JVM's default Locale. And you catch DateTimeParseException —unchecked— returning Optional for user input.

You convert to and from the old API (Date.from/toInstant, GregorianCalendar.toZonedDateTime, java.sql.Date.toLocalDate) with Instant as the universal bridge, and you have settled the debt from 07-06: FileTime.toInstant() and FileTime.from(Instant) turn file timestamps into real dates.

And above all: you use Clock. You know that LocalDate.now() inside business logic makes the class impossible to test deterministically, and that injecting a ClockClock.fixed to freeze time, Clock.offset to travel into the future— turns a test that depends on the day it runs into one that will give the same result in five years' time. FineCalculator's edge cases —due today, one day late, exactly the grace days, the 20-euro cap— are now checked explicitly and reproducibly.

BiblioTech has migrated completely. Loan has LocalDate loanDate, LocalDate dueDate, Optional<LocalDate> returnDate and an Instant recordedAt for auditing, with range validation in the constructor. FineCalculator uses ChronoUnit.DAYS.between and receives its Clock. NoticeService computes with TemporalAdjusters so that notices land on a working day and report dates are the last Friday of the month. BiblioTechAdjusters knows the holidays with MonthDay and counts working days with datesUntil. The CSV carries ISO-8601 that any system in the world understands, instead of the numbers 196 and 217 that only meant something inside BiblioTech. And Configuration fixes the time zone explicitly, instead of inheriting the server's and behaving differently on deployment.

And now look at the code you have written in this lesson. It is full of switch with ->, of record, of var, of List.of and Map.ofEntries, of instanceof with a pattern, of String.repeat, of Files.readString. You have been using all of that since module 1 without anyone telling you where it came from, in which version it appeared or what problem it solved. And there is more you have not seen yet: text blocks that would make module 11's JSON readable, sealed classes that would let the compiler verify you have covered every type of Material, pattern matching in switch that would replace whole chains of if (x instanceof ...), and the virtual threads that —as you anticipated in 09-03— completely change the "one thread per connection" arithmetic of CatalogServer.

In 10-06, Java 9 and Beyond, all of that gets sorted out. You will see the six-month release cadence and what LTS releases mean, the module system that explains why the reflection of 10-03 stopped being able to open everything, the API and syntax additions version by version with criteria for what to use today, sealed classes combined with record to model algebraic types that make a switch exhaustive without a default, Java 21's pattern matching with record patterns and when guards, and virtual threads, with the CatalogServer refactoring that makes its bounded pool unnecessary — together with synchronized "pinning" and what they do not solve.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved