Engineering Atlas › Low-Level Design

Null Object: replacing nulls with a safe default

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem

An order service has three collaborators. Pricing rules may give a discount, a notifier emails the customer, and a metrics client counts orders. In tests, in a CLI tool, and in a small region that has no email provider, some of them are absent, so the code fills with guards:

def place(self, order):
    discount = self.discounts.get(order.code)
    off = discount.apply(order.subtotal) if discount else 0
    total = order.subtotal - off
    if self.notifier is not None:
        self.notifier.send(order.customer, f"total {total}")
    if self.metrics:
        self.metrics.incr("orders")
    return total

Every new method repeats the guards, and the one that forgets crashes with AttributeError: 'NoneType' object has no attribute 'send' at the worst moment. Tony Hoare called null references his billion-dollar mistake for this reason: the type says "a notifier" but the value may be nothing, and the compiler cannot tell which call sites remembered.

Replace absence with an object

Write an implementation for the "nothing" case and inject it once:

class SilentNotifier:
    def send(self, to, text):
        pass

class Checkout:
    def __init__(self, notifier=None, metrics=None):
        self.notifier = notifier or SilentNotifier()
        self.metrics = metrics or NullMetrics()

The method body loses its branches: it always calls self.notifier.send and self.metrics.incr. The only null check left is in the constructor, where the decision "no notifier means silence" is stated once and can be read and tested.

Neutral, not empty

A null object must return values that leave the computation unchanged, and the right neutral value depends on the operation. A discount returns 0. A list-returning repository returns an empty list, not None. A feature flag client with no backend returns each flag's coded default. A permission checker has no neutral answer: "allow" is dangerous and "deny" breaks every request, so a null permission checker is a bug waiting to happen. When you cannot name the neutral answer, absence is an error and should be treated as one.

The algebra helps. If you combine implementations (a composite notifier that fans out to email and SMS, a chain of discount rules), the null object is the identity element of that combination: a composite of zero notifiers behaves like the silent notifier, and the sum of zero discounts is zero. That is a quick test of whether your null object is correct.

Where you already use it

Go's io.Discard is a writer that accepts everything. slog.DiscardHandler (Go 1.24) is a handler whose Enabled returns false so loggers skip formatting entirely. Python libraries are told to attach logging.NullHandler() to their top logger so they never print on behalf of an application that has not configured logging. contextlib.nullcontext() is a context manager that does nothing, so with lock or nullcontext(): needs no branch. OpenTelemetry's API returns a no-op tracer when no SDK is installed, which is why libraries can instrument themselves without forcing tracing on their users. Java's Collections.emptyList() is a shared immutable null list.

Special Case and default values

Martin Fowler generalises the idea as Special Case: an object for a recurring edge case, such as an UnknownCustomer with no billing plan or a GuestUser with an empty cart. The same shape appears in system design: a recommendation widget whose service times out shows a fixed "popular this week" list instead of an error. That fallback is a null object for a degraded dependency, the partial failure problem shape handled by substituting a safe neutral answer.

When not to use it

Reviewing agent-written code

Asked to "make metrics optional", an agent will add if self.metrics: before each of the 14 increments, often missing one. Ask instead for a NullMetrics class, a default in the constructor, and a test that runs the main flow with no optional dependencies. Also check the reverse mistake: an agent that "fixes" a crash by defaulting the payment client to a no-op has turned an outage into silent revenue loss.

Down the stack

Layer 1The contract

A null object promises full substitutability (Liskov): every method of the interface exists, accepts the same inputs, never raises because it is the null variant, and returns the neutral value for that operation. It is stateless, or its state is never observable, so one instance can be shared by every caller and every thread. Its identity is not part of the contract: callers must not write if notifier is SILENT, because that reintroduces the branch the pattern removed. What it does not promise is that absence was intended; that decision belongs to whoever wires the object in.

Layer 2The mechanism

In Python, a null object is a class with empty methods and a module-level instance. In TypeScript, a const object literal satisfying the interface is enough, or a class with a single exported instance. In Go, the idiomatic form is an empty struct type with value receivers, exposed as a package variable of the interface type: var Discard Writer = discard{}. Go's own io.Discard goes a step further and implements ReaderFrom, so io.Copy(io.Discard, r) reads into a pooled 8 KB buffer instead of allocating per call. slog.DiscardHandler shows the other refinement: a null object with a cheap guard method (Enabled returns false) lets the caller skip building arguments at all, which matters more than the cost of the call.

Go has a trap here. An interface value is a pair (type, data). A nil *EmailNotifier stored in a Notifier interface is not equal to nil, because the type half is set, so if n != nil passes and the method call dereferences nil. Returning a null object instead of a typed nil pointer removes the trap.

Layer 3The machine

Allocation: a stateless null object costs nothing per use. In Go, zero-size values such as struct{} share the runtime's zerobase address, and storing one in an interface does not allocate. In Python and TypeScript, share one module-level instance rather than building one per request.

Dispatch: the null object replaces a predictable branch with an indirect call. A nil check is under 1 ns when predicted. A Go interface call loads the itab and the function pointer: about 1 to 2 ns, and it blocks inlining unless profile-guided optimisation devirtualises the site. In V8, a call site that sees both EmailNotifier and SilentNotifier becomes polymorphic, which the inline cache handles cheaply up to four shapes. In CPython, calling an empty method costs roughly 30 to 60 ns against about 15 ns for if x is not None. Next to one network call, all of this is noise.

Concurrency: a stateless null object needs no lock. One that counts calls "for debugging" is no longer null; it needs atomics and becomes a contention point on hot paths.

Why it's built this way

  1. Optional collaborators are common (tests, CLIs, small deployments), and checking for absence at every call site is repetitive and easy to forget.
  2. Polymorphism already answers "which behaviour runs here", so absence can be one more implementation instead of a special branch.
  3. The behaviour must not change results, so the null implementation returns the identity value of each operation.
  4. Having no state makes it free to share, which removes allocation and locking costs.
  5. Silencing a required dependency hides outages, so the decision is made once, explicitly, at construction.

Diagram

classDiagram
  class Notifier {
    <<interface>>
    +send(to, text)
  }
  class EmailNotifier {
    +send(to, text)
  }
  class SilentNotifier {
    +send(to, text)
  }
  class Checkout {
    -notifier: Notifier
    -metrics: Metrics
    +place(customer, subtotal, code)
  }
  Notifier <|.. EmailNotifier
  Notifier <|.. SilentNotifier
  Checkout --> Notifier

Worked example

A marketplace's order service ran in 4 regions, one without an SMS provider, and an admin CLI reused it with neither SMS nor metrics. The code had 37 if self.sms and if self.metrics checks across 11 files. In one quarter, a refund path added without the guard crashed every refund in the SMS-less region for 40 minutes, and the CLI crashed on a metrics call in a batch job.

The refactor added SilentSms and NullMetrics, defaulted them in the composition root, and deleted all 37 checks. A test now builds the service with only its required dependencies (database, payment gateway) and runs every public method. A benchmark showed the extra interface calls add under 200 ns to a request that spends 35 ms in the database and payment provider.

The same review caught the opposite mistake: a NullPaymentGateway "so the CLI can start without credentials". It was rejected in favour of a --dry-run flag that wires an explicit DryRunGateway which logs what it would have charged, and production startup fails without the real gateway.

Trade-offs

OptionStrengthsWeaknessesChoose when
Null checks at call sitesAbsence is visible where it mattersRepetitive; one missed check crashesVery few call sites, or the caller must react to absence
Null ObjectNo branches; shared and free; tests run with minimal setupHides absence; wrong for required dependenciesOptional collaborators with a clear neutral behaviour
Optional type (Option, `T \undefined`)Compiler forces handlingHandling still spread over callersValues that may be absent, such as lookups
Special Case objectEncodes domain defaults (guest user, unknown customer)More types to maintainA recurring edge case with domain meaning
Fail fast at startupMisconfiguration found before trafficLess flexible for tests and toolsRequired dependencies

Pitfalls in production

Leadership lens

Check yourself

  1. Why is a null permission checker usually a mistake, while a null metrics sink is fine?

    Show answer

    Metrics have a neutral behaviour (record nothing) that does not change results. Authorization has none: allowing everything is a security hole and denying everything breaks the service, so absence must be an error.

  2. How does the identity-element view help you check a null object's return values?

    Show answer

    Combining it with any real implementation must give the same result as the real one alone, for example adding a zero discount or fanning out to an empty notifier list. If a return value would change the result, it is not neutral.

  3. Why does returning a typed nil pointer as an interface in Go defeat if n != nil?

    Show answer

    The interface holds a non-nil type and a nil data pointer, so it compares unequal to nil, and calling a method that dereferences the receiver panics.

  4. When would you prefer an optional type over a null object?

    Show answer

    When the caller must behave differently if the value is absent, such as showing "not found", or when the value is data rather than a collaborator you call methods on.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →