Concepts / Behavioral patterns / Specification and rule objects

Specification and rule objects

Business rules as composable objects you can and, or and not.

01The intuition

The problem it solves

A course registration program decides whether a student may join a class. The rules: has the prerequisite, is in year 11 or above, has not failed the course twice, the class is not full. The obvious code puts all of them in one can_join method. Then the sports club needs "year 11 or above and not on the injured list", which shares one rule and adds another, and the scholarship form needs three of the four plus a new one. Each place rewrites the rules, and when the year rule changes to year 10, four files change.

def can_join(student: "Student", course: "Course") -> bool:
    return (course.prereq in student.passed
            and student.year >= 11
            and student.fails.get(course.id, 0) < 2
            and len(course.members) < course.capacity)

def can_join_club(student, club):
    return student.year >= 11 and student.id not in injured   # rule copied

Build it one piece at a time

Step one: make each rule a small object with one question: is this candidate satisfied? Programmers call such an object a specification.

from typing import Protocol

class Spec(Protocol):
    def ok(self, student: "Student") -> bool: ...

class MinYear:
    def __init__(self, year: int) -> None: self.year = year
    def ok(self, s: "Student") -> bool: return s.year >= self.year

class HasPassed:
    def __init__(self, course_id: str) -> None: self.course_id = course_id
    def ok(self, s: "Student") -> bool: return self.course_id in s.passed

class NotInjured:
    def ok(self, s: "Student") -> bool: return s.id not in injured

Step two: give specifications the three words you combine rules with: and, or, not. Each combination is itself a specification, so you can keep combining.

class And:
    def __init__(self, *parts: Spec) -> None: self.parts = parts
    def ok(self, s) -> bool: return all(p.ok(s) for p in self.parts)

class Or:
    def __init__(self, *parts: Spec) -> None: self.parts = parts
    def ok(self, s) -> bool: return any(p.ok(s) for p in self.parts)

class Not:
    def __init__(self, part: Spec) -> None: self.part = part
    def ok(self, s) -> bool: return not self.part.ok(s)

Step three: each place in the program builds its rule from the shared parts, as data. The year rule now exists once; the club and the course both use it.

senior = MinYear(11)
physics_entry = And(senior, HasPassed("maths-10"), Not(FailedTwice("physics-11")))
club_entry = And(senior, NotInjured())

eligible = [s for s in students if physics_entry.ok(s)]

Why it works

The principle: a business rule is a value, not a line of code, so it can be named, shared, combined and tested on its own. The invariant is that a specification answers only yes or no about one candidate and has no side effects, which is what makes combining safe. Where it breaks: a rule that needs two candidates (student and course together), where the specification should take a small pair object instead; rules that hit a database inside ok and are then evaluated ten thousand times in a loop; and the temptation to make every if in the program a specification. Interviewers test by asking you to add a rule and by asking why a student was rejected, which is where a specification that can explain itself (return the failing part) earns its place.

The trade-off you must say out loud

Four rules become four classes plus three combinators, and the final rule is harder to read as a tree than as one boolean line. Use it when rules are shared across places, built at runtime from configuration, or need to be reported back to the user. The follow-up is usually about explanation and performance: short-circuit order, and returning the list of unsatisfied rules rather than a bare False.

Where it shows up in the 100 Google problems

02Used in these problems