Extra pattern · not GoF
Null Object
A is a real do-nothing implementation of the same interface as the real collaborator. Callers always call apply() or log(); they never branch on null for “no policy.”
Example: at exit, the fee engine asks for a discount. Most cars have none. If you return null, every caller writes if (discount != null). Miss one check and the gate throws. A NoDiscount object that returns the fee unchanged is a valid answer.
Is “do nothing” a missing value, or a valid behavior I can call?
Absence is a policy
- No coupon — NoDiscount
- No extra fee — ZeroSurcharge
- Quiet logger in tests — NoOpLogger
- is a valid apply()
Absence is information
- Missing ticket — Optional or error
- Unknown user — do not invent a ghost
- Failed lookup — fail loudly
- A is not a no-op
No discount is still a discount
interface Discount {
Money apply(Money fee);
}
final class PercentOff implements Discount {
private final int percent;
public Money apply(Money fee) { return fee.percentOff(percent); }
}
final class NoDiscount implements Discount {
public Money apply(Money fee) { return fee; }
}
class Pricing {
Money quote(Ticket ticket, Discount discount) {
return discount.apply(hourlyFee(ticket));
}
}Pricing does not know whether the car had a coupon. It always has a Discount. That is the same seam as Strategy: swap the implementation, keep the call site clean.
Decision
Optional vs Null Object
| Question | Optional | Null Object |
|---|---|---|
| Must the caller notice absence? | Yes — orElseThrow, map, empty branch | No — just call the method |
| Is “do nothing” a legal policy? | Usually no | Yes — NoDiscount, QuietLogger |
| Missing identity? | This is the tool | Never — hides bugs |
| Factory return when code is unknown | empty() if unknown is allowed | NoDiscount — or throw if illegal |
See nullability for the API-level story. This page is the object-model story: a type that is safe to call.
The factory must not return null
If DiscountFactory returns null when the code is unknown, callers keep the null checks. The factory should return NoDiscount — or throw if the code is illegal. That is the same instinct as Factory Method: one place decides the concrete type, including the empty one.
class DiscountFactory {
Discount from(String code) {
if (code == null || code.isBlank()) return new NoDiscount();
return percents.getOrDefault(code, new NoDiscount());
}
}
// Wrong: callers will write if (d != null) again.
// Discount from(String code) { return percents.get(code); }Cousins
Strategy
Special Case (Fowler)
Typed errors
Null Object for a missing entity
A NullTicket that “exists” so findById never fails will close spots you never opened. Missing identity is Optional or an error, not a silent fake.
Still returning null from the factory
If DiscountFactory returns null when the code is unknown, callers keep the null checks. The factory should return NoDiscount — or throw if the code is illegal.
Zero surcharge is the same shape
Weekend surcharge, EV surcharge, none. Do not return null from SurchargeFactory. Return ZeroSurcharge that adds nothing. Pricing always calls add(). The call site stays one line.
interface Surcharge {
Money addTo(Money fee);
}
final class WeekendBump implements Surcharge {
public Money addTo(Money fee) { return fee.plus(fee.percent(10)); }
}
final class ZeroSurcharge implements Surcharge {
public Money addTo(Money fee) { return fee; }
}A quiet logger is a Null Object
Tests that assert fees should not care about log lines. A NoOpLogger implements Logger and drops every call. Production injects a real logger. That is the same idea as NoDiscount — absence of side effects is a policy.
interface LotLogger {
void info(String message);
}
final class NoOpLogger implements LotLogger {
public void info(String message) { /* stay quiet */ }
}
class ParkingLot {
private final LotLogger log;
ParkingLot(Clock clock, TicketRepository tickets, LotLogger log) {
this.log = log;
}
}Do not return null from a logger factory “when logging is off.” Return NoOpLogger. Otherwise every call site grows a null check, and one missed check throws in production when logging is disabled.
How to explain this in an interview
“Most cars have no coupon. I will make NoDiscount a real Discount so Pricing never branches.” If the interviewer asks about a missing ticket, switch vocabulary: “That is identity. I will return Optional.empty(), not a fake ticket.” That distinction is the page.