Method Chaining Pattern — Deep Dive

Anatomy of a Chainable API

A well-designed chainable API has three components:

  1. Builder methods — modify state and return self (or a copy).
  2. Terminal methods — execute the built-up configuration and return a result.
  3. Inspection methods — let users peek at the current state without breaking the chain.
class HttpRequest:
    """A chainable HTTP request builder."""

    def __init__(self, method: str, url: str):
        self._method = method
        self._url = url
        self._headers: dict[str, str] = {}
        self._params: dict[str, str] = {}
        self._body: bytes | None = None
        self._timeout: float = 30.0

    # Builder methods (return self)
    def header(self, key: str, value: str) -> "HttpRequest":
        self._headers[key] = value
        return self

    def param(self, key: str, value: str) -> "HttpRequest":
        self._params[key] = value
        return self

    def body(self, data: bytes) -> "HttpRequest":
        self._body = data
        return self

    def timeout(self, seconds: float) -> "HttpRequest":
        self._timeout = seconds
        return self

    # Terminal methods (return results)
    def send(self) -> "Response":
        import urllib.request
        # Build and send the actual request
        ...

    def build(self) -> dict:
        """Return the request configuration without sending."""
        return {
            "method": self._method,
            "url": self._url,
            "headers": self._headers.copy(),
            "params": self._params.copy(),
        }

    # Inspection (return self, but with side effects)
    def debug(self) -> "HttpRequest":
        print(f"{self._method} {self._url}")
        for k, v in self._headers.items():
            print(f"  {k}: {v}")
        return self

Type-Safe Chaining with Type Hints

The challenge with method chaining and inheritance is return types. If QueryBuilder.where() returns QueryBuilder, subclass methods disappear from the chain:

class QueryBuilder:
    def where(self, condition: str) -> "QueryBuilder":
        ...
        return self

class UserQuery(QueryBuilder):
    def active_only(self) -> "UserQuery":
        ...
        return self

# Problem: .where() returns QueryBuilder, not UserQuery
# UserQuery().where("x > 1").active_only()  # Type error!

Solution: Self Type (Python 3.11+)

from typing import Self

class QueryBuilder:
    def where(self, condition: str) -> Self:
        ...
        return self

class UserQuery(QueryBuilder):
    def active_only(self) -> Self:
        ...
        return self

# Now correctly typed:
UserQuery().where("x > 1").active_only()  # Returns UserQuery

Pre-3.11 Solution: TypeVar

from typing import TypeVar

T = TypeVar("T", bound="QueryBuilder")

class QueryBuilder:
    def where(self: T, condition: str) -> T:
        ...
        return self

Django’s QuerySet: Immutable Chaining Case Study

Django’s QuerySet is one of Python’s most sophisticated chainable APIs. Each method returns a new QuerySet clone:

# Each call returns a NEW QuerySet
qs1 = User.objects.filter(active=True)
qs2 = qs1.exclude(role="admin")
qs3 = qs2.order_by("-created_at")

# qs1, qs2, qs3 are independent — modifying one doesn't affect others

How Django Implements Cloning

# Simplified from django/db/models/query.py
class QuerySet:
    def _clone(self):
        """Return a copy of the QuerySet."""
        c = self.__class__(
            model=self.model,
            query=self.query.chain(),  # Deep copy the SQL query
            using=self._db,
        )
        c._sticky_filter = self._sticky_filter
        c._for_write = self._for_write
        return c

    def filter(self, *args, **kwargs):
        clone = self._clone()
        clone.query.add_q(Q(*args, **kwargs))
        return clone

    def order_by(self, *field_names):
        clone = self._clone()
        clone.query.clear_ordering(force=True)
        clone.query.add_ordering(*field_names)
        return clone

Lazy Evaluation

Django QuerySets are lazy — the SQL query isn’t executed until you actually need the data:

# No SQL executed yet:
qs = User.objects.filter(active=True).order_by("name").exclude(role="bot")

# SQL executed here:
list(qs)        # Iteration triggers execution
qs[0]           # Indexing triggers execution
len(qs)         # len() triggers execution
bool(qs)        # Truthiness triggers execution

This is possible because chaining builds up a query object in memory. The terminal operation (iteration, indexing, etc.) compiles it to SQL and hits the database.

Building a Lazy Pipeline

Combining method chaining with lazy evaluation:

from typing import Self, Callable, Iterator, TypeVar

T = TypeVar("T")
U = TypeVar("U")


class Pipeline:
    """A lazy, chainable data processing pipeline."""

    def __init__(self, source: Iterator | None = None):
        self._source = source
        self._operations: list[Callable] = []

    def _clone(self) -> "Pipeline":
        p = Pipeline(self._source)
        p._operations = self._operations.copy()
        return p

    def from_iterable(self, data) -> Self:
        clone = self._clone()
        clone._source = iter(data)
        return clone

    def map(self, fn: Callable) -> Self:
        clone = self._clone()
        clone._operations.append(lambda it: (fn(x) for x in it))
        return clone

    def filter(self, predicate: Callable) -> Self:
        clone = self._clone()
        clone._operations.append(lambda it: (x for x in it if predicate(x)))
        return clone

    def take(self, n: int) -> Self:
        clone = self._clone()
        def _take(it):
            for i, x in enumerate(it):
                if i >= n:
                    break
                yield x
        clone._operations.append(_take)
        return clone

    # Terminal methods
    def collect(self) -> list:
        """Execute the pipeline and return results."""
        result = self._source
        for op in self._operations:
            result = op(result)
        return list(result)

    def first(self):
        """Execute and return the first result."""
        result = self._source
        for op in self._operations:
            result = op(result)
        return next(iter(result), None)

    def count(self) -> int:
        return len(self.collect())


# Usage
result = (
    Pipeline()
    .from_iterable(range(1000))
    .filter(lambda x: x % 2 == 0)
    .map(lambda x: x ** 2)
    .take(5)
    .collect()
)
# [0, 4, 16, 36, 64]

No computation happens until .collect() — the chain just records operations.

Error Handling in Chains

Chaining makes error handling tricky. Here are three strategies:

Strategy 1: Raise Immediately

class StrictBuilder:
    def set_port(self, port: int) -> Self:
        if not (1 <= port <= 65535):
            raise ValueError(f"Invalid port: {port}")
        self._port = port
        return self

Simple but breaks the chain on error.

Strategy 2: Result Monad

class Result:
    def __init__(self, value=None, error=None):
        self.value = value
        self.error = error
        self.ok = error is None

    def then(self, fn):
        if not self.ok:
            return self  # Skip — propagate error
        try:
            return Result(value=fn(self.value))
        except Exception as e:
            return Result(error=e)

# Usage
result = (
    Result(value=raw_data)
    .then(parse_json)
    .then(validate_schema)
    .then(transform_data)
    .then(save_to_db)
)

if result.ok:
    print(f"Success: {result.value}")
else:
    print(f"Error: {result.error}")

Strategy 3: Deferred Validation

class ConfigBuilder:
    def __init__(self):
        self._errors: list[str] = []
        self._config = {}

    def set(self, key: str, value) -> Self:
        self._config[key] = value
        return self

    def validate(self) -> Self:
        if "host" not in self._config:
            self._errors.append("host is required")
        if self._config.get("port", 0) < 1:
            self._errors.append("port must be positive")
        return self

    def build(self) -> dict:
        self.validate()
        if self._errors:
            raise ValueError(f"Configuration errors: {self._errors}")
        return self._config.copy()

Performance Considerations

Mutable Chaining

Zero overhead beyond regular method calls. Returns the same object every time — no allocation.

Immutable Chaining

Creates a new object per chain step. For Django QuerySets, each .filter() creates a new QuerySet plus a deep copy of the SQL query object. In a chain of 10 operations, that’s 10 object allocations.

In practice, this is negligible — object creation is ~100ns in CPython. For hot paths processing millions of items, consider mutable chaining or collect operations into a single step.

Lazy vs. Eager

Lazy chains (like Django QuerySets or the Pipeline above) defer computation but accumulate closures and generator objects in memory. For very long chains (100+ operations), the generator nesting can cause noticeable overhead when finally evaluated.

Anti-Patterns

Chains That Are Too Long

# Hard to read, hard to debug, hard to maintain
result = (builder
    .set_a(1).set_b(2).set_c(3).set_d(4).set_e(5)
    .configure_x().configure_y().configure_z()
    .validate().transform().optimize()
    .build().execute().format()
)

Break long chains into logical groups with intermediate variables.

Returning None Accidentally

class Broken:
    def add(self, item):
        self.items.append(item)
        # Forgot to return self! Returns None.

# builder.add("x").add("y")  → AttributeError: 'NoneType' has no attribute 'add'

This is the most common chaining bug.

Mixing Chaining with Side Effects

class Confusing:
    def save(self):
        write_to_database(self)
        return self  # Saved, but the chain continues — is it saved again?

    def notify(self):
        send_email(self)
        return self

# Does this save once or set up to save later?
obj.configure().save().notify().save()  # Saves twice! Probably a bug.

Terminal methods with side effects should generally not return self.

One thing to remember: Method chaining is a design pattern for building expressive, fluent APIs. The best implementations combine immutable cloning (for safety), lazy evaluation (for efficiency), and clear separation between builder methods (return self) and terminal methods (return results). Type it correctly with Self, handle errors explicitly, and break long chains into readable segments.

pythondesign-patternsoop

See Also

  • Python Adapter Pattern How Python's Adapter Pattern works like a travel power plug — making incompatible things work together.
  • Python Bridge Pattern Why separating what something does from how it does it keeps your Python code from becoming a tangled mess.
  • Python Builder Pattern Why building complex Python objects step by step beats cramming everything into one giant constructor.
  • Python Composite Pattern How the Composite Pattern lets you treat a group of things the same way you'd treat a single thing in Python.
  • Python Facade Pattern How the Facade Pattern gives you one simple button instead of a confusing control panel in Python.