Low-level designDeep dive8 min
Null Object
Replace a missing object with one that does nothing. A null object implements the same interface with neutral behavior, so callers stop checking for null, and a new feature can't forget a check that no longer exists.
A checkout with optional loyalty cards
A Customer has a LoyaltyCard, or no card at all for a guest. Checkout handles both.
1 · The situation
Members and guests at a cafe
A cafe's checkout gives loyalty members 10% off and a point for every dollar. Guests have no card. The first version models that as a Customer whose card is a LoyaltyCard or nothing: null in Java and JavaScript, None in Python, an empty std::optional in C++.
Checkout.charge() builds the receipt line, checking for a card before each use of it. Python writes discount_percent().
class LoyaltyCard {
private int points;
LoyaltyCard(int points) {
this.points = points;
}
int discountPercent() {
return 10;
}
int earn(int cents) {
int earned = cents / 100;
points += earned;
return earned;
}
int points() {
return points;
}
}
record Customer(String name, LoyaltyCard card) {}
class Checkout {
static String money(int cents) {
return String.format("$%d.%02d", cents / 100, cents % 100);
}
String charge(Customer c, String item, int cents) {
int discount = c.card() != null ? c.card().discountPercent() : 0;
int total = cents * (100 - discount) / 100;
String line = c.name() + ": " + item + " " + money(cents);
if (discount > 0) line += ", " + discount + "% off, " + money(total);
if (c.card() != null) {
int earned = c.card().earn(total);
line += ", +" + earned + " points (" + c.card().points() + ")";
}
return line;
}
}
public class Main {
public static void main(String[] args) {
Checkout checkout = new Checkout();
Customer[] customers = {
new Customer("Ana", new LoyaltyCard(40)),
new Customer("Guest", null),
};
for (Customer c : customers) System.out.println(checkout.charge(c, "breakfast", 1250));
}
}The class diagram of this version, drawn from its code: a customer may or may not hold a card.
A member and a guest each order breakfast:
Ana: breakfast $12.50, 10% off, $11.25, +11 points (51) Guest: breakfast $12.50
2 · The change
A new feature forgets the check
The cafe adds a birthday treat: a free cookie for members in their birthday month. The developer adds one line to charge(), and it works for members:
class Checkout {
static String money(int cents) {
return String.format("$%d.%02d", cents / 100, cents % 100);
}
String charge(Customer c, String item, int cents) {
int discount = c.card() != null ? c.card().discountPercent() : 0;
int total = cents * (100 - discount) / 100;
String line = c.name() + ": " + item + " " + money(cents);
if (discount > 0) line += ", " + discount + "% off, " + money(total);
if (c.card() != null) {
int earned = c.card().earn(total);
line += ", +" + earned + " points (" + c.card().points() + ")";
}
if (c.card().birthdayMonth()) line += ", free birthday cookie";
return line;
}
}It doesn't check for a missing card, and the first guest breaks the checkout:
Ana: breakfast $12.50, 10% off, $11.25, +11 points (51), free birthday cookie Guest: checkout failed, no card
The member gets a cookie; the guest gets an error, caught here so the program can say so.
Each language fails in its own way, and none of them at compile time:
Language | What the guest's checkout hits |
|---|---|
Java |
|
C++ |
|
JavaScript |
|
Python |
|
The cost isn't one missing check. Every use of the card needs one, and every future feature, a receipt footer, a members-only special, has to remember. The null checks are the same decision, "a guest gets nothing", written out at every call.
3 · The refactor
Give guests a card that does nothing
Start from the version with the birthday treat. Make "no card" an object that answers every question the way a guest should get it:
Loyalty declares what checkout asks: discountPercent(), earn(cents), which returns the receipt's points note, and birthdayTreat(). MemberCard is the old card, implementing it.
class MemberCard implements Loyalty {
private int points;
private final boolean birthdayMonth;
MemberCard(int points, boolean birthdayMonth) {
this.points = points;
this.birthdayMonth = birthdayMonth;
}
public int discountPercent() {
return 10;
}
public String earn(int cents) {
int earned = cents / 100;
points += earned;
return ", +" + earned + " points (" + points + ")";
}
public boolean birthdayTreat() {
return birthdayMonth;
}
}The member's receipt is unchanged, and the guest checks out:
Ana: breakfast $12.50, 10% off, $11.25, +11 points (51), free birthday cookie Guest: breakfast $12.50
Places in charge() that must test for a missing card, counting the birthday line that forgot. With NoCard, none.
4 · The pattern
Null Object: absence with neutral behavior
Null Object replaces a null reference with an object that implements the expected interface and does nothing, or returns neutral values: zero, an empty string, false, an empty list. Callers treat it like any other implementation, so the checks for absence disappear.
The roles in the cafe's code: Loyalty is the interface, MemberCard the real implementation, and NoCard the null object.
Role | In the cafe | Its job |
|---|---|---|
Abstraction |
| What clients ask of a loyalty program |
Real object |
| Gives the discount, earns points, knows the birthday |
Null object |
| Answers every question with the neutral value |
Client |
| Asks without testing for absence |
A guest's checkout: every question gets the neutral answer, and nothing fails.
The neutral answer has to be right for every caller. "No discount, no points, no treat" is exactly what a guest should get, which is why NoCard works. If callers needed to treat a guest differently, say to offer a sign-up, a null object would be the wrong tool: an Optional makes each caller decide, which is then the point. Checking the type of the null object would be the worst of both.
5 · What it costs
Silence can hide bugs
- Errors can look like normal behavior. If a member's card failed to load and the code quietly used
NoCard, the member would pay full price and no one would notice. Use a null object where absence is normal, not to cover failures. - The neutral answer must suit every caller. A method whose "do nothing" isn't obvious, such as a balance used in a calculation, may need a real decision, not a default.
- Someone can still pass a real null. A null object helps only if code consistently uses it; constructors and factories should never hand out
null.
Situation | Null object? |
|---|---|
Absence is normal and should mean "do nothing" | Yes |
Absence is an error that must be reported | No: fail loudly, or return an error |
Callers must react differently to absence | An |
Absence should mean a specific default, not nothing | A special-case object with that behavior |
6 · Getting it right
Neutral, shared, and never null
- Make the null object do nothing, visibly. Return neutral values; don't invent data, such as a fake card number.
- Share one instance. A null object has no state, so one shared instance, like
Collections.emptyList()in Java, is enough. - Never hand out null. Factories and constructors return the null object for the absent case, so callers can rely on always getting something.
- Keep it in step with the interface. A new method on
Loyaltyneeds a neutral answer inNoCardtoo, and in Java and C++ the compiler makes sure of it.
Language | Absence without null |
|---|---|
Java | A null object implementing the interface; or |
C++ | A null object behind the interface; or |
JavaScript | A null object with the same methods; optional chaining |
Python | A null object matching the |
7 · Look-alikes
Other ways to handle absence
Null Object is one answer to "what if it isn't there?". The others differ in who decides what happens:
Approach | Who decides what absence means | Good when |
|---|---|---|
Null Object | The null object, once, for every caller | Absence means "do nothing" everywhere |
Optional or a result type | Each caller, forced by the type | Callers need to react differently |
Special Case | A subclass with specific behavior for a case | Absence, or another case, has its own rules |
Null checks | Each caller, if it remembers | One or two uses that won't grow |
A null object is a special case of Strategy or State: one more implementation of the interface, chosen when the real one is missing. Its only job is to make the absence uneventful.
8 · In practice
Null objects in the standard library
Java's Collections.emptyList() returns an immutable empty list: code that loops over it, asks its size or checks whether it contains something works as if handed any list, with no null check. Returning an empty collection instead of null is the most common null object of all.
The practice checks have no Null Object detector, and all three versions read as clean: a forgotten null check is a run-time failure the checks can't see. The output of the second version shows it instead.
Check yourself
4 questions
Takeaways
Remember this
- Null Object replaces a null with an object that implements the interface and does nothing.
- Callers stop checking for absence, so new features can't forget a check.
- Use it where absence is normal and means "do nothing"; report real errors instead.
- Never hand out null: factories return the null object, often one shared instance.
- Choose Optional when callers must react to absence differently.