Java lets any reference be null, so a missing value can crash code far from where it went missing. Objects and Optional make a missing value visible, and java.time makes you say which moment, in which city, you mean.
A Java reference can always be null, and nothing in the type tells you when to expect it. This post covers the tools the JDK gives you for that: the Objects helpers, Optional for a result that might not exist, and where Optional makes code worse. Then it moves to java.time, where a missing detail causes the same kind of bug. A date, a wall-clock time and a moment on the timeline are different things, and mixing them up gives you bugs that appear twice a year.
Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java. None of them reads the real clock or your machine’s time zone, so you’ll get the same output we did.
null means “no object”, and the crash happens later
A NullPointerException is thrown when code calls a method or reads a field through a reference that’s null. The trouble is that the null usually arrives quietly, and the crash comes a few lines later. Map.get returns null for a missing key:
Map<String, String> settings = new HashMap<>();
void main() {
settings.put("theme", "dark");
IO.println(settings.get("theme").toUpperCase());
IO.println(settings.get("font").toUpperCase());
}
It prints, then stops:
DARK
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because the return value of "java.util.Map.get(Object)" is null
That message is a helpful NullPointerException message, which the part on values and references first showed. It names the exact expression that was null. It can’t tell you why the key was missing, though. That’s the real problem with null: it tells you where the crash happened, not where the value went missing.
Checking for null with Objects
The java.util.Objects class has small static methods that deal with null in one place instead of scattering if (x != null) checks. Here are the three you’ll use most:
record Account(String owner, String nickname) {
Account {
Objects.requireNonNull(owner, "owner is required");
nickname = Objects.requireNonNullElse(nickname, owner);
}
}
void main() {
IO.println(new Account("Ana", "annie"));
IO.println(new Account("Ben", null));
try {
new Account(null, "ghost");
} catch (NullPointerException e) {
IO.println("rejected: " + e.getMessage());
}
String typed = null;
String stored = "Ana";
IO.println(Objects.equals(typed, stored));
IO.println(Objects.equals(null, null));
}
It prints:
Account[owner=Ana, nickname=annie]
Account[owner=Ben, nickname=Ben]
rejected: owner is required
false
true
requireNonNull(value, message)throws straight away, in the constructor, instead of letting anullowner travel until something calls a method on it. The part on exceptions covers why the message matters.requireNonNullElse(value, fallback)returns the value, or the fallback when the value isnull. Ben had no nickname, so he got his name. The fallback itself mustn’t benull:requireNonNullElse(null, null)throws.Objects.equals(a, b)istruewhen both arenull,falsewhen only one is, and otherwise callsa.equals(b). Writingtyped.equals(stored)would have thrown, becausetypedisnull.
Java’s type system has no way to say “this String is never null“. Every reference type allows it, and javac doesn’t check. Third-party libraries such as JSpecify add annotations like @Nullable, and tools in your editor or build read them and warn you. This series is JDK-only, so it doesn’t use them, but you’ll meet them in real codebases.
Optional: a return type for “maybe no result”
Optional<T> is a small box that holds either one value or nothing. A method that returns Optional<User> says in its signature that there might be no user, so the caller can’t forget to handle that case the way they forget a null.
record User(String name, String email) {}
List<User> users = List.of(
new User("ana", "ana@example.com"),
new User("ben", null));
Optional<User> findUser(String name) {
return users.stream()
.filter(u -> u.name().equals(name))
.findFirst();
}
void main() {
IO.println(findUser("ana"));
IO.println(findUser("zoe"));
IO.println(findUser("zoe").isPresent());
Optional<String> anaEmail = Optional.ofNullable(findUser("ana").orElseThrow().email());
Optional<String> benEmail = Optional.ofNullable(findUser("ben").orElseThrow().email());
IO.println(anaEmail);
IO.println(benEmail);
IO.println(Optional.empty().equals(benEmail));
}
It prints:
Optional[User[name=ana, email=ana@example.com]]
Optional.empty
false
Optional[ana@example.com]
Optional.empty
true
findFirst already returns an Optional, which the part on streams mentioned. There are three ways to make one yourself:
Optional.of(value)when you know the value isn’tnull.Optional.ofNullable(value)when it might be. Anullbecomes an emptyOptional, as Ben’s missing email did.Optional.empty()for nothing at all.
orElseThrow() with no arguments returns the value, or throws if there isn’t one. We used it on Ana and Ben because we know they exist.
orElse runs its argument even when it isn’t needed
orElse and orElseGet both give a fallback for an empty Optional, and they look interchangeable. They aren’t, because Java evaluates a method’s arguments before it calls the method:
String loadDefault() {
IO.println(" loading the default name...");
return "guest";
}
void main() {
Optional<String> name = Optional.of("ana");
IO.println("orElse:");
IO.println(name.orElse(loadDefault()));
IO.println("orElseGet:");
IO.println(name.orElseGet(this::loadDefault));
}
It prints:
orElse:
loading the default name...
ana
orElseGet:
ana
The Optional had a value both times, so neither fallback was used. But orElse(loadDefault()) called loadDefault() first, to have an argument to pass. orElseGet takes a Supplier, a function it calls only when the Optional is empty. If the fallback is a constant like "guest", orElse is fine. If it reads a file, queries a database or builds something expensive, use orElseGet.
Asking an empty Optional for its value
get() returns the value inside an Optional, and throws when there isn’t one. That makes it no safer than a null check you forgot:
void main() {
Optional<String> email = Optional.empty();
try {
email.orElseThrow(() -> new IllegalStateException("ben has no email on file"));
} catch (IllegalStateException e) {
IO.println("caught: " + e.getMessage());
}
IO.println(email.get());
}
It prints, then stops:
caught: ben has no email on file
Exception in thread "main" java.util.NoSuchElementException: No value present
orElseThrow(supplier) lets you throw an exception that explains what was missing. get() and orElseThrow() both throw NoSuchElementException with the message No value present. They do exactly the same thing, but orElseThrow() says what it does in its name, which is why it was added in Java 10. Prefer it to get().
Optional.of(null) throws
Optional.of refuses a null, and the exception comes with no message at all:
String nickname(String name) {
return name.equals("ana") ? "annie" : null;
}
void main() {
IO.println(Optional.ofNullable(nickname("ben")));
IO.println(Optional.of(nickname("ben")));
}
It prints, then stops:
Optional.empty
Exception in thread "main" java.lang.NullPointerException
There’s no helpful message, because Optional.of checks with Objects.requireNonNull inside the JDK rather than calling a method on the null. The first stack frame names Objects.requireNonNull. If you see a bare NPE there, the fix is usually ofNullable.
Transforming an Optional without unwrapping it
map, filter and flatMap work on the value inside an Optional, and they skip the work when it’s empty, so a chain of steps needs no if at all:
record User(String name, String email) {}
Map<String, User> users = new HashMap<>();
Map<String, String> cities = new HashMap<>();
Optional<User> findUser(String name) {
return Optional.ofNullable(users.get(name));
}
Optional<String> cityOf(User user) {
return Optional.ofNullable(cities.get(user.name()));
}
void main() {
users.put("ana", new User("ana", "ana@example.pt"));
users.put("ben", new User("ben", null));
cities.put("ana", "Lisbon");
IO.println(findUser("ana").map(User::email));
IO.println(findUser("ben").map(User::email));
IO.println(findUser("zoe").map(User::email));
IO.println(findUser("ana").map(User::email).filter(e -> e.endsWith(".com")));
IO.println(findUser("ana").map(this::cityOf));
IO.println(findUser("ana").flatMap(this::cityOf));
IO.println(findUser("ben").flatMap(this::cityOf));
}
It prints:
Optional[ana@example.pt]
Optional.empty
Optional.empty
Optional.empty
Optional[Optional[Lisbon]]
Optional[Lisbon]
Optional.empty
mapapplies a function to the value. If the function returnsnull, as Ben’semail()did, you get an emptyOptional, not anOptionalholdingnull.filterkeeps the value only if the test passes. Ana’s email ends in.pt, so the result is empty.flatMapis for a function that already returns anOptional.map(this::cityOf)wrapped oneOptionalinside another.flatMapdoesn’t.
Three more methods finish the set:
record User(String name) {}
Map<String, User> users = new HashMap<>();
Optional<User> findUser(String name) {
return Optional.ofNullable(users.get(name));
}
void main() {
users.put("ana", new User("ana"));
users.put("ben", new User("ben"));
findUser("ana").ifPresentOrElse(
u -> IO.println("hello, " + u.name()),
() -> IO.println("no such user"));
findUser("zoe").ifPresentOrElse(
u -> IO.println("hello, " + u.name()),
() -> IO.println("no such user"));
IO.println(findUser("zoe").or(() -> findUser("ana")));
List<String> found = Stream.of("ana", "zoe", "ben")
.map(this::findUser)
.flatMap(Optional::stream)
.map(User::name)
.toList();
IO.println(found);
}
It prints:
hello, ana
no such user
Optional[User[name=ana]]
[ana, ben]
ifPresentOrElseruns one action for a value and another for nothing.orgives a fallback that is itself anOptional, which suits a second lookup that might also fail.orElsewould force you to pick a plain value.stream()turns a value into a one-element stream and nothing into an empty one. WithflatMap, it drops the misses from a list of lookups, so Zoe just isn’t there.
Where Optional doesn’t belong
Optional was designed as a return type, and it makes code worse in most other places. A parameter of type Optional is the clearest case, because the caller can still pass null:
String greet(Optional<String> name) {
return "Hello, " + name.orElse("guest");
}
void main() {
IO.println(greet(Optional.of("Ana")));
IO.println(greet(Optional.empty()));
IO.println(greet(null));
}
It prints, then stops:
Hello, Ana
Hello, guest
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.Optional.orElse(Object)" because "<parameter1>" is null
The parameter now has three states instead of two, and every caller has to wrap its argument. Two plain methods, greet(String name) and greet(), say the same thing more clearly. The message says <parameter1> rather than name because the class was compiled without debug information. We compiled it again with javac -g, and the message named name.
The other places to avoid:
- Fields. A field can hold
nulland your class controls it, so check it in the constructor instead.Optionalalso isn’tSerializable:Serializable.class.isAssignableFrom(Optional.class)isfalse. - Collections of
Optional. AList<Optional<User>>makes every reader unwrap every element. Leave the empty ones out, asflatMap(Optional::stream)did above. Optional<List<T>>. A list can already say “nothing”: it’s empty. ReturnList.of()and every caller’sforloop just works, with no special case.
For primitives, OptionalInt, OptionalLong and OptionalDouble avoid boxing. The part on streams showed OptionalDouble from average():
void main() {
OptionalInt best = IntStream.of(72, 95, 88).max();
IO.println(best);
IO.println(best.getAsInt());
OptionalInt none = IntStream.empty().max();
IO.println(none);
IO.println(none.orElse(0));
}
It prints:
OptionalInt[95]
95
OptionalInt.empty
0
They have fewer methods than Optional: no map, filter or flatMap. The getter is getAsInt(), not get().
The java.time types: date, time, and where
The java.time package, added in Java 8, has a separate type for each question you can ask about time. The same date and wall-clock time can be two different moments, depending on the city:
void main() {
var date = LocalDate.of(2026, 3, 29);
var time = LocalTime.of(9, 30);
var dateTime = LocalDateTime.of(date, time);
var lisbon = ZonedDateTime.of(dateTime, ZoneId.of("Europe/Lisbon"));
var tokyo = ZonedDateTime.of(dateTime, ZoneId.of("Asia/Tokyo"));
IO.println(date + " is a " + date.getDayOfWeek());
IO.println(time);
IO.println(dateTime);
IO.println(lisbon);
IO.println(tokyo);
IO.println(lisbon.toInstant());
IO.println(tokyo.toInstant());
}
It prints:
2026-03-29 is a SUNDAY
09:30
2026-03-29T09:30
2026-03-29T09:30+01:00[Europe/Lisbon]
2026-03-29T09:30+09:00[Asia/Tokyo]
2026-03-29T08:30:00Z
2026-03-29T00:30:00Z
LocalDateis a date with no time: a birthday.LocalTimeis a time with no date: “the shop opens at 09:30”.LocalDateTimeis both, with no zone.ZonedDateTimeadds a zone likeEurope/Lisbon, plus the offset from UTC that applied then,+01:00here.Instantis a point on the timeline in UTC, printed with aZ. Two computers on different continents agree on anInstant.
Both zoned values show 09:30 on the wall, but they’re eight hours apart. Every ZoneId in this post is written out. ZoneId.systemDefault() returns whatever zone the machine was set to, so code that uses it gives different answers on a laptop and on a server.
Explain it like I’m ten
A LocalDateTime is a photo of a wall clock. The photo shows 09:30 on Sunday 29 March, but it has no idea which city the clock was hanging in. If you ask “was that before or after lunch in Tokyo?”, the photo can’t answer.
A ZonedDateTime is the same photo with the city written on the back: “Lisbon”. Now anyone, anywhere, can work out when the photo was taken in their own city.
The precise version
A LocalDateTime is a year, month, day, hour, minute, second and nanosecond. It doesn’t identify a moment, so its toInstant method makes you pass an offset. A ZonedDateTime is a LocalDateTime, a ZoneId and a ZoneOffset. The zone holds the rules, from the tz database bundled with the JDK, for which offset applies at which moment. toInstant() subtracts the offset and gives you UTC.
Where the analogy breaks: a city name isn’t always enough. Some wall-clock times happen twice in a city, when the clocks go back in autumn, and some never happen at all, when they go forward in spring. That’s why a ZonedDateTime stores the offset as well as the zone. The section on daylight saving time shows both.
java.time objects never change
Every java.time type is immutable, so plusDays returns a new object and leaves the original alone. Ignoring the return value is the classic bug, and javac doesn’t warn about it:
void main() {
var due = LocalDate.of(2026, 3, 29);
due.plusDays(14);
IO.println("ignored the result: " + due);
due = due.plusDays(14);
IO.println("kept the result: " + due);
}
It prints:
ignored the result: 2026-03-29
kept the result: 2026-04-12
The first plusDays(14) built a new date and threw it away. If you’re used to the old Calendar.add, which changed the object, this looks right and does nothing. The upside of immutability is that you can share a date between threads, or keep it as a map key, without anyone changing it under you.
Adding months: the end of the month moves
Adding a month to a date keeps the day of the month when it can, and uses the last valid day when it can’t. January 31 is where that shows:
void main() {
var jan31 = LocalDate.of(2026, 1, 31);
IO.println(jan31.plusMonths(1));
IO.println(LocalDate.of(2028, 1, 31).plusMonths(1));
IO.println(jan31.plusMonths(1).plusMonths(1));
IO.println(jan31.plusMonths(2));
IO.println(LocalDate.of(2026, 3, 31).minusMonths(1));
}
It prints:
2026-02-28
2028-02-29
2026-03-28
2026-03-31
2026-02-28
2026 isn’t a leap year, so February ends on the 28th. 2028 is, so it ends on the 29th. The third and fourth lines are the surprise: one month plus one month isn’t two months. After the first step, the 31 has already become a 28, and nothing remembers it. If you bill on the last day of each month, compute each date from the start date, as plusMonths(2) does, not from the previous one.
Duration and Period
Duration measures time in seconds and nanoseconds, and Period measures it in years, months and days. They sound like the same idea, but a month has no fixed number of seconds:
void main() {
var start = LocalDate.of(2026, 1, 31);
var end = LocalDate.of(2026, 3, 29);
IO.println(Period.between(start, end));
IO.println(ChronoUnit.DAYS.between(start, end));
var boarding = LocalDateTime.of(2026, 3, 28, 22, 45);
var landing = LocalDateTime.of(2026, 3, 29, 1, 0);
IO.println(Duration.between(boarding, landing));
IO.println(Duration.ofMinutes(135).toHours());
}
It prints:
P1M29D
57
PT2H15M
2
Both print in ISO 8601 form. P1M29D is one month and 29 days. PT2H15M is two hours and fifteen minutes, where T separates the date part from the time part. ChronoUnit.DAYS.between gives a plain count when that’s what you need.
The difference matters most when a zone is involved, because a day on the calendar isn’t always 24 hours long.
Parsing and formatting dates
Every java.time type prints in ISO 8601 form, and parse reads that same form back. For any other layout you build a DateTimeFormatter from a pattern:
void main() {
var date = LocalDate.parse("2026-03-29");
var meeting = LocalDateTime.parse("2026-03-29T14:05");
IO.println(date.plusDays(1));
IO.println(meeting);
var pretty = DateTimeFormatter.ofPattern("EEEE d MMMM uuuu, HH:mm", Locale.US);
IO.println(meeting.format(pretty));
var european = DateTimeFormatter.ofPattern("dd/MM/uuuu");
IO.println(LocalDate.parse("05/04/2026", european));
IO.println(date.format(european));
}
It prints:
2026-03-30
2026-03-29T14:05
Sunday 29 March 2026, 14:05
2026-04-05
29/03/2026
EEEE is the full day name and MMMM the full month name. Those words depend on a language, so the pretty formatter names Locale.US. Without it, Java uses the machine’s locale, and the same program prints domingo on a computer set to Portuguese. The european pattern has only numbers, so it needs no locale. 05/04/2026 parsed as 5 April, because the pattern says the day comes first.
Text that doesn’t fit throws DateTimeParseException:
void main() {
try {
LocalDate.parse("29/03/2026");
} catch (DateTimeParseException e) {
IO.println("caught: " + e.getMessage());
}
IO.println(LocalDate.parse("2026-02-30"));
}
It prints, then stops:
caught: Text '29/03/2026' could not be parsed at index 0
Exception in thread "main" java.time.format.DateTimeParseException: Text '2026-02-30' could not be parsed: Invalid date 'FEBRUARY 30'
The first text has the wrong shape, and the message points at index 0, where the ISO parser expected a four-digit year. The second has the right shape and an impossible date. parse checks both, so you can’t sneak a 30 February into your data.
Daylight saving time: 1 day isn’t 24 hours
On the day clocks go forward, a day on the calendar is 23 hours long, and java.time makes you choose which one you meant. In Lisbon in 2026, that’s Sunday 29 March, when 01:00 becomes 02:00:
void main() {
var lisbon = ZoneId.of("Europe/Lisbon");
var saturdayNoon = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, lisbon);
IO.println("start: " + saturdayNoon);
IO.println("plusDays(1): " + saturdayNoon.plusDays(1));
IO.println("plusHours(24): " + saturdayNoon.plusHours(24));
IO.println("Period.ofDays(1): " + saturdayNoon.plus(Period.ofDays(1)));
IO.println("Duration.ofDays(1): " + saturdayNoon.plus(Duration.ofDays(1)));
var hours = Duration.between(saturdayNoon, saturdayNoon.plusDays(1)).toHours();
IO.println("hours from noon to noon: " + hours);
}
It prints:
start: 2026-03-28T12:00Z[Europe/Lisbon]
plusDays(1): 2026-03-29T12:00+01:00[Europe/Lisbon]
plusHours(24): 2026-03-29T13:00+01:00[Europe/Lisbon]
Period.ofDays(1): 2026-03-29T12:00+01:00[Europe/Lisbon]
Duration.ofDays(1): 2026-03-29T13:00+01:00[Europe/Lisbon]
hours from noon to noon: 23
The first line shows a quirk: Lisbon’s winter offset is zero, and an offset of zero prints as Z, not +00:00.
plusDays(1) works on the calendar. It keeps the wall-clock time, noon, and lets the offset change. plusHours(24) works on the timeline. It adds exactly 24 hours of real time, which lands at 13:00 on the new offset.
The last two lines are the trap. Duration.ofDays(1) sounds like a day, but a Duration is a number of seconds, so it’s 24 hours. Period.ofDays(1) is a calendar day. Use Period or plusDays for “same time tomorrow”, and Duration or plusHours for “exactly 24 hours from now”.
Starting at Saturday noon in Lisbon, the clocks skip from 01:00 to 02:00 on Sunday. plusDays(1) keeps the wall-clock time, so it lands on Sunday 12:00, only 23 real hours later. plusHours(24) adds 24 real hours, so it lands on Sunday 13:00.
A time that never happened, and one that happened twice
On 29 March 2026, no Lisbon clock showed 01:30. On 25 October 2026, when the clocks go back, 01:30 happened twice. Java has to pick something in both cases:
void main() {
var lisbon = ZoneId.of("Europe/Lisbon");
var rules = lisbon.getRules();
var inGap = LocalDateTime.of(2026, 3, 29, 1, 30);
IO.println("offsets for " + inGap + ": " + rules.getValidOffsets(inGap));
IO.println(ZonedDateTime.of(inGap, lisbon));
var inOverlap = LocalDateTime.of(2026, 10, 25, 1, 30);
IO.println("offsets for " + inOverlap + ": " + rules.getValidOffsets(inOverlap));
var first = ZonedDateTime.of(inOverlap, lisbon);
IO.println(first);
IO.println(first.withLaterOffsetAtOverlap());
}
It prints:
offsets for 2026-03-29T01:30: []
2026-03-29T02:30+01:00[Europe/Lisbon]
offsets for 2026-10-25T01:30: [+01:00, Z]
2026-10-25T01:30+01:00[Europe/Lisbon]
2026-10-25T01:30Z[Europe/Lisbon]
- In the gap, no offset is valid, so Java moves the time forward by the length of the gap. 01:30 became 02:30. It doesn’t throw, which means a 01:30 alarm silently rings at 02:30.
- In the overlap, two offsets are valid, and Java picks the earlier one, the summer offset
+01:00.withLaterOffsetAtOverlap()gives you the second 01:30, an hour later in real time.
If a scheduled job must run exactly once, store its time as an Instant, or check getValidOffsets when you turn a local time into a zoned one.
If you meet Date and Calendar, convert at the edge
java.util.Date and Calendar are the date classes Java had before Java 8. They’re mutable, they count months from zero, and Date.toString() quietly uses the machine’s time zone. You’ll still meet them in older libraries. Convert them to java.time the moment they enter your code, and back only when you hand a value to that library:
Date lastLoginFromOldLibrary() {
return new Date(1774779330000L);
}
void main() {
Instant lastLogin = lastLoginFromOldLibrary().toInstant();
IO.println(lastLogin);
IO.println(lastLogin.atZone(ZoneId.of("Europe/Lisbon")));
Date backForTheLibrary = Date.from(lastLogin);
IO.println(backForTheLibrary.getTime());
}
It prints:
2026-03-29T10:15:30Z
2026-03-29T11:15:30+01:00[Europe/Lisbon]
1774779330000
toInstant() and Date.from are the bridge in both directions. A Date is a count of milliseconds since 1970, so turning one into an Instant loses nothing. Going the other way drops anything finer than a millisecond: we tried an Instant ending in .123456789 and got back .123. For a GregorianCalendar, toZonedDateTime() does the same job and keeps its zone.
Clock: code you can test
A method that calls LocalDate.now() gives a different answer every day, which makes it hard to test. Pass a Clock in instead, and the caller decides what “now” means:
record Subscription(String owner, LocalDate lastDay) {}
boolean isExpired(Subscription subscription, Clock clock) {
return LocalDate.now(clock).isAfter(subscription.lastDay());
}
void main() {
var ana = new Subscription("ana", LocalDate.of(2026, 3, 29));
var lisbon = ZoneId.of("Europe/Lisbon");
var lateSunday = Clock.fixed(Instant.parse("2026-03-29T22:30:00Z"), lisbon);
var justAfter = Clock.fixed(Instant.parse("2026-03-29T23:30:00Z"), lisbon);
IO.println(LocalDate.now(lateSunday) + " expired: " + isExpired(ana, lateSunday));
IO.println(LocalDate.now(justAfter) + " expired: " + isExpired(ana, justAfter));
}
It prints:
2026-03-29 expired: false
2026-03-30 expired: true
Clock.fixed returns a clock stuck at one Instant in one zone. 22:30 UTC is 23:30 in Lisbon, still Sunday. An hour later it’s 00:30 on Monday in Lisbon, so the subscription has expired, even though it’s still Sunday in UTC. A test can check both sides of midnight without waiting for midnight.
In production you’d pass Clock.system(ZoneId.of("Europe/Lisbon")), or the zone your users are in. Every now method in java.time has an overload that takes a Clock, so one parameter covers them all.
What to remember
- Any reference can be
null, and javac doesn’t check. UseObjects.requireNonNullat the door,requireNonNullElsefor defaults, andObjects.equalsto compare values that might benull. - Return
Optionalwhen a method might have no result. Don’t use it for fields, parameters or collections, and return an empty list instead ofOptional<List>. orElseevaluates its fallback every time, andorElseGetonly when it’s needed. PreferorElseThrow()toget(), and useofNullablewhen a value might benull.java.timeobjects are immutable.plusDaysreturns a new date, so keep the result.LocalDateTimehas no zone and isn’t a moment. UseZonedDateTimewith an explicitZoneId, orInstant, when the moment matters.- Across a daylight saving change,
plusDays(1)andPeriodkeep the wall-clock time, whileplusHours(24)andDurationadd real hours. - Pass a
Clockinto code that needs “now”, and test it withClock.fixed.
Say which time zone you mean, every time, and let the type say whether a value might be missing.