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
- Why the problem existed:
DateandCalendar - The classic bug: a shared
SimpleDateFormat - The design principles of
java.time - The core classes and how to choose
InstantversusLocalDateTime: the golden rule- Creation:
now,of,parse - Querying: getting parts and properties
- Manipulation: methods that return copies
- Comparison
DurationversusPeriodChronoUnit.between- Temporal adjusters:
TemporalAdjusters - Time zones:
ZoneIdandZoneOffset - Daylight saving time and its two dangerous cases
- Formatting and parsing with
DateTimeFormatter Locale: month and day names in English- Interoperability with the old API and with
FileTime Clock: the injectable time source- BiblioTech: the complete migration
- Common Mistakes and Tips
- Exercises
- Why the problem existed:
Date and Calendar
Date and CalendarTo 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) |
- The classic bug: a shared
SimpleDateFormat
SimpleDateFormatThis 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:07Why 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 finalis 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.
- The design principles of
java.time
java.timeFour 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-04The 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.
- 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:
- Is there only a date, with no time? →
LocalDate. That is the case for most business fields: due dates, sign-up dates, terms. - Is it a moment that has to be recorded or compared across systems? →
Instant. - 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()); // 365YearMonth 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.
Instant versus LocalDateTime: the golden rule
Instant versus LocalDateTime: the golden ruleThis 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())); // PT7HThe golden rule
Store
Instant(orOffsetDateTimein UTC). DisplayZonedDateTime. 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.
- Creation:
now, of, parse
now, of, parseThe 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:
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:
- 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()); // 123000000Month 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"))); // AugustAnd 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
- 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:59The classic mistake: ignoring the returned value
LocalDate dueDate = LocalDate.of(2026, 8, 5);
dueDate.plusDays(21); // DOES NOTHING!
System.out.println(dueDate); // 2026-08-05The 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 worksIDEs 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-28java.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
- 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 timeRule: to know whether two moments are the same moment, use isEqual or compare the Instants. equals on ZonedDateTime is almost never what you want.
Duration versus Period
Duration versus PeriodTwo 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 daysWhy "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))); // 29A 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)); // 28The 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.
ChronoUnit.between
ChronoUnit.betweenFor "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)); // 0The 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))); // 1And the sign indicates the direction:
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)); // 9045ChronoUnit.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 daysUse ChronoUnit to compute ("it is 8 days late") and Period to present ("1 year, 2 months and 5 days ago").
- Temporal adjusters:
TemporalAdjusters
TemporalAdjustersA 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-11The 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: 20The 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.
- Time zones:
ZoneId and ZoneOffset
ZoneId and ZoneOffsetZoneId 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 600The 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? falseThe 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:00One single Instant, six different wall clocks. That is exactly the golden rule of section 5 in action.
- 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: PT1HatZone 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
LocalDateTimegives the wrong order; sorting byInstantgives 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.
- Formatting and parsing with
DateTimeFormatter
DateTimeFormatterDateTimeFormatter 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)); // 20260805ISO-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 2026The 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:30A single formatter that accepts both forms. It is exactly what a CSV importer receiving data from several sources needs.
Locale: month and day names in English
Locale: month and day names in EnglishWithout 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 agostoNever 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 2026And 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
- Interoperability with the old API and with
FileTime
FileTimeIn 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: 1FileTime.toInstant() and FileTime.from(Instant) are the bridge in both directions. The debt from 07-06 is settled.
Clock: the injectable time source
Clock: the injectable time sourceThis 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:
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 EURThis 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.
- 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=P21DNotice 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.903ZAny 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:
boolean isWorkingDay(LocalDate): not a Saturday, a Sunday or a holiday (useMonthDayfor the fixed holidays and compute Easter with Butcher's algorithm for the moveable ones).LocalDate nextWorkingDay(LocalDate)andLocalDate plusWorkingDays(LocalDate, int).long workingDaysBetween(LocalDate, LocalDate)usingdatesUntil.Map<Month, Long> workingDaysByMonth(int year)with streams.List<LocalDate> holidaysOf(int year)sorted, distinguishing fixed from moveable ones.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:
record Reservation(String id, String room, String employee, ZonedDateTime start, Duration length).- A derived
ZonedDateTime end(). boolean overlapsWith(Reservation)that works even if the bookings are in different zones (it compares instants).- A
ReservationManagerwithResult<Reservation> book(...)(theResult<T>from 10-01) that rejects overlaps, bookings outside the working hours of the room's office, and bookings in the past. String scheduleFor(String employee, ZoneId employeeZone)that shows all their bookings in their zone.- Demonstrate with code the case of a booking created in Tokyo that an employee in Madrid sees on the previous day.
- 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:
- Loans by
YearMonth, in chronological order. - The real average length of returned loans, in days, using
ChronoUnit. - The distribution by day of the week of the loan (
DayOfWeek), with a histogram. - The 5 most overdue loans as of the injected
Clock's date. - Loans falling due this week (Monday to Sunday of the current week), using
TemporalAdjusters. - A "heat map" by month and day of the week:
Map<Month, Map<DayOfWeek, Long>>. - 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-31Comments.
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 Clock —Clock.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
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
