Nulls, validation and failing fast
Tony Hoare called null references his billion-dollar mistake. He invented them in 1965 because it was easy to do, and spent the rest of his career watching the consequences. In Java you cannot avoid them, but you can arrange your code so they have almost nowhere to appear.
Fail fast, at the boundary
The worst thing a program can do with bad input is accept it and carry on. A
null that is stored, passed through four methods and then dereferenced gives
you a NullPointerException in a class that did nothing wrong, with a stack
trace pointing nowhere near the mistake.
So check at the edge — where data enters your code — and never again.
public record Subscriber(String name, String pincode, Plan plan, LocalDate startedOn) {
public Subscriber {
Objects.requireNonNull(name, "name must not be null");
Objects.requireNonNull(pincode, "pincode must not be null");
Objects.requireNonNull(plan, "plan must not be null");
Objects.requireNonNull(startedOn, "startedOn must not be null");
name = name.strip();
if (name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
if (!pincode.matches("[1-9]\\d{5}")) {
throw new IllegalArgumentException(
"pincode must be six digits not starting with zero, got [" + pincode + "]");
}
}
}
That is the capstone's actual constructor, and it is doing a lot of work. A
compact constructor runs before the fields are assigned, so an invalid
Subscriber cannot exist. Not "should not" — cannot. Every method on the class,
and every method receiving one, is then free of defensive checks, because there
is no path by which a bad one could arrive.
That is the goal: validate once, at construction, and let the type carry the guarantee.
Objects.requireNonNull with a message rather than a bare dereference, because
"name must not be null" names the field and NullPointerException at line 42
does not. Java 14 improved the built-in messages considerably, but an explicit
one is still clearer and documents the contract.
Which exception
| Situation | Throw |
|---|---|
| An argument is null and should not be | NullPointerException (via requireNonNull) |
| An argument has a bad value | IllegalArgumentException |
| The object is in the wrong state for this call | IllegalStateException |
| A rule of your domain was broken | your own exception |
| Something the caller can recover from | a checked exception |
The last two are worth pausing on. A domain rule deserves a domain exception:
public class InsufficientStockException extends RuntimeException {
private final String sku;
private final int available;
public InsufficientStockException(String sku, int requested, int available) {
super("only %d of %s left, %d requested".formatted(available, sku, requested));
this.sku = sku;
this.available = available;
}
public String sku() { return sku; }
public int available() { return available; }
}
The caller can catch exactly this and tell the customer how many are left —
which it cannot do if you threw RuntimeException with a sentence in it.
Carry the data, not just the message.
Never return null from a collection method
// bad
public List<Delivery> deliveriesFor(String customer) {
if (!known(customer)) {
return null;
}
…
}
// good
public List<Delivery> deliveriesFor(String customer) {
if (!known(customer)) {
return List.of();
}
…
}
An empty list works in a for loop, in a stream, in isEmpty(). A null throws.
Returning null forces every caller to write a check, and the one who forgets
finds out in production.
Same for arrays, maps and strings. List.of(), Map.of(), new int[0], "".
Optional, used properly
Optional is for a return value that may legitimately be absent.
public Optional<LocalDate> busiestDay(List<Delivery> deliveries) {
return deliveries.stream()
.collect(Collectors.groupingBy(Delivery::date, Collectors.summingInt(Delivery::tiffins)))
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey);
}
The signature says there may be no busiest day — an empty list has none — and the compiler makes the caller deal with it:
billing.busiestDay(deliveries)
.ifPresent(d -> System.out.println("Busiest day: " + d));
Where Optional should not go:
- As a field. It is not serialisable and adds an allocation per object.
- As a parameter.
foo(Optional<String> name)makes the caller wrap; two overloads read better. - In a collection.
List<Optional<String>>is a list you now have to unwrap twice.
And never, ever:
if (optional.isPresent()) {
use(optional.get());
}
That is a null check with more typing. ifPresent, map, orElse,
orElseThrow exist precisely so you never write get().
Validate at the boundary, trust inside
The pattern the whole capstone follows:
CSV text → Delivery.parse → Delivery (valid, always) → everything else
DeliveryStore deals with text, which may be malformed. Once it has produced a
Delivery, no other class checks anything — the type is the guarantee.
This is why Parsed<T> exists:
public record Parsed<T>(List<T> rows, List<String> problems) {
public boolean hasProblems() { return !problems.isEmpty(); }
}
A single bad row must not lose the other four hundred, so parsing returns both the rows that worked and a description of the ones that did not, and the calling command decides what to do. The alternative — throwing on the first bad line — means one typo in a CSV makes the whole month unreadable.
Do not over-defend
The counterweight, because this advice has a failure mode too.
public long billPaise(Subscriber s, int tiffins) {
if (s == null) return 0; // no
if (tiffins < 0) return 0; // no
if (s.plan() == null) return 0; // no
…
}
Three checks that cannot fire — Subscriber guarantees a non-null plan — and
each one silently returns a wrong answer instead of failing. Returning 0 for
an invalid input is worse than throwing, because the bill is now quietly wrong
and nobody will know until somebody reconciles it.
Check at the boundary. Inside, trust your own types. If you feel the need to re-check something a constructor already guaranteed, either the constructor is not guaranteeing it or the check is superstition.
Check your work
Why fail fast: a bad value accepted at the edge surfaces far away, in code that did nothing wrong.
What a compact constructor buys: it runs before assignment, so an invalid instance cannot exist.
Why requireNonNull with a message: it names the field; a bare
dereference does not.
Why a domain exception carries data: the caller can act on it — "only 3 left" — rather than parsing a sentence.
Why never return null from a collection method: an empty collection works everywhere; null forces a check every caller can forget.
What Optional is for: a return value that may legitimately be absent —
not fields, not parameters, not collections.
Why isPresent() plus get() is wrong: it is a null check with more
typing.
Why Parsed<T> keeps the failures: one bad row must not lose the other four
hundred.
Why over-defending is its own bug: returning a default for invalid input makes the answer quietly wrong instead of loudly absent.
Practice
- Construct a
Subscriberwith a null plan and read the message. - Remove one
requireNonNullfrom the compact constructor, then build an object with that field null and find where it eventually fails. - Find a method of yours returning null for a collection. Change it to
List.of()and delete the null checks it made necessary. - Write
InsufficientStockExceptioncarrying the SKU and the available count, then catch it and print a message a customer could act on. - Rewrite
busiestDayto returnnullinstead ofOptional. Say what the caller now has to do. - Find any
isPresent()followed byget()and rewrite it. - Put an
Optionalfield on a class and try to serialise it. - Feed the capstone a CSV with one malformed row among five. Confirm the other four survive.
- Change the parser to throw on the first bad row instead. Decide which you would rather have at month end.
- Find a null check in your code that can never fire. Remove it and explain why it was safe to.
Next: reading other people's code, and reviewing it without being unbearable.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship