Design Patterns, Explained Simply · Modern Patterns and Choosing Well
Null Object and Specification
Two small patterns with a big effect: replace null checks with an object that does nothing, and turn business rules into small objects you can combine and reuse.
The problem
Two smells from the same codebase:
- Null checks everywhere. A checkout might have a logged-in customer or a guest. Every method has
if customer is not None and customer.discount .... One place forgot the check and crashed on guest checkouts. - Business rules duplicated as boolean logic. "Eligible for free shipping" (
total >= 50 and country in EU and not on_hold) is written slightly differently in the cart, the email job and the admin report, and they disagree.
Real-world analogy
- Null Object: a "no-op" remote button. On a TV without subtitles, the subtitles button does nothing, rather than making the remote crash.
- Specification: a recruiter's checklist ("5+ years AND Python AND (London OR remote)"). Each criterion is a reusable card, and you combine cards for each job.
Without the pattern
discount = customer.discount_percent if customer is not None else 0 # repeated everywhere
if order.total >= 5000 and order.country in EU and not order.on_hold: # same rule, written three ways
...
The pattern
Null Object:
- An object with the same interface as the real one whose methods do "nothing" or return neutral values: a
Guestcustomer with 0% discount, aNullLoggerthat discards messages. - Callers stop checking for
null; the "nothing" behaviour is explicit and in one place.
Specification (from Eric Evans and Martin Fowler):
- A small object answering one question:
is_satisfied_by(candidate) -> bool. - Specifications combine with
and,orandnotinto bigger rules, which are named, reusable and testable. - The same specification can filter lists, validate input, or be translated into a database query.
The code
class Customer:
def __init__(self, name, discount_percent):
self.name, self.discount_percent = name, discount_percent
class Guest: # Null Object: same interface, neutral behaviour
name, discount_percent = "guest", 0
def price_for(customer, cents): # no None checks needed
return cents - cents * customer.discount_percent // 100
for who in [Customer("Ada", 10), Guest()]:
print(f"{who.name} pays {price_for(who, 8000)}")
class Spec: # Specification: a reusable, combinable rule
def __init__(self, test, label):
self.test, self.label = test, label
def is_satisfied_by(self, x):
return self.test(x)
def and_(self, other):
return Spec(lambda x: self.test(x) and other.test(x), f"({self.label} and {other.label})")
def or_(self, other):
return Spec(lambda x: self.test(x) or other.test(x), f"({self.label} or {other.label})")
def not_(self):
return Spec(lambda x: not self.test(x), f"not {self.label}")
big_order = Spec(lambda o: o["total"] >= 5000, "total >= 50")
in_eu = Spec(lambda o: o["country"] in ("DE", "FR", "IE"), "in EU")
on_hold = Spec(lambda o: o["on_hold"], "on hold")
free_shipping = big_order.and_(in_eu).and_(on_hold.not_())
print("rule:", free_shipping.label)
orders = [{"id": 1, "total": 7000, "country": "DE", "on_hold": False},
{"id": 2, "total": 7000, "country": "US", "on_hold": False},
{"id": 3, "total": 9000, "country": "FR", "on_hold": True}]
print("free shipping:", [o["id"] for o in orders if free_shipping.is_satisfied_by(o)])
Output
Ada pays 7200 guest pays 8000 rule: ((total >= 50 and in EU) and not on hold) free shipping: [1]
interface Buyer { name: string; discountPercent: number; }
class Customer implements Buyer {
constructor(public name: string, public discountPercent: number) {}
}
class Guest implements Buyer { // Null Object: same interface, neutral behaviour
name = "guest";
discountPercent = 0;
}
const priceFor = (b: Buyer, cents: number) => cents - Math.floor(cents * b.discountPercent / 100); // no null checks
for (const who of [new Customer("Ada", 10), new Guest()]) console.log(`${who.name} pays ${priceFor(who, 8000)}`);
type Order = { id: number; total: number; country: string; onHold: boolean };
class Spec<T> { // Specification: a reusable, combinable rule
constructor(private test: (x: T) => boolean, readonly label: string) {}
isSatisfiedBy(x: T) { return this.test(x); }
and(other: Spec<T>) { return new Spec<T>((x) => this.test(x) && other.test(x), `(${this.label} and ${other.label})`); }
or(other: Spec<T>) { return new Spec<T>((x) => this.test(x) || other.test(x), `(${this.label} or ${other.label})`); }
not() { return new Spec<T>((x) => !this.test(x), `not ${this.label}`); }
}
const bigOrder = new Spec<Order>((o) => o.total >= 5000, "total >= 50");
const inEu = new Spec<Order>((o) => ["DE", "FR", "IE"].includes(o.country), "in EU");
const onHold = new Spec<Order>((o) => o.onHold, "on hold");
const freeShipping = bigOrder.and(inEu).and(onHold.not());
console.log("rule:", freeShipping.label);
const orders: Order[] = [{ id: 1, total: 7000, country: "DE", onHold: false },
{ id: 2, total: 7000, country: "US", onHold: false },
{ id: 3, total: 9000, country: "FR", onHold: true }];
console.log(`free shipping: [${orders.filter((o) => freeShipping.isSatisfiedBy(o)).map((o) => o.id).join(", ")}]`);
Output
Ada pays 7200 guest pays 8000 rule: ((total >= 50 and in EU) and not on hold) free shipping: [1]
import java.util.List;
import java.util.function.Predicate;
public class Main {
static int priceFor(Buyer b, int cents) { return cents - cents * b.discountPercent() / 100; } // no null checks
public static void main(String[] args) {
for (Buyer who : List.of(new Customer("Ada", 10), new Guest())) {
System.out.println(who.name() + " pays " + priceFor(who, 8000));
}
Spec<Order> bigOrder = new Spec<>(o -> o.total() >= 5000, "total >= 50");
Spec<Order> inEu = new Spec<>(o -> List.of("DE", "FR", "IE").contains(o.country()), "in EU");
Spec<Order> onHold = new Spec<>(Order::onHold, "on hold");
Spec<Order> freeShipping = bigOrder.and(inEu).and(onHold.not());
System.out.println("rule: " + freeShipping.label());
List<Order> orders = List.of(new Order(1, 7000, "DE", false), new Order(2, 7000, "US", false),
new Order(3, 9000, "FR", true));
System.out.println("free shipping: " + orders.stream().filter(freeShipping::isSatisfiedBy).map(Order::id).toList());
}
}
interface Buyer { String name(); int discountPercent(); }
record Customer(String name, int discountPercent) implements Buyer {}
record Guest() implements Buyer { // Null Object: same interface, neutral behaviour
public String name() { return "guest"; }
public int discountPercent() { return 0; }
}
record Order(int id, int total, String country, boolean onHold) {}
record Spec<T>(Predicate<T> test, String label) { // Specification: a reusable, combinable rule
boolean isSatisfiedBy(T x) { return test.test(x); }
Spec<T> and(Spec<T> o) { return new Spec<>(test.and(o.test), "(" + label + " and " + o.label + ")"); }
Spec<T> or(Spec<T> o) { return new Spec<>(test.or(o.test), "(" + label + " or " + o.label + ")"); }
Spec<T> not() { return new Spec<>(test.negate(), "not " + label); }
}
Output
Ada pays 7200 guest pays 8000 rule: ((total >= 50 and in EU) and not on hold) free shipping: [1]
Run this on your computer — see “Get Started” for setup.
<?php
interface Buyer { public function name(): string; public function discountPercent(): int; }
class Customer implements Buyer {
public function __construct(private string $name, private int $discount) {}
public function name(): string { return $this->name; }
public function discountPercent(): int { return $this->discount; }
}
class Guest implements Buyer { // Null Object: same interface, neutral behaviour
public function name(): string { return "guest"; }
public function discountPercent(): int { return 0; }
}
function priceFor(Buyer $b, int $cents): int { return $cents - intdiv($cents * $b->discountPercent(), 100); } // no null checks
foreach ([new Customer("Ada", 10), new Guest()] as $who) echo "{$who->name()} pays ", priceFor($who, 8000), "\n";
final class Spec { // Specification: a reusable, combinable rule
public function __construct(private Closure $test, public readonly string $label) {}
public function isSatisfiedBy(array $x): bool { return ($this->test)($x); }
public function and(Spec $o): Spec { return new Spec(fn($x) => $this->isSatisfiedBy($x) && $o->isSatisfiedBy($x), "({$this->label} and {$o->label})"); }
public function or(Spec $o): Spec { return new Spec(fn($x) => $this->isSatisfiedBy($x) || $o->isSatisfiedBy($x), "({$this->label} or {$o->label})"); }
public function not(): Spec { return new Spec(fn($x) => !$this->isSatisfiedBy($x), "not {$this->label}"); }
}
$bigOrder = new Spec(fn($o) => $o["total"] >= 5000, "total >= 50");
$inEu = new Spec(fn($o) => in_array($o["country"], ["DE", "FR", "IE"], true), "in EU");
$onHold = new Spec(fn($o) => $o["on_hold"], "on hold");
$freeShipping = $bigOrder->and($inEu)->and($onHold->not());
echo "rule: {$freeShipping->label}\n";
$orders = [["id" => 1, "total" => 7000, "country" => "DE", "on_hold" => false],
["id" => 2, "total" => 7000, "country" => "US", "on_hold" => false],
["id" => 3, "total" => 9000, "country" => "FR", "on_hold" => true]];
$ids = array_map(fn($o) => $o["id"], array_values(array_filter($orders, fn($o) => $freeShipping->isSatisfiedBy($o))));
echo "free shipping: [", implode(", ", $ids), "]\n";
Output
Ada pays 7200 guest pays 8000 rule: ((total >= 50 and in EU) and not on hold) free shipping: [1]
Pricing has no null checks, and "free shipping" is one named rule that the cart, the email job and the report can all reuse.
When to use it
- Null Object: a dependency or value is optional, and "absent" has a sensible do-nothing behaviour (guest users, no-op loggers, disabled feature flags, an empty discount).
- Specification: business rules are reused in several places, combined in different ways, or need to be both checked in code and translated into queries.
When not to use it
- Null Object can hide real errors: if "missing" is a bug (an order with no customer should fail), fail loudly instead. Modern alternatives:
Optional(Java), optional chaining and nullable types (TypeScript, PHP's?->, Python'sX | None) make absence explicit. - Specification is overkill for a rule used once. A well-named function (
is_eligible_for_free_shipping(order)) is usually enough.
You've already used it
- Null Object: Python's
logging.NullHandler, PSR-3'sNullLoggerin PHP, Java'sCollections.emptyList()(an empty collection instead ofnull). - Specification: Spring Data JPA
Specificationobjects that combine into SQLWHEREclauses; Doctrine's Criteria; Laravel query scopes combined on a builder.
Key points
- Null Object replaces
nullwith an object that has neutral behaviour, removing scattered null checks. Don't use it to hide real errors. - Specification turns business rules into small objects that combine with and/or/not and can be reused.
- Both are small but improve clarity and remove duplicated conditional logic.
- Further reading: Null object pattern and Specification pattern.
Practice questions
What is the Null Object pattern and when can it be harmful?
Answer
An object with the same interface as the real one whose methods do nothing or return neutral values (a guest with 0% discount, a NullLogger), so callers don't check for null. It is harmful when absence is actually an error: it hides the bug instead of failing fast.
What is the Specification pattern?
Answer
A business rule as an object with is_satisfied_by(candidate). Specifications combine with and/or/not into larger rules, so rules are named, reusable and testable, and can also be translated into database queries.
Give a framework example of the Specification pattern.
Answer
Spring Data JPA's Specification<T> objects, which combine with and()/or() and are translated into SQL WHERE clauses. Doctrine Criteria and Laravel query scopes serve similar purposes.
Open in the app to mark lessons complete, take notes and track your progress.