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:

  1. 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.
  2. 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 Guest customer with 0% discount, a NullLogger that 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, or and not into 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

A guest Null Object and composable eligibility rulesTry it yourself
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]

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's X | 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's NullLogger in PHP, Java's Collections.emptyList() (an empty collection instead of null).
  • Specification: Spring Data JPA Specification objects that combine into SQL WHERE clauses; Doctrine's Criteria; Laravel query scopes combined on a builder.

Key points

  • Null Object replaces null with 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.