Skip to content

Null Object — Find the Bug

Category: Control-Flow Patterns — return a do-nothing object that satisfies the expected interface instead of null.

11 buggy snippets across Go, Java, Python.


Bug 1: Null Object Throws (Java)

Logger NULL = new Logger() {
    public void info(String msg) {
        throw new UnsupportedOperationException();   // BUG
    }
};

Symptoms: Every call site crashes instead of silently doing nothing.

Find the bug A Null Object's methods must be no-ops. Throwing defeats the entire point — callers would still have to guard.

Fix

public void info(String msg) { /* no-op */ }

Lesson

If "do nothing" is not safe here, you don't want a Null Object — you want Fail Fast. A Null Object never throws.


Bug 2: New Instance Per Call (Java)

public Customer find(String id) {
    Customer c = db.lookup(id);
    return c != null ? c : new NullCustomer();   // BUG: fresh instance each time
}

if (customer == Customer.GUEST) { ... }          // never true!

Symptoms: Identity checks against the singleton fail; needless allocation on every miss.

Find the bug A new Null Object is allocated per call. It's stateless, so this wastes memory and breaks `==` identity comparisons against the shared `GUEST`.

Fix

return c != null ? c : Customer.GUEST;   // shared singleton

Lesson

A stateless Null Object is a shared immutable singleton. Never new it per call.


Bug 3: Typed nil Returned as Interface (Go)

func New(enabled bool) Logger {
    var l *consoleLogger          // nil pointer
    if enabled {
        l = &consoleLogger{}
    }
    return l                      // BUG: returns a non-nil interface wrapping a nil pointer
}

log := New(false)
if log == nil { return }          // FALSE — check doesn't fire
log.Info("x")                     // PANIC: nil pointer dereference

Symptoms: log == nil is false, yet log.Info panics.

Find the bug A `nil` *pointer* boxed in an interface is **not** a `nil` *interface* — its type word is set. The nil check passes through and the method call dereferences nil.

Fix

func New(enabled bool) Logger {
    if !enabled {
        return nopLogger{}   // a real no-op value
    }
    return &consoleLogger{}
}

Lesson

The Null Object pattern is the fix for Go's nil-interface trap: return a concrete no-op value, never a typed nil.


Bug 4: Null Object for a Required Dependency (Java)

PaymentGateway gw = lookup();          // returns NullGateway when unconfigured
gw.charge(order.total());              // BUG: silently does nothing — order ships unpaid

Symptoms: Orders complete "successfully" but no money is charged. No error, no log.

Find the bug A payment gateway is a *required* dependency. A no-op `charge()` turns a misconfiguration into a silent financial bug. Null Object is wrong here.

Fix

PaymentGateway gw = Objects.requireNonNull(lookup(), "payment gateway not configured");
gw.charge(order.total());

Lesson

Required collaborators must fail fast. Only give a Null Object to optional collaborators whose silence is acceptable.


Bug 5: Mixed null and Null Object Returns (Java)

public Customer find(String id) {
    if (id == null) return null;                 // BUG: sometimes null...
    Customer c = db.lookup(id);
    return c != null ? c : Customer.GUEST;       // ...sometimes Null Object
}

Symptoms: Callers must check for both null and GUEST. Worst of both worlds.

Find the bug The method returns `null` on one path and a Null Object on another. The whole point was to never return `null`.

Fix

public Customer find(String id) {
    if (id == null) return Customer.GUEST;       // consistent contract
    Customer c = db.lookup(id);
    return c != null ? c : Customer.GUEST;
}

Lesson

Pick one contract. A method that returns a Null Object must never also return null.


Bug 6: Dishonest Neutral Value (Python)

class NullAccount:
    def balance(self): return 0      # BUG: 0 means "broke", not "no account"

acct = find_account(uid) or NullAccount()
if acct.balance() < required:
    deny()                            # denies a real customer whose lookup failed

Symptoms: A failed lookup is indistinguishable from a zero balance; real users are wrongly denied.

Find the bug `balance() == 0` is not an honest neutral value for "account not found." The Null Object lies, and the lie propagates into a wrong decision.

Fix

def find_account(repo, uid):
    return repo.get(uid)             # return None; force the caller to handle absence

acct = find_account(repo, uid)
if acct is None:
    raise AccountNotFound(uid)       # distinct from "balance is zero"

Lesson

When no neutral value is truthful, Null Object is the wrong pattern. Use Optional/error so "absent" and "zero" stay distinct.


Bug 7: Null Object With Hidden Mutable State (Java)

public final class NullCounter implements Counter {
    private int count = 0;                    // BUG: state in a "null" object
    public void incr() { count++; }
    public int total() { return count; }
}
Counter NULL = new NullCounter();             // shared, but now mutated by everyone

Symptoms: The shared singleton accumulates counts across unrelated callers; not thread-safe.

Find the bug A Null Object must be stateless to be safely shared. This one mutates shared state across all callers and races under concurrency.

Fix

public final class NullCounter implements Counter {
    public void incr()      { /* no-op */ }
    public int total()      { return 0; }     // neutral, stateless
}
Counter NULL = new NullCounter();             // safe to share

Lesson

A "no-op" that secretly keeps state isn't a Null Object. Keep it immutable and stateless so one instance can be shared.


Bug 8: getattr Null Object Swallows a Typo (Python)

class Null:
    def __getattr__(self, name):
        return lambda *a, **k: None    # BUG: absorbs EVERY attribute

gw = payment_gateway or Null()
gw.chrage(order.total)                 # typo: "chrage" — silently does nothing

Symptoms: A misspelled method call succeeds and charges nothing. No AttributeError.

Find the bug `__getattr__` makes *any* attribute a no-op, including typos and methods that never existed. The bug-catching `AttributeError` is gone.

Fix

class NullGateway:                     # implement the REAL interface explicitly
    def charge(self, amount): pass     # only `charge` is a no-op

gw = payment_gateway or NullGateway()
gw.chrage(order.total)                 # now raises AttributeError — caught

Lesson

Prefer an explicit Null class implementing exactly the interface. __getattr__ Null Objects silently absorb typos and undefined calls.


Bug 9: Null Object Leaks Into Persistence (Java)

Customer c = repo.find(id);            // may be Customer.GUEST
session.save(c);                       // BUG: persists a phantom "Guest" row

Symptoms: A GUEST Null Object gets written to the database as if it were a real customer.

Find the bug The Null Object was meant for in-memory branch-free logic, but it crossed the persistence boundary and got stored as a real entity.

Fix

Customer c = repo.find(id);
if (c == Customer.GUEST) return;       // don't persist the Null Object
session.save(c);

Or make GUEST un-persistable (override save/serialization to reject it at the boundary).

Lesson

Null Objects must not leak across boundaries (persistence, serialization) where their "phantom" nature becomes real data.


Bug 10: Forgetting the Argument Is Still Evaluated (Java)

Logger logger = Logger.NULL;
logger.info("payload=" + expensiveSerialize(bigObject));   // BUG: serialize runs anyway

Symptoms: Even though logging does nothing, expensiveSerialize runs on every call, dominating the hot loop.

Find the bug The Null Object's `info` is a no-op, but the *argument* is constructed before the call. The expensive string is built and thrown away.

Fix

if (logger.isInfoEnabled()) {                          // guard expensive construction
    logger.info("payload=" + expensiveSerialize(bigObject));
}
// or pass a supplier the logger only invokes when enabled:
logger.info(() -> "payload=" + expensiveSerialize(bigObject));

Lesson

A Null Object eliminates the call cost, not the cost of building its arguments. Guard or lazily defer expensive argument construction.


Bug 11: Re-checking for the Null Object (Java)

Customer c = repo.find(id);            // returns GUEST when absent
if (c != Customer.GUEST) {             // BUG: reintroduced the conditional
    sendNewsletter(c);
}

Symptoms: Every call site now branches on "is this the Null Object?" — exactly the conditional the pattern was meant to delete.

Find the bug If callers must check whether they got the Null Object, the absence wasn't really "do nothing" — they needed to *react* to it. Null Object was the wrong choice.

Fix

// Use Optional so the absence decision is explicit and the type forces it:
repo.find(id).ifPresent(this::sendNewsletter);

Lesson

If call sites re-check for the Null Object, you needed Optional/Special Case all along. Null Object only works when callers can ignore absence.


Practice Tips

  1. Run go test -race on shared Null Objects — confirm they're stateless.
  2. Grep for new NullX() — Null Objects should be singletons, not per-call allocations.
  3. In Go, never return a typed-nil from a constructor — return a no-op value.
  4. For every Null Object, ask: would a reader seeing the no-op fire say "good" or "wait, why?" The latter means Fail Fast.
  5. Check boundaries: can the Null Object be persisted, serialized, or sent over the wire?

← Tasks · Control Flow · Coding Patterns · Next: Optimize