The Strategy Pattern for Filtering: A Practical Guide to Swappable Filter LogicHow to replace brittle if/else filter chains with composable, testable, runtime-swappable strategies

Introduction

Every non-trivial application eventually needs to filter something - products by price, log entries by severity, users by permission level, transactions by risk score. The first version of this code is almost always a function with a few if statements. The second version, after a few new requirements land, is a function with a dozen if statements. By the third version, someone on the team proposes "just add a config flag," and the filtering logic becomes an unreadable decision tree that nobody wants to touch.

The Strategy pattern offers a disciplined way out of this spiral. Instead of encoding every filtering rule as a branch inside one function, you define a common interface for "a filter," implement each rule as its own small class (or function object), and let the calling code select - or compose - a strategy at runtime. The code that applies the filter never needs to know how the filter works internally; it only needs to know that the filter satisfies a contract. This article walks through the problem this pattern solves, how to implement it well in Python and TypeScript, where it earns its complexity, and where a simpler approach is the better engineering choice.

The Problem: Why Ad Hoc Filtering Logic Breaks Down

Filtering logic tends to start small and stay small for a surprisingly long time - which is exactly why it becomes dangerous. A single filter_products(products, max_price=None, category=None, in_stock=None) function is easy to write and easy to review. But real systems accumulate filter criteria linearly with product requirements, and a function with five optional parameters quickly becomes a function with fifteen, most of them None in any given call. Each new criterion means touching the same function, re-testing all the existing branches, and hoping the interactions between parameters were reasoned about correctly.

This is a textbook violation of the Open/Closed Principle from SOLID design: a module should be open for extension but closed for modification. Every time a new filter type needs to be supported, the existing, already-tested function has to be edited again, which reintroduces risk into code that previously worked. It also violates the Single Responsibility Principle, because one function ends up owning price logic, category logic, inventory logic, and eventually promotional logic, all tangled together with shared local variables and easy-to-miss short-circuit bugs.

There's a second, less obvious cost: testability. A monolithic filter function with many optional parameters requires either an explosion of test cases covering every combination, or - more realistically - a team that only tests the combinations they remember to think of. When filtering rules are isolated into small, independent units, each one can be unit-tested in complete isolation, with no need to reason about fifteen other parameters that happen to be None.

Finally, ad hoc filtering resists runtime flexibility. If a product manager wants users to pick a filter from a dropdown, or an admin wants to define custom filter combinations through a UI, a giant conditional function offers no natural seam for that. You end up building a second layer of mapping code just to translate "user selected X" into "set the right combination of boolean flags," which duplicates the very branching logic you were trying to avoid.

Deep Technical Explanation: How Strategy Solves It

The Strategy pattern, as documented in the original "Design Patterns: Elements of Reusable Object-Oriented Software" by Gamma, Helm, Johnson, and Vlissides (the "Gang of Four," 1994), defines a family of interchangeable algorithms, encapsulates each one, and makes them interchangeable through a shared interface. Applied to filtering, the "algorithm" is a predicate: given an item, does it satisfy some rule? The pattern has three participants - the strategy interface, the concrete strategies, and the context that uses a strategy without knowing its internals.

What makes this pattern specifically well-suited to filtering, rather than just "one more way to organize code," is that filtering predicates are naturally composable and naturally swappable at runtime. A filter is a pure function of an item to a boolean, which means multiple filters can be combined using ordinary boolean algebra - AND, OR, NOT - without any special-casing. Once each filter is an object implementing the same interface, composing them becomes a matter of writing composite strategies that themselves implement the same interface, a technique often paired with the Composite pattern. The ProductFilter context in the introductory example never needs to change, whether it's applying a single PriceFilter or a deeply nested tree of AndFilter and OrFilter objects, because it only ever calls is_satisfied.

Implementation: Building It Out in Python and TypeScript

The Python example from the original problem statement already demonstrates the core shape: an abstract FilterStrategy base class, concrete strategies like PriceFilter and CategoryFilter, and a ProductFilter context that applies whichever strategy it's given. It's worth extending that example to show what a production version looks like once you add negation, a builder for readability, and a registry that lets filters be selected dynamically - for example, from a UI dropdown or a query string.

from abc import ABC, abstractmethod
from typing import List, Dict, Callable

class FilterStrategy(ABC):
    @abstractmethod
    def is_satisfied(self, item: dict) -> bool:
        ...

class PriceFilter(FilterStrategy):
    def __init__(self, max_price: float):
        self.max_price = max_price

    def is_satisfied(self, item: dict) -> bool:
        return item["price"] <= self.max_price

class CategoryFilter(FilterStrategy):
    def __init__(self, category: str):
        self.category = category

    def is_satisfied(self, item: dict) -> bool:
        return item["category"] == self.category

class NotFilter(FilterStrategy):
    def __init__(self, strategy: FilterStrategy):
        self.strategy = strategy

    def is_satisfied(self, item: dict) -> bool:
        return not self.strategy.is_satisfied(item)

class OrFilter(FilterStrategy):
    def __init__(self, *strategies: FilterStrategy):
        self.strategies = strategies

    def is_satisfied(self, item: dict) -> bool:
        return any(s.is_satisfied(item) for s in self.strategies)

# A registry lets the client choose a strategy by name at runtime,
# e.g. from a query parameter, config file, or UI dropdown.
FILTER_REGISTRY: Dict[str, Callable[..., FilterStrategy]] = {
    "max_price": PriceFilter,
    "category": CategoryFilter,
}

def build_filter_from_request(params: dict) -> FilterStrategy:
    active = []
    for key, factory in FILTER_REGISTRY.items():
        if key in params:
            active.append(factory(params[key]))
    return OrFilter(*active) if len(active) > 1 else active[0]

The build_filter_from_request function is the payoff: request parameters - which might come from an HTTP query string, a GraphQL argument, or a saved user preference - map directly onto strategy objects without a single if/elif chain for filter types. New filter types are added by registering a new factory, not by editing existing logic.

The same structure translates cleanly to TypeScript, which is common in front-end filtering UIs (product listings, admin dashboards, table components) where the strategy needs to be selected interactively:

interface FilterStrategy<T> {
  isSatisfied(item: T): boolean;
}

interface Product {
  name: string;
  price: number;
  category: string;
  stock: number;
}

class PriceFilter implements FilterStrategy<Product> {
  constructor(private maxPrice: number) {}
  isSatisfied(item: Product): boolean {
    return item.price <= this.maxPrice;
  }
}

class CategoryFilter implements FilterStrategy<Product> {
  constructor(private category: string) {}
  isSatisfied(item: Product): boolean {
    return item.category === this.category;
  }
}

class AndFilter<T> implements FilterStrategy<T> {
  private strategies: FilterStrategy<T>[];
  constructor(...strategies: FilterStrategy<T>[]) {
    this.strategies = strategies;
  }
  isSatisfied(item: T): boolean {
    return this.strategies.every((s) => s.isSatisfied(item));
  }
}

class ProductFilter {
  apply(items: Product[], strategy: FilterStrategy<Product>): Product[] {
    return items.filter((item) => strategy.isSatisfied(item));
  }
}

// Usage: strategy chosen at runtime, e.g. from a dropdown selection
const context = new ProductFilter();
const affordable = context.apply(
  products,
  new AndFilter(new CategoryFilter("electronics"), new PriceFilter(100))
);

Generics (FilterStrategy<T>) make the TypeScript version reusable across entity types - the same AndFilter and ProductFilter-equivalent context can filter orders, users, or log records without duplicating the composition logic. This is a natural extension point once a codebase has more than one collection that needs filtering.

A further refinement worth calling out is making strategies serializable. If filters need to be saved (a user's "saved search"), each strategy should expose enough state to reconstruct itself - a toDict()/fromDict() pair in Python, or a discriminated union in TypeScript - so filter configurations can be persisted to a database or URL and rebuilt later without re-deriving the logic from scratch.

Trade-offs and Pitfalls

The Strategy pattern is not free. The most immediate cost is boilerplate: what was a single if item["price"] <= max_price check becomes a class with a constructor, a method, and - in statically typed languages - an interface declaration. For a one-off filter used in a single place, this ceremony can genuinely make code harder to read, not easier. Teams sometimes over-apply the pattern reflexively, wrapping every predicate in a class "for consistency," which adds indirection without adding real flexibility, since nothing about those filters is actually swapped or composed at runtime.

A second pitfall is over-engineering the composition layer. Once AndFilter, OrFilter, and NotFilter exist, it's tempting to build a full expression tree, a query language, or even a mini rules engine on top of them. That can be the right call for something like a search-and-filter UI with many independent criteria, but for a fixed, small set of filters known at compile time, a composite tree adds a debugging burden - tracing why an item was excluded means walking a tree of objects instead of reading a single boolean expression. In these simpler cases, plain functions composed with all()/any() in Python, or &&/|| in TypeScript, deliver the same behavior with less indirection and an easier stack trace.

Performance is a smaller but real concern at scale. Each strategy object introduces a virtual method call (or, in Python, an attribute lookup and function call) instead of an inlined comparison. For filtering a few thousand records this is immaterial, but for hot paths processing millions of rows - a real-time event pipeline, for instance - the abstraction overhead can matter, and a specialized, flattened predicate (or pushing the filter down into a database query) will usually outperform composed strategy objects evaluated in application memory.

Best Practices for Applying the Pattern

Keep each concrete strategy narrowly scoped to a single rule. The value of the pattern comes from strategies being small, independently testable units; a PriceFilter that also checks stock levels "while we're in there" defeats the purpose and reintroduces the tangled logic the pattern was meant to remove. If a rule genuinely depends on two pieces of data, that's a sign it's one rule, not that the interface should grow more parameters.

Favor composition over new strategy subclasses whenever behavior can be expressed as a combination of existing ones. Before writing an InStockElectronicsFilter class, check whether AndFilter(CategoryFilter("electronics"), InStockFilter()) already expresses the same thing. This keeps the strategy catalog from growing combinatorially as new criteria are added, which is one of the main failure modes that reintroduces the original if/else sprawl in a different shape.

Where the target language supports first-class functions, don't reach for a full class hierarchy unless state, serialization, or a runtime registry genuinely requires it. Python and TypeScript both support passing lambdas or plain functions as strategies; a Callable[[dict], bool] type alias can serve the same architectural role as an abstract base class for simple, stateless filters, while still allowing the composable, swappable structure that makes the pattern useful. Reserve the class-based version for filters that carry configuration, need validation on construction, or must be looked up by name from a registry.

Finally, write unit tests against each concrete strategy in isolation, and a separate, smaller set of tests for the composite operators (AndFilter, OrFilter, NotFilter) verifying their boolean logic once, generically, rather than re-testing boolean algebra for every new filter combination. This mirrors how the pattern itself decomposes responsibility, and it keeps the test suite from growing faster than the filters it covers.

Mental Model: Filters as Pluggable Fuses

A useful way to think about this pattern is as a fuse box rather than a wiring diagram. In a badly wired panel, every appliance's protection logic is soldered directly into the same circuit, and changing how one outlet is protected risks touching wires for everything else. A fuse box instead defines a standard socket - any fuse that fits the socket can be swapped in without rewiring the building. The ProductFilter context is the socket; each FilterStrategy implementation is a fuse rated for a specific job. Swapping a PriceFilter for a CategoryFilter is exactly like swapping a 15-amp fuse for a 20-amp one - the panel doesn't care, as long as the fuse fits the socket.

This mental model also clarifies when not to use the pattern. Nobody installs a fuse box for a single lamp with one switch; the overhead of a standardized socket only pays off once there's real variety and real need to swap components without rewiring. The same logic applies to filtering code: if there is genuinely only one filtering rule and no indication that will change, a fuse box is over-engineering, and a hardwired connection - a plain conditional - is the more honest design.

The 80/20 of the Strategy Pattern for Filtering

Most of the value of this pattern comes from a small subset of its full machinery. Defining the interface and a handful of concrete strategies captures the majority of the benefit: it decouples filtering logic from calling code and makes new rules additive rather than invasive. Teams can stop there and already have eliminated the core problem of monolithic conditional functions.

The next-highest-value addition is composability - AndFilter, OrFilter, and NotFilter - because most real filtering needs are combinations of a few simple rules rather than entirely novel logic. Everything beyond that (registries, serialization, rule engines, dynamic query builders) delivers real but comparatively smaller returns, and is worth building only once a concrete requirement - a saved-search feature, a dynamic admin UI - demands it. Building the registry or serialization layer speculatively, before any feature needs it, is the most common way teams over-invest in this pattern relative to the problem they actually have.

Key Takeaways

  • Extract each filtering rule into its own class or function implementing a shared, single-method interface, rather than adding another branch to an existing conditional.
  • Keep a filtering "context" object (like ProductFilter) that only knows how to call the strategy interface, never the concrete rule implementations.
  • Use composite strategies (AndFilter, OrFilter, NotFilter) to combine simple rules instead of writing a new class for every combination.
  • Reach for a full class-based strategy only when filters carry state, need runtime lookup by name, or must be serialized; use plain functions or lambdas for simple, stateless, one-off predicates.
  • Push filtering into the database or data layer (e.g., SQL WHERE clauses) when working with large datasets, and reserve in-memory strategy objects for cases where the flexibility is worth the overhead.

Conclusion

The Strategy pattern earns its place in a filtering codebase not because it's the "correct" object-oriented way to write a boolean check, but because it solves a specific, recurring problem: filtering logic that needs to grow, combine, and change at runtime without destabilizing existing code. The core discipline - a shared interface, small independent implementations, and a context that stays ignorant of the details - scales from a simple e-commerce product list to a complex, user-configurable search system.

Like most design patterns, its cost is real and its benefit is conditional. A single, stable filtering rule doesn't need an abstract base class any more than a single lamp needs a fuse box. The judgment call professional engineers should make is not "should I use Strategy" in the abstract, but whether the filtering requirements in front of them are already showing the symptoms described here - a growing conditional, awkward parameter combinations, or a genuine need for runtime configurability. When those symptoms appear, the pattern converts a fragile decision tree into a small, testable, extensible catalog of interchangeable rules - exactly the kind of trade favorable engineering judgment is built on.

References