RizTech Academy logo
RizTech Academy
Best Practices: Code Others Can ReadLesson 4 of 530 min

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

  1. Construct a Subscriber with a null plan and read the message.
  2. Remove one requireNonNull from the compact constructor, then build an object with that field null and find where it eventually fails.
  3. Find a method of yours returning null for a collection. Change it to List.of() and delete the null checks it made necessary.
  4. Write InsufficientStockException carrying the SKU and the available count, then catch it and print a message a customer could act on.
  5. Rewrite busiestDay to return null instead of Optional. Say what the caller now has to do.
  6. Find any isPresent() followed by get() and rewrite it.
  7. Put an Optional field on a class and try to serialise it.
  8. Feed the capstone a CSV with one malformed row among five. Confirm the other four survive.
  9. Change the parser to throw on the first bad row instead. Decide which you would rather have at month end.
  10. 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