Specification¶
Intent¶
Encapsulate a business rule as an object that answers one question, is_satisfied_by(candidate), and combine rules with and, or and not into bigger rules that have the same interface. The client filters with a rule and never learns how many leaves it has; a new rule is a new leaf, not another condition inside a matches() function that every caller shares.
When to use and when not to¶
Use it when
- Rules are assembled at runtime from parts somebody else picks (a filter panel, a search form, a promotion editor); the combination is data you receive, not code you wrote.
- One rule is needed in several places (validate before saving, filter in search, select for a report), so it needs a name, equality and a single definition.
- You must do more than evaluate the rule: log it, explain a rejection, translate it to SQL. A tree of objects can be walked; a lambda is opaque.
Leave it out when
- There is one condition in one place:
if product.stock > 0 and product.price < limitbeats three objects. - The "rule" produces a result (a price, an ordering) rather than a verdict; that is Strategy.
- Rules arrive as text and need parsing; that is Interpreter, of which Specification is the fixed-grammar special case.
Structure¶
A Composite of rules: each leaf answers one question, three nodes implement the algebra, and the client evaluates whichever tree it is handed.
classDiagram
class Specification~T~ {
<<abstract>>
+is_satisfied_by(candidate: T) bool
+describe() str
+__and__(other: Specification) Specification
+__or__(other: Specification) Specification
+__invert__() Specification
}
class AndSpecification~T~ {
+left: Specification
+right: Specification
}
class OrSpecification~T~ {
+left: Specification
+right: Specification
}
class NotSpecification~T~ {
+inner: Specification
}
class InStock
class PriceBelow {
+limit: Money
}
class InCategory {
+category: str
}
class Product {
+sku: str
+price: Money
+stock: int
+category: str
}
class Catalog {
-_products: Tuple~Product~
+search(spec: Specification) List~Product~
}
Specification <|-- AndSpecification
Specification <|-- OrSpecification
Specification <|-- NotSpecification
Specification <|-- InStock
Specification <|-- PriceBelow
Specification <|-- InCategory
AndSpecification o-- "2" Specification : combines
NotSpecification o-- "1" Specification : wraps
Catalog ..> Specification : evaluates
Catalog *-- Product
The composites hold other specifications and nothing else; they are the only place and, or and not occur. Leaves hold configuration and one comparison. Catalog depends on the abstract type alone; the dotted arrow says it never builds rules, only receives them.
Canonical example in Python¶
The base class carries the algebra; the three composites are the whole of it (code/patterns/specification.py, tested by code/patterns/tests/test_specification.py):
class Specification[T](ABC):
"""The Specification interface plus the algebra every rule inherits.
An ``ABC`` rather than a ``Protocol`` because ``&``, ``|`` and ``~`` are
shared behaviour: every rule gets them for free and no rule can forget one.
``and``, ``or`` and ``not`` cannot be overloaded in Python, hence the
bitwise operators (the same choice Django's ``Q`` and SQLAlchemy made).
"""
@abstractmethod
def is_satisfied_by(self, candidate: T) -> bool: ...
@abstractmethod
def describe(self) -> str:
"""A readable rendering of the rule; the tree is data, not only behaviour."""
def __call__(self, candidate: T) -> bool:
return self.is_satisfied_by(candidate)
def __and__(self, other: Specification[T]) -> Specification[T]:
return AndSpecification(self, other)
def __or__(self, other: Specification[T]) -> Specification[T]:
return OrSpecification(self, other)
def __invert__(self) -> Specification[T]:
return NotSpecification(self)
@dataclass(frozen=True, slots=True)
class AndSpecification[T](Specification[T]):
left: Specification[T]
right: Specification[T]
def is_satisfied_by(self, candidate: T) -> bool:
return self.left.is_satisfied_by(candidate) and self.right.is_satisfied_by(candidate)
def describe(self) -> str:
return f"({self.left.describe()} AND {self.right.describe()})"
@dataclass(frozen=True, slots=True)
class OrSpecification[T](Specification[T]):
left: Specification[T]
right: Specification[T]
def is_satisfied_by(self, candidate: T) -> bool:
return self.left.is_satisfied_by(candidate) or self.right.is_satisfied_by(candidate)
def describe(self) -> str:
return f"({self.left.describe()} OR {self.right.describe()})"
@dataclass(frozen=True, slots=True)
class NotSpecification[T](Specification[T]):
inner: Specification[T]
def is_satisfied_by(self, candidate: T) -> bool:
return not self.inner.is_satisfied_by(candidate)
def describe(self) -> str:
return f"NOT {self.inner.describe()}"
Four decisions to say out loud:
ABC, notProtocol, and the operators are the reason. Strategy's interface is one method, so structural typing wins there. Here every rule must also compose, and the three operator methods are shared code: an ABC hands them to every subclass, including one defined inside a test.&,|,~becauseand,or,notcannot be overloaded. PEP 335 proposed it and was rejected; Django'sQand SQLAlchemy's clauses made the same choice for the same reason.- Frozen dataclasses make rules values.
InStock() & PriceBelow(x) == InStock() & PriceBelow(x), so you can memoise by rule, deduplicate filter trees and share one rule across every request thread without a lock. describe()is abstract on purpose. The tree is data: the walk that renders(in_stock AND price < 100.00 USD)can just as well renderWHERE stock > 0 AND price < 10000and push the work to the database. Three leaves over 1M products is 3M evaluations; at the 100 ns of one memory reference each that is 0.3 s before interpreter overhead, against one indexed query.
The leaves, the client and the fold come next:
@dataclass(frozen=True, slots=True)
class InStock(Specification[Product]):
"""A leaf with no configuration: the rule is the class."""
def is_satisfied_by(self, candidate: Product) -> bool:
return candidate.stock > 0
def describe(self) -> str:
return "in_stock"
@dataclass(frozen=True, slots=True)
class PriceBelow(Specification[Product]):
"""A leaf with configuration, fixed at construction and immutable afterwards."""
limit: Money
def is_satisfied_by(self, candidate: Product) -> bool:
return candidate.price < self.limit
def describe(self) -> str:
return f"price < {self.limit}"
@dataclass(frozen=True, slots=True)
class InCategory(Specification[Product]):
category: str
def is_satisfied_by(self, candidate: Product) -> bool:
return candidate.category == self.category
def describe(self) -> str:
return f"category = {self.category}"
class Catalog:
"""The client: owns the products, evaluates any rule tree, never inspects it."""
def __init__(self, products: Iterable[Product]) -> None:
self._products = tuple(products)
def __len__(self) -> int:
return len(self._products)
@property
def products(self) -> tuple[Product, ...]:
return self._products
def search(self, spec: Specification[Product]) -> list[Product]:
return [product for product in self._products if spec.is_satisfied_by(product)]
def all_of[T](*specs: Specification[T]) -> Specification[T]:
"""Fold the rules a filter panel collected: ``reduce`` over ``&`` builds the tree."""
if not specs:
raise ValidationError("all_of needs at least one specification")
return reduce(and_, specs)
def any_of[T](*specs: Specification[T]) -> Specification[T]:
if not specs:
raise ValidationError("any_of needs at least one specification")
return reduce(or_, specs)
Catalog.search is the only loop in the module. all_of folds whatever a filter panel collected with reduce(and_, specs): three ticked boxes become one tree. Short-circuiting is inherited from Python's and, so put the cheap leaf on the left.
Running python -m patterns.specification prints:
--- (in_stock AND price < 100.00 USD) ---
B-1 Python Cookbook 45.00 USD
T-2 Chess Set 60.00 USD
E-2 USB-C Hub 25.00 USD
--- (((in_stock AND price < 100.00 USD) AND NOT category = electronics) OR category = books) ---
B-1 Python Cookbook 45.00 USD
B-2 Collectors Atlas 180.00 USD
T-2 Chess Set 60.00 USD
--- a filter panel folds its selections with all_of(...) ---
((in_stock AND price < 100.00 USD) AND category = electronics)
matches: ['E-2']
rules are values: rebuilt tree == panel -> True
--- functional variant: plain predicates, same answers ---
bargain classes and predicates agree: True
gift classes and predicates agree: True
Pythonic variant¶
A rule with one method is a function. Callable[[T], bool] is the whole interface, closures carry the configuration that dataclass fields carried above, and three tiny combinators replace the composite classes:
# A rule with one method is a function: ``Predicate`` is the whole interface.
type Predicate[T] = Callable[[T], bool]
def every[T](*preds: Predicate[T]) -> Predicate[T]:
return lambda candidate: all(pred(candidate) for pred in preds)
def some[T](*preds: Predicate[T]) -> Predicate[T]:
return lambda candidate: any(pred(candidate) for pred in preds)
def negate[T](pred: Predicate[T]) -> Predicate[T]:
return lambda candidate: not pred(candidate)
def in_stock(product: Product) -> bool:
return product.stock > 0
def price_below(limit: Money) -> Predicate[Product]:
"""A closure carries the configuration that the dataclass field carried above."""
return lambda product: product.price < limit
def in_category(category: str) -> Predicate[Product]:
return lambda product: product.category == category
filter(pred, products)is the catalogue, andevery/someareall/anywith the candidate bound later.- The two forms mix. A specification is callable, so
every(in_stock, InStock() & under_100)works. - What you give up is everything that made the tree data: two identical lambdas are unequal, nothing can be printed or translated, and a failing rule cannot say which leaf failed.
| Reach for | When |
|---|---|
An inline and/or expression |
One condition, one call site |
Predicates plus every, some, negate |
Several reusable conditions, evaluated in-process only |
Frozen dataclass leaves with &, |, ~ |
Rules built from user input, shared across services, or needing a name, equality or a log line |
Leaves plus a translator (Visitor or match) |
The same rule must run inside a database or a search engine |
Draw the class diagram, then say "in Python I would start with predicates and promote them to specifications the day a rule has to be named, compared or translated".
Real-world usage¶
- Django
Qobjects:Product.objects.filter(Q(stock__gt=0) & Q(price__lt=100) | ~Q(category="toys")). The tree is never evaluated in Python; the ORM walks it and emits SQL. Same operators, same rejected PEP behind them. - SQLAlchemy clauses (
and_,or_,not_, or&,|,~on columns) and pandas masks (df[(df.stock > 0) & (df.price < 100)]) use the same trick, with the trap that&binds tighter than<, hence the parentheses. - Standard library:
pytest -k "cart and not slow"is a specification over test names;logging.Filteris a leaf rule over records;fnmatchandrecompile a textual rule into a predicate;functools.reducewithoperator.and_is the fold. - Domain-driven design: Evans and Fowler's paper named the pattern and split its uses into selection, validation and construction-to-order.
Related patterns and confusions¶
| Looks like Specification | How to tell them apart |
|---|---|
| Strategy | A strategy computes a result (a price, an ordering); a specification returns a verdict and composes with other verdicts. A specification often chooses which strategy applies. |
| Interpreter | Specification is an Interpreter with a fixed grammar (and, or, not over leaves) built with operators rather than parsed from text. When the rules arrive as strings from a config file, add a parser and you are in Interpreter. |
| Composite | The structure is Composite: AndSpecification is the composite node, the leaves are leaves, the client treats both alike. Specification is Composite applied to predicates, plus the operator overloads. |
| Chain of Responsibility | A chain passes a request along handlers until one handles it; no specification node owns the candidate, every node is asked and the answers are combined. |
| Decorator | NotSpecification wraps one rule behind the same interface, like a Decorator, but it inverts the answer instead of adding a responsibility; the point is the algebra, not the wrapping. |
| Visitor | The moment you render the tree as SQL as well as evaluating it, you want a second operation over the same nodes without editing them: Visitor, or match on the node type. |
Where it appears in LLD problems¶
- Design Stack Overflow — search filters (tagged, unanswered, score above a threshold) that users combine freely; the "bounty eligible" rule shared by the UI and a nightly job.
- Design LinkedIn (social network) — people search such as second-degree connections at the same company, and feed eligibility rules.
- Design Amazon (cart, order, inventory, payment) — catalogue filters and promotion eligibility (
CartTotalAbove(x) & ~ContainsGiftCard()). - Design a rate limiter (LLD) — matching a request to its rule (
PathMatches("/api/*") & ClientTier("free")) before a limit is applied.
Interview tips¶
Interview tip
Start with the leaf and the question: "every rule answers is_satisfied_by(product); the filter panel combines leaves with & and | into one rule the catalogue evaluates." Then show what the tree buys: print it for the audit log, translate it to SQL so the database does the work, test each leaf alone. Close with the Python caveat: and/or cannot be overloaded.
Common mistake
A matches(product, filters: dict) function that grows an if "max_price" in filters branch per filter. It is the ladder the pattern removes, and it cannot express or or not without another rewrite. Runner-up: leaves that query a database or mutate state. A specification must be a pure function of its candidate, or equality, memoisation and thread-safety are gone.
Related¶
- Interpreter — the same tree with a parser and an open grammar
- Strategy — an algorithm with a result, not a verdict
- Composite — the structure underneath the algebra
- Design Stack Overflow — user-combined search filters
- Design Amazon (cart, order, inventory, payment) — catalogue and promotion rules
- Eric Evans and Martin Fowler, Specifications (1997)
- Django documentation: Complex lookups with Q objects
- PEP 335 — Overloadable Boolean Operators (rejected)