Skip to content

Null Object

Intent

Provide an implementation of an interface whose methods do nothing, or return the neutral value, and hand it to clients in place of None. The client keeps one code path: Checkout calls self._notifier.send(...) and self._discount.discount(subtotal) whether or not the customer has an email address or a coupon, and no call site ever asks.

When to use and when not to

Use it when

  • A collaborator is optional and "absent" has an obvious neutral behaviour: no notification, zero discount, no lock, no log output.
  • The same if x is not None appears at several call sites; each is a branch to test and a place to forget.
  • An injected dependency needs a default that does nothing rather than something real: a Checkout built without a notifier must not send email.

Leave it out when

  • The caller needs to know about absence. repo.get(id) returns None because "no such user" is information; a NullUser with an empty name lets the bug travel several layers before it surfaces.
  • "Do nothing" would hide a failure. A gateway that silently approves or a repository that silently drops writes is a defect with a pattern name.
  • The neutral behaviour is not obvious. If two engineers would write the null price() differently (zero, raise, last known price), the interface is not ready for one.

Structure

Two interfaces, a working and a null implementation of each, and a client that treats them identically.

classDiagram
    class Notifier {
        <<interface>>
        +send(recipient: str, message: str)
    }
    class RecordingNotifier {
        +sent: List~tuple~
        +send(recipient: str, message: str)
    }
    class NullNotifier {
        +send(recipient: str, message: str)
    }
    class DiscountPolicy {
        <<interface>>
        +discount(subtotal: Money) Money
    }
    class PercentageDiscount {
        +percent: int
        +discount(subtotal: Money) Money
    }
    class NoDiscount {
        +discount(subtotal: Money) Money
    }
    class Checkout {
        -_ids: IdGenerator
        -_notifier: Notifier
        -_discount: DiscountPolicy
        -_lock: AbstractContextManager
        +place(customer_id: str, prices: List~Money~) Receipt
        +placed: int
    }
    class Receipt {
        +order_id: str
        +subtotal: Money
        +discount: Money
        +total: Money
    }
    Notifier <|.. RecordingNotifier
    Notifier <|.. NullNotifier : does nothing
    DiscountPolicy <|.. PercentageDiscount
    DiscountPolicy <|.. NoDiscount : subtracts zero
    Checkout o-- Notifier
    Checkout o-- DiscountPolicy
    Checkout ..> Receipt : creates

Checkout holds a Notifier and a DiscountPolicy and never inspects which. The null implementations realise the same Protocols; they do not subclass the working ones. NullNotifier qualifies by shape, exactly like RecordingNotifier.

Canonical example in Python

The notifier pair first (code/patterns/null_object.py, tested by code/patterns/tests/test_null_object.py):

code/patterns/null_object.py — a working notifier and the null one
@runtime_checkable
class Notifier(Protocol):
    def send(self, recipient: str, message: str) -> None: ...


class RecordingNotifier:
    """A working implementation: keeps what was sent (the demo's stand-in for email)."""

    def __init__(self) -> None:
        self.sent: list[tuple[str, str]] = []

    def send(self, recipient: str, message: str) -> None:
        self.sent.append((recipient, message))


@dataclass(frozen=True, slots=True)
class NullNotifier:
    """The null object: same contract, no behaviour, no failure modes, no state.

    Frozen and field-less, so every ``NullNotifier()`` equals every other and a single
    instance can be shared by every caller in the process.
    """

    def send(self, recipient: str, message: str) -> None:
        return None

The discount pair has the same shape, with the null object as the identity element of the operation:

code/patterns/null_object.py — a real discount and the null one
@runtime_checkable
class DiscountPolicy(Protocol):
    def discount(self, subtotal: Money) -> Money: ...  # the amount to subtract


@dataclass(frozen=True, slots=True)
class NoDiscount:
    """The identity element of discounts: subtracts zero, so the caller never branches."""

    def discount(self, subtotal: Money) -> Money:
        return Money(0, subtotal.currency)


@dataclass(frozen=True, slots=True)
class PercentageDiscount:
    percent: int

    def __post_init__(self) -> None:
        if not 0 < self.percent <= 100:
            raise ValidationError(f"percent must be between 1 and 100, got {self.percent}")

    def discount(self, subtotal: Money) -> Money:
        return subtotal * (Decimal(self.percent) / 100)  # Money rounds half-up to the cent

Three decisions to say out loud:

  • Frozen, field-less dataclasses. A null object has no state, so NullNotifier() == NullNotifier(), instances are hashable, and one shared instance serves the whole process. Immutability is also what makes it safe as a default argument.
  • The neutral value in the right type. NoDiscount.discount returns Money(0, subtotal.currency), not 0 and not None; the caller's subtotal - off works without a branch. The identity element is part of the contract.
  • Realisation, not inheritance. NullNotifier does not subclass RecordingNotifier and override send with pass; that would inherit a list it never uses and tie the null object to one implementation.

The client shows the payoff:

code/patterns/null_object.py — the client with one code path
@dataclass(frozen=True, slots=True)
class Receipt:
    order_id: str
    customer_id: str
    subtotal: Money
    discount: Money
    total: Money


# Null objects carry no state, so one shared instance per type is enough, and because
# they are immutable they are safe as default arguments.
NO_DISCOUNT: Final = NoDiscount()
NO_NOTIFICATIONS: Final = NullNotifier()
NO_LOCK: Final = nullcontext()  # the stdlib's null object for "a context manager"


class Checkout:
    """The client. It never asks whether a notifier, a coupon or a lock is present.

    Each optional collaborator defaults to its null object, so ``place`` has one code
    path. ``_lock`` protects ``_receipts``: a single-threaded caller keeps the
    ``nullcontext`` default, a threaded one passes ``threading.Lock()``.
    """

    def __init__(
        self,
        ids: IdGenerator,
        notifier: Notifier = NO_NOTIFICATIONS,
        discount: DiscountPolicy = NO_DISCOUNT,
        lock: AbstractContextManager[object] = NO_LOCK,
    ) -> None:
        self._ids = ids
        self._notifier = notifier
        self._discount = discount
        self._lock = lock
        self._receipts: list[Receipt] = []

    def place(self, customer_id: str, prices: Sequence[Money]) -> Receipt:
        if not prices:
            raise ValidationError("an order needs at least one item")
        subtotal = sum(prices[1:], start=prices[0])
        off = self._discount.discount(subtotal)
        receipt = Receipt(self._ids.next_id(), customer_id, subtotal, off, subtotal - off)
        with self._lock:
            self._receipts.append(receipt)
        self._notifier.send(customer_id, f"order {receipt.order_id}: {receipt.total}")
        return receipt

    @property
    def placed(self) -> int:
        with self._lock:
            return len(self._receipts)

Every optional collaborator defaults to its null object, including the lock: contextlib.nullcontext() is the standard library's null object for "a context manager", so a single-threaded Checkout and a threaded one run the same with self._lock: line.

The stdlib section names the two you will be asked about:

code/patterns/null_object.py — the stdlib's own null objects
def library_logger(name: str) -> logging.Logger:
    """What a library does at import time: attach ``NullHandler`` so it never writes to stderr.

    When no handler exists anywhere in a logger's hierarchy, ``logging`` falls back to
    ``lastResort`` and prints WARNING and above to stderr. ``NullHandler`` counts as a
    handler and does nothing, so the fallback never fires and the application that
    imports the library stays in charge of where log output goes.
    """
    logger = logging.getLogger(name)
    logger.addHandler(logging.NullHandler())
    return logger


def optional_lock(thread_safe: bool) -> AbstractContextManager[object]:
    """``nullcontext()`` is the null object for locks: the same ``with``, no locking."""
    return threading.Lock() if thread_safe else nullcontext()

When no handler exists anywhere in a logger's hierarchy, logging falls back to lastResort and writes warnings to stderr. NullHandler counts as a handler and does nothing, so a library that attaches one never surprises the application that imports it.

Running python -m patterns.null_object prints:

--- a guest checkout: no coupon, no contact details, one thread ---
order-1: subtotal 30.00 USD, discount 0.00 USD, total 30.00 USD
--- a member checkout: a 10% coupon and an email on file ---
order-2: subtotal 30.00 USD, discount 3.00 USD, total 27.00 USD
notified: ('grace', 'order order-2: 27.00 USD')
--- the same Checkout code path, with and without a real lock ---
nullcontext, one thread: 100 orders placed
threading.Lock, four threads: 100 orders placed
--- logging.NullHandler: the stdlib's null object for handlers ---
handlers on the library logger: ['NullHandler']; the warning went nowhere
--- null objects are values: one shared instance is enough ---
NullNotifier() == NullNotifier(): True; NoDiscount() == NO_DISCOUNT: True

Pythonic variant

Most null objects in Python are not classes:

  • Empty collections. Return [], {} or "" instead of None, and callers iterate, index and join without a check; dict.get(key, []) and defaultdict(list) build the idea into lookups.
  • A do-nothing callable fills an optional callback slot, so the loop calls it unconditionally:
def _ignore(*_: object) -> None:
    return None


class Download:
    def __init__(self, on_progress: Callable[[int], None] = _ignore) -> None:
        self._on_progress = on_progress  # always callable, never checked
  • nullcontext(value) stands in for an optional resource: with (open(path) if path else nullcontext(sys.stdin)) as source: keeps one with block.
  • os.devnull is the null file: subprocess.run(cmd, stdout=subprocess.DEVNULL) discards output without branching on a quiet flag.

Promote the idiom to a class when the interface has more than one method, when the null choice should be visible and named in a constructor signature, or when isinstance(x, Notifier) must hold for it.

Real-world usage

  • logging.NullHandler: the handler a library attaches to its top-level logger so that importing it never writes to stderr.
  • contextlib.nullcontext: the stand-in for an optional context manager; nullcontext(enter_result) even chooses what __enter__ returns.
  • os.devnull and subprocess.DEVNULL: the null file; open(os.devnull, "w") is a sink that accepts every write.
  • Django's AnonymousUser: request.user is always user-shaped, with is_authenticated set to False, instead of being None for visitors.
Looks like Null Object How to tell them apart
Strategy A null object is a strategy whose algorithm is "nothing": NoDiscount sits in the slot PercentageDiscount uses. The difference is intent: a strategy chooses behaviour, a null object removes a check.
None None says "absent, and you must handle it"; a null object says "absent, and nobody needs to care". Use None when absence is information, a null object when it is not.
Special Case (Fowler) The general form: an object for one particular case, such as UnknownCustomer with a zero credit limit. A null object is the special case whose behaviour is empty.
Stub and fake Test doubles live in tests and stand in for something real; a null object is production code and stands in for nothing. RecordingNotifier is a fake; NullNotifier ships.
Mock() The opposite worth remembering: it accepts everything and records it, and auto-creates attributes. A Mock standing in for a notifier in production code is a bug, not a null object.

Where it appears in LLD problems

Interview tips

Interview tip

Introduce it when you write the first default: "the notifier defaults to a NullNotifier, so Checkout never checks for None." Then add the two SDE2 sentences: which null objects the stdlib already ships (nullcontext, NullHandler, os.devnull), and the case where you refuse the pattern because absence is information (get returning None).

Common mistake

A null object that hides a failure: a payment gateway that approves everything for now, a repository whose add is pass. Null objects are for optional side effects with an obvious neutral value, never for required collaborators. Runner-up: a NullUser full of empty strings that lets a missing-user bug surface three layers later as a blank page.