Mastering the Specification Design Pattern in Python: From Predicates to Configurable Rules

The Problem with Sprawling Logic

Mastering the Specification Design Pattern in Python: From Predicates to Configurable Rules
This Design Pattern Scares Me To Death

Business rules often start simple but inevitably mutate into a maintenance nightmare. You might find an if statement checking user permissions in an API endpoint, only to see a nearly identical block of code in a reporting script or a CLI tool. This duplication creates a fragile codebase where a policy change requires updates in a dozen disconnected places. If you miss one, you introduce subtle bugs and security holes.

offers a way out. Originally popularized in
Domain-Driven Design
, this pattern treats business rules as first-class citizens. Instead of embedding logic directly in control flow, you encapsulate rules into small, reusable building blocks. These blocks can be combined using boolean logic—AND, OR, NOT—to build complex requirements from simple primitives. This approach separates the definition of a rule from its execution.

Prerequisites

Before implementing this pattern, you should be comfortable with the following

concepts:

  • Higher-Order Functions: Understanding functions that take or return other functions.
  • Decorators: Knowledge of how to wrap functions to modify their behavior.
  • Generics: Using TypeVar and Generic to write type-safe, reusable code.
  • Dunder Methods: Overloading operators like __and__, __or__, and __invert__.

Key Libraries & Tools

  • typing: Used for Callable, Generic, TypeVar, and Any to ensure strict type hinting.
  • functools: Specifically wraps, which is essential for preserving metadata when creating decorators.
  • json: Used in the final stage to demonstrate loading business rules from external configuration files.
  • SerpApi
    : A tool mentioned for handling external data retrieval, though not core to the pattern implementation itself.

Step 1: Building the Predicate Foundation

A predicate is a function that takes an object and returns a boolean. In Python, we can wrap this function in a class that overloads bitwise operators to allow for a natural syntax. This allows us to write RuleA & RuleB without immediately evaluating the logic.

from typing import Callable, Generic, TypeVar

T = TypeVar("T")

class Predicate(Generic[T]):
    def __init__(self, func: Callable[[T], bool]):
        self.func = func

    def __call__(self, obj: T) -> bool:
        return self.func(obj)

    def __and__(self, other: "Predicate[T]") -> "Predicate[T]":
        return Predicate(lambda x: self.func(x) and other.func(x))

    def __or__(self, other: "Predicate[T]") -> "Predicate[T]":
        return Predicate(lambda x: self.func(x) or other.func(x))

    def __invert__(self) -> "Predicate[T]":
        return Predicate(lambda x: not self.func(x))

This structure creates a DSL (Domain Specific Language) for rules. The __call__ method ensures the Predicate instance acts like a function, while the logic inside __and__ and __or__ creates new combined predicates that stay dormant until called.

Step 2: Refining with Decorators and Factories

Writing manual lambdas inside the Predicate class is tedious. A more Pythonic approach uses a decorator to transform standard functions into Predicate objects. By adding a factory layer, we can also pass arguments (like a minimum age or a specific country code) to our rules.

from functools import wraps

def rule(func: Callable):
    @wraps(func)
    def wrapper(*args, **kwargs) -> Predicate:
        return Predicate(lambda obj: func(*args, obj, **kwargs))
    return wrapper

@rule
def is_active(user) -> bool:
    return user.active

@rule
def min_age(age: int, user) -> bool:
    return user.age >= age

# Usage:
access_rule = is_active() & min_age(18)

This refactoring makes the rules highly readable. The rule decorator handles the heavy lifting, allowing the developer to focus on the business logic inside the decorated functions.

Step 3: From Code to Data

The ultimate power of the Specification Pattern lies in its ability to turn logic into data. By registering these rules in a dictionary, we can parse a

or
YAML
file and reconstruct the logic at runtime. This enables non-developers, such as product owners, to adjust thresholds like "Minimum Credit Score" or "Permitted Countries" without touching the source code.

rules_registry = {"is_active": is_active, "min_age": min_age}

def load_rule(config: dict) -> Predicate:
    # Recursively build the predicate tree from JSON config
    # Example logic to parse AND/OR structures omitted for brevity
    pass

Syntax Notes & Practical Examples

  • Generic Type T: By using Generic[T], this pattern isn't limited to "Users." You can use it for Product validation, Order processing, or Sensor data filtering.
  • Operator Precedence: Remember that bitwise operators (&, |, ~) have different precedence than logical operators (and, or, not). Use parentheses to ensure rules evaluate in the intended order.

Real-world applications include dynamically building database queries based on user filters or creating a flexible permissions system where roles are defined by a combination of fine-grained predicates.

Tips & Gotchas

  • Overengineering Warning: This pattern is powerful but introduces complexity. Only use it when business rules are highly volatile or need to be reused across multiple domains.
  • Preserving Metadata: Always use @functools.wraps in your decorators. Without it, debugging becomes difficult because the decorated functions lose their original names and docstrings.
  • Type Checking: Python's type system can struggle with deeply nested generic wrappers. Keep your rule factories simple to help tools like
    Mypy
    provide accurate feedback.
5 min read