Skip to main content

Null Object

complexitypopularity

Replace `null` with a real object that implements the same interface but does nothing (or does something safely neutral), so callers never need a null-check.

The problem

A support ticket may or may not have someone assigned to it. Perfectly ordinary situation, except every single piece of code that touches assignee now has to ask first.

if (ticket.assignee != null) {
ticket.assignee.notify(ticket);
}

One check is fine. The tenth one, written by someone who has never seen the first nine, is where the bug lives - the guard that got forgotten, the NullPointerException three months into production, the code review comment that says "did you check for unassigned tickets here?" for the hundredth time.

The solution

Give "nobody assigned" its own class, and make that class implement the exact same interface as a real assignee. UnassignedSlot.notify() simply does nothing. Every caller that used to null-check now calls assignee.notify(ticket) unconditionally, and it is always safe, because assignee is never actually null - it is either a working handler or a handler that quietly declines to do anything.

The field never needs to be checked again, because it never needs to be absent. It always holds something that answers to the interface; the two implementations just answer differently.

ApplicationTicketRouterTicketAssignee (whoever is plugged in)route(ticket)1searchForMatch(ticket)2assignee = new UnassignedSlot()3escalate()4assignee.notify(this)5(no-op)6assignee = new HumanAgent("Priya")7assignee.notify(this)8
  1. 1A new ticket arrives with no obviously right human for it yet.
  2. 2The router looks for a human match and finds none this time.
  3. 3Instead of leaving the field null, the router plugs in a do-nothing stand-in that still speaks the Assignee interface.
  4. 4Later, an unrelated code path escalates the ticket without knowing or caring whether anyone is assigned.
  5. 5Ticket calls notify() through the interface, oblivious to which concrete class answers.
  6. 6UnassignedSlot's notify() does nothing at all. The call succeeds, nothing happens, no exception ever threatens the caller.
  7. 7Some time later a human actually claims the queue. Same field, same interface, no code at the call site changes.
  8. 8Same line of code as before, but now it pages a real person. Ticket never found out the difference.

Structure

Notice Ticket has exactly one relationship in this diagram: it uses Assignee, the interface. It has no relationship to HumanAgent or UnassignedSlot at all - it cannot tell them apart, and that is the entire design.

«interface»Assigneename()notify(ticket)SHAREDHumanAgentname()notify(ticket)REALUnassignedSlotname()notify(ticket)NULLTicketassignee: Assigneeescalate()CLIENT
implementsuses

Code

Same example three ways: a ticket that always has an assignee, real or not.

// Every caller has to remember the guard, and someone eventually forgets.
class Ticket is
field assignee: Assignee or null
 
method escalate() is
if assignee != null then
assignee.notify(this)
// else: silently do nothing, and hope nobody needed to know that.
 
method assigneeName() is
if assignee != null then
return assignee.name()
else
return "Unassigned"
// Ticket never checks for null. Every path through this class is the same
// whether a real agent or the do-nothing stand-in answers the call.
class Ticket {
private assignee = new UnassignedSlot(); // never left null
 
escalate() {
this.assignee.notify(this); // safe unconditionally
}
 
assigneeName() {
return this.assignee.name(); // "Unassigned", not a crash
}
}
// Swapping in a real handler later touches one field, zero call sites:
ticket.assignee = new HumanAgent("Priya");
// The shared interface every stand-in and every real handler implements.
interface Assignee is
method name()
method notify(ticket)
 
// A real, working implementation.
class HumanAgent implements Assignee is
field agentName: string
 
constructor HumanAgent(agentName) is
this.agentName = agentName
 
method name() is
return agentName
 
method notify(ticket) is
pager.page(agentName, "New activity on ticket #" + ticket.id)
 
// The null object: same interface, nothing happens.
class UnassignedSlot implements Assignee is
method name() is
return "Unassigned"
 
method notify(ticket) is
// Deliberately empty. There is nobody to notify, and that is fine.
return
 
// The client never null-checks. It just calls through the interface.
class Ticket is
field id
field assignee: Assignee
 
constructor Ticket(id) is
this.id = id
this.assignee = new UnassignedSlot()
 
method escalate() is
assignee.notify(this)
 
method assigneeName() is
return assignee.name()
 
// The router swaps in a real handler when one becomes available.
class TicketRouter is
method route(ticket) is
agent = findHumanMatch(ticket)
if agent != null then
ticket.assignee = agent
// else: leave the UnassignedSlot from the constructor in place.

When to use it

  • A collaborator field is legitimately optional (no handler, no logger, no discount) and every consumer of that field currently null-checks before using it.
  • The "do nothing" behavior is genuinely safe - there is no case where silently skipping the operation could be mistaken for success when it matters.

Pitfalls

  • Hiding a real problem. If "unassigned" is itself something the business needs to notice and act on, a Null Object that swallows the call is the wrong tool - it makes the absence invisible exactly where visibility mattered.
  • Overreach. Not every optional field wants a full class hierarchy. A single Optional or a default value is sometimes just simpler than standing up an interface and an implementation for it.
  • Debuggability. "Nothing happened" and "something happened but did nothing useful" look identical from outside. Logging inside the null object (carefully, so it stays inert) can save an afternoon.

Don't confuse it with

  • State. Both look like "an object representing emptiness that implements a shared interface." State exists to drive transitions between meaningfully different behaviors; Null Object exists purely to make one particular case harmless. If your empty state needs to become a different state later, you are already doing State.
  • Strategy. A no-op strategy and a Null Object can be byte-for-byte identical code. The distinction is intent: Strategy is chosen because the algorithm genuinely varies; Null Object is chosen because "there is nothing here" needed to stop being a special case.
  • Default parameter values. Passing 0 or "" as a default is not this pattern - Null Object specifically replaces an object reference that would otherwise need a null-check before its methods are called.

Check yourself

Question 1 of 5

What does a Null Object actually eliminate?