Learn

Architecture & pattern guides

Read the idea, build it on a Grid, prove it in a challenge. Short, practical guides — illustrated with RodGrid's own nodes — on system design, design patterns, flow modeling, and how to get the most out of the app.

All articles

Null Object

Replace the null checks every caller repeats with an implementation of the same interface that does nothing or returns a neutral value, and know when that silence hides a real problem.

An audit trail most tenants do not have

if (audit != null) audit.record(event); appears 23 times in the stock service of a warehouse platform that several companies share. Each company is a tenant, and only some of them, mostly pharmaceutical distributors, have contracts that require a trail of every stock movement: who received, picked, moved or recounted which item, and when. For those tenants the service is built with a DatabaseAuditTrail. For everyone else the audit field is left null, and every method that moves stock checks it before writing.

Each check is cheap, and that is the trouble: the code compiles just as happily without one. When the team added cycle-count adjustments, the new method called audit.record(event) directly. It was tested against a pharma tenant in staging, where the field is set, and it passed. In production it threw a null reference error (NullPointerException, in Java) for every tenant without an audit trail, which is most of them.

Three ways to say "not here"

Returning null lets every caller decide what absence means, and lets any of them forget. An Optional (called Maybe in some languages) makes absence explicit and would have stopped the adjustment method's direct call from compiling, but a caller can still unwrap it without checking, and the decision is still made at every call site, 24 identical times.

Look at what those decisions have in common: none of them does anything different when the trail is missing. They all mean skip it. A decision every caller makes the same way does not need making at every call site. The third option makes it once: an object that is always there and, for tenants without auditing, does nothing. Optional or an explicit error stays the right choice when callers need to tell absence apart, and the next two sections meet both cases.

AuditTrail becomes an interface with the method callers use, record(event). DatabaseAuditTrail implements it by inserting a row; NoAuditTrail implements it with an empty method body. NoAuditTrail is the Null Object: a deliberate do-nothing implementation, so callers use it without asking whether it is there. The stock service always receives one of the two, never null, every call site shrinks to audit.record(event), and the adjustment method that forgot the check is now simply correct.

Every tenant gets an AuditTrail

Only the tenant setup knows whether a tenant audits; one shared NoAuditTrail serves every tenant that does not.

Handing the service its trail from outside is ordinary Dependency Injection, and because NoAuditTrail holds no state, one instance is enough. A State machine's idle state or the last handler of a Chain of Responsibility can play the same role, but a hook in Template Method is not a Null Object: it is an empty default step inside one base class's algorithm, not a separate object standing in for a collaborator.

Doing nothing is easy; returning nothing is not

record returns nothing, so its neutral version is obvious. But the interface also has a method that returns something: historyFor(itemId) gives back the list of movements for one item. Two callers use it. A nightly job counts movements with no operator name and flags them for review. The item page shows the list under a History tab.

What should NoAuditTrail.historyFor return? An empty list is neutral for the nightly job: no movements, no flags, which is the right answer for a tenant that keeps no trail. For the History tab it is not neutral at all. The page would say "No movements recorded" about an item that has been received, moved and picked a dozen times, and could no longer tell an item with no movements from a tenant that keeps no trail.

That is the real test of a Null Object. For each method, ask whether one value is correct for every caller: an empty list, zero, false, the input unchanged. When there is one, the Null Object returns it, and the interface documents it, because the null version has to keep the promises the real one makes, the substitution rule that SOLID design principles covers. When there is none, the method does not belong behind a Null Object. Here the fix is to split the interface: the code that moves stock depends on AuditTrail with record, and the History tab reads from a separate AuditHistory that only audited tenants have. The page checks once whether that exists, an honest check, since it really does behave differently there.

When silence is the bug

A Null Object makes absence quiet. That is what you want when absence is normal, and exactly wrong when it is not.

Say a pharma tenant's configuration is saved without the audit database's connection string, and the tenant setup reads "no connection string" as "no auditing". The service starts, every movement goes into NoAuditTrail, nothing crashes, and the missing trail is found months later, when someone asks for it. The setup was answering the wrong question. "This tenant's contract has no auditing" is a reason to inject the Null Object; "this tenant should audit and its configuration is broken" is a reason to refuse to start. Keep the two apart in code, and log which implementation each tenant got, so a wrong choice shows up at startup instead of in an audit. A lookup miss is the same kind of absence: a scanner that reads an unknown barcode should report it to the picker, not hand back an empty NullItem.

Nor is a Null Object a way to react to failure. If DatabaseAuditTrail cannot reach its database, quietly switching to NoAuditTrail would drop the very records the contract requires. What to answer when a working dependency fails at runtime is the subject of Fallback; a Null Object is the behaviour chosen up front for an absence that is normal.

A reorder policy that orders nothing

The Strategy Pattern challenge is about interchangeable strategies, not about Null Object, so the do-nothing strategy here is one you add yourself. Model the reorder decision: a Component node for the stock service as the Context, a Port / Interface node for ReorderPolicy with quantityFor(item), and Strategy nodes for FixedQuantity, CoverTwoWeeks and NoReorder, which returns zero for items being discontinued. Then put the History tab's question to NoReorder: list who calls quantityFor and ask whether zero is right for each. For the purchase order it is. A report meant to average reorder quantities over active items would be dragged down by those zeros, and should not be asking about discontinued items at all.

Practice this