Low-level designDeep dive8 min

Null Object

Replace a missing object with one that does nothing. A null object implements the same interface with neutral behavior, so callers stop checking for null, and a new feature can't forget a check that no longer exists.

Watch it evolve1 / 8
The problem

A checkout with optional loyalty cards

A Customer has a LoyaltyCard, or no card at all for a guest. Checkout handles both.

80
Start
Stage 1
Stage 2
Final

1 · The situation

Members and guests at a cafe

A cafe's checkout gives loyalty members 10% off and a point for every dollar. Guests have no card. The first version models that as a Customer whose card is a LoyaltyCard or nothing: null in Java and JavaScript, None in Python, an empty std::optional in C++.

Checkout.charge() builds the receipt line, checking for a card before each use of it. Python writes discount_percent().

The first versionJava
class LoyaltyCard {
    private int points;

    LoyaltyCard(int points) {
        this.points = points;
    }

    int discountPercent() {
        return 10;
    }

    int earn(int cents) {
        int earned = cents / 100;
        points += earned;
        return earned;
    }

    int points() {
        return points;
    }
}

record Customer(String name, LoyaltyCard card) {}

class Checkout {
    static String money(int cents) {
        return String.format("$%d.%02d", cents / 100, cents % 100);
    }

    String charge(Customer c, String item, int cents) {
        int discount = c.card() != null ? c.card().discountPercent() : 0;
        int total = cents * (100 - discount) / 100;
        String line = c.name() + ": " + item + " " + money(cents);
        if (discount > 0) line += ", " + discount + "% off, " + money(total);
        if (c.card() != null) {
            int earned = c.card().earn(total);
            line += ", +" + earned + " points (" + c.card().points() + ")";
        }
        return line;
    }
}

public class Main {
    public static void main(String[] args) {
        Checkout checkout = new Checkout();
        Customer[] customers = {
            new Customer("Ana", new LoyaltyCard(40)),
            new Customer("Guest", null),
        };
        for (Customer c : customers) System.out.println(checkout.charge(c, "breakfast", 1250));
    }
}
LoyaltyCard− points: int~ discountPercent(): int~ earn(int): int~ points(): int«record»Customer− name: String− card: LoyaltyCardCheckout~ money(int): String~ charge(Customer, String, int): String

The class diagram of this version, drawn from its code: a customer may or may not hold a card.

A member and a guest each order breakfast:

Ana: breakfast $12.50, 10% off, $11.25, +11 points (51)
Guest: breakfast $12.50

2 · The change

A new feature forgets the check

The cafe adds a birthday treat: a free cookie for members in their birthday month. The developer adds one line to charge(), and it works for members:

The new lineJava
class Checkout {
    static String money(int cents) {
        return String.format("$%d.%02d", cents / 100, cents % 100);
    }

    String charge(Customer c, String item, int cents) {
        int discount = c.card() != null ? c.card().discountPercent() : 0;
        int total = cents * (100 - discount) / 100;
        String line = c.name() + ": " + item + " " + money(cents);
        if (discount > 0) line += ", " + discount + "% off, " + money(total);
        if (c.card() != null) {
            int earned = c.card().earn(total);
            line += ", +" + earned + " points (" + c.card().points() + ")";
        }
        if (c.card().birthdayMonth()) line += ", free birthday cookie";
        return line;
    }
}

It doesn't check for a missing card, and the first guest breaks the checkout:

Ana: breakfast $12.50, 10% off, $11.25, +11 points (51), free birthday cookie
Guest: checkout failed, no card

The member gets a cookie; the guest gets an error, caught here so the program can say so.

Each language fails in its own way, and none of them at compile time:

Language

What the guest's checkout hits

Java

NullPointerException: Cannot invoke "LoyaltyCard.birthdayMonth()" because the return value of "Customer.card()" is null

C++

std::bad_optional_access from value(); with -> instead, undefined behavior

JavaScript

TypeError: Cannot read properties of null (reading 'birthdayMonth')

Python

AttributeError: 'NoneType' object has no attribute 'birthday_month'

The cost isn't one missing check. Every use of the card needs one, and every future feature, a receipt footer, a members-only special, has to remember. The null checks are the same decision, "a guest gets nothing", written out at every call.

3 · The refactor

Give guests a card that does nothing

Start from the version with the birthday treat. Make "no card" an object that answers every question the way a guest should get it:

Loyalty declares what checkout asks: discountPercent(), earn(cents), which returns the receipt's points note, and birthdayTreat(). MemberCard is the old card, implementing it.

Java
class MemberCard implements Loyalty {
    private int points;
    private final boolean birthdayMonth;

    MemberCard(int points, boolean birthdayMonth) {
        this.points = points;
        this.birthdayMonth = birthdayMonth;
    }

    public int discountPercent() {
        return 10;
    }

    public String earn(int cents) {
        int earned = cents / 100;
        points += earned;
        return ", +" + earned + " points (" + points + ")";
    }

    public boolean birthdayTreat() {
        return birthdayMonth;
    }
}
«interface»Loyalty+ discountPercent(): int+ earn(int): String+ birthdayTreat(): booleanMemberCard− points: int− birthdayMonth: boolean+ discountPercent(): int+ earn(int): String+ birthdayTreat(): booleanNoCard~ INSTANCE: NoCard+ discountPercent(): int+ earn(int): String+ birthdayTreat(): boolean«record»Customer− name: String− loyalty: LoyaltyCheckout~ money(int): String~ charge(Customer, String, int): String

The member's receipt is unchanged, and the guest checks out:

Ana: breakfast $12.50, 10% off, $11.25, +11 points (51), free birthday cookie
Guest: breakfast $12.50
Null checks in Checkout
3
With a null object
0

Places in charge() that must test for a missing card, counting the birthday line that forgot. With NoCard, none.

4 · The pattern

Null Object: absence with neutral behavior

Null Object replaces a null reference with an object that implements the expected interface and does nothing, or returns neutral values: zero, an empty string, false, an empty list. Callers treat it like any other implementation, so the checks for absence disappear.

«interface»Loyalty+ discountPercent(): int+ earn(int): String+ birthdayTreat(): booleanMemberCard− points: int− birthdayMonth: boolean+ discountPercent(): int+ earn(int): String+ birthdayTreat(): booleanNoCard~ INSTANCE: NoCard+ discountPercent(): int+ earn(int): String+ birthdayTreat(): boolean«record»Customer− name: String− loyalty: LoyaltyCheckout~ money(int): String~ charge(Customer, String, int): String

The roles in the cafe's code: Loyalty is the interface, MemberCard the real implementation, and NoCard the null object.

Role

In the cafe

Its job

Abstraction

Loyalty

What clients ask of a loyalty program

Real object

MemberCard

Gives the discount, earns points, knows the birthday

Null object

NoCard

Answers every question with the neutral value

Client

Checkout

Asks without testing for absence

A guest's checkout: every question gets the neutral answer, and nothing fails.

The neutral answer has to be right for every caller. "No discount, no points, no treat" is exactly what a guest should get, which is why NoCard works. If callers needed to treat a guest differently, say to offer a sign-up, a null object would be the wrong tool: an Optional makes each caller decide, which is then the point. Checking the type of the null object would be the worst of both.

5 · What it costs

Silence can hide bugs

  • Errors can look like normal behavior. If a member's card failed to load and the code quietly used NoCard, the member would pay full price and no one would notice. Use a null object where absence is normal, not to cover failures.
  • The neutral answer must suit every caller. A method whose "do nothing" isn't obvious, such as a balance used in a calculation, may need a real decision, not a default.
  • Someone can still pass a real null. A null object helps only if code consistently uses it; constructors and factories should never hand out null.

Situation

Null object?

Absence is normal and should mean "do nothing"

Yes

Absence is an error that must be reported

No: fail loudly, or return an error

Callers must react differently to absence

An Optional or a result type makes them handle it

Absence should mean a specific default, not nothing

A special-case object with that behavior

6 · Getting it right

Neutral, shared, and never null

  • Make the null object do nothing, visibly. Return neutral values; don't invent data, such as a fake card number.
  • Share one instance. A null object has no state, so one shared instance, like Collections.emptyList() in Java, is enough.
  • Never hand out null. Factories and constructors return the null object for the absent case, so callers can rely on always getting something.
  • Keep it in step with the interface. A new method on Loyalty needs a neutral answer in NoCard too, and in Java and C++ the compiler makes sure of it.

Language

Absence without null

Java

A null object implementing the interface; or Optional where callers must decide

C++

A null object behind the interface; or std::optional, whose value() throws when empty

JavaScript

A null object with the same methods; optional chaining ?. is the shorter, unchecked alternative

Python

A null object matching the Protocol; or None with checks a type checker can enforce

7 · Look-alikes

Other ways to handle absence

Null Object is one answer to "what if it isn't there?". The others differ in who decides what happens:

Approach

Who decides what absence means

Good when

Null Object

The null object, once, for every caller

Absence means "do nothing" everywhere

Optional or a result type

Each caller, forced by the type

Callers need to react differently

Special Case

A subclass with specific behavior for a case

Absence, or another case, has its own rules

Null checks

Each caller, if it remembers

One or two uses that won't grow

A null object is a special case of Strategy or State: one more implementation of the interface, chosen when the real one is missing. Its only job is to make the absence uneventful.

8 · In practice

Null objects in the standard library

Java's Collections.emptyList() returns an immutable empty list: code that loops over it, asks its size or checks whether it contains something works as if handed any list, with no null check. Returning an empty collection instead of null is the most common null object of all.

The practice checks have no Null Object detector, and all three versions read as clean: a forgotten null check is a run-time failure the checks can't see. The output of the second version shows it instead.

Check yourself

4 questions

1. The cafe adds a members-only monthly special shown on the receipt. What does NoCard return for it?
2. A member's card fails to load from the database. Should the code use NoCard?
3. Why can one NoCard instance be shared by every guest?
4. When is an Optional better than a null object?

Takeaways

Remember this

  • Null Object replaces a null with an object that implements the interface and does nothing.
  • Callers stop checking for absence, so new features can't forget a check.
  • Use it where absence is normal and means "do nothing"; report real errors instead.
  • Never hand out null: factories return the null object, often one shared instance.
  • Choose Optional when callers must react to absence differently.