Every codebase eventually hits this moment: you have N types of things to handle, and the handling logic differs per type. Errors. HTTP requests. File formats. CI failure categories. Whatever. The first instinct is always the same — a growing if/else chain in one function that knows about every type.
It works. Until it doesn’t. The function grows. Every new type means touching the same file. Two people adding handlers in parallel get merge conflicts on the same block of code. The function becomes a 200-line monument to “we should really refactor this.”
This post is about the set of patterns that solve this problem: interfaces, abstract classes, factories, and the multi-handler registry pattern. They’re all connected, and once you see how they fit together, you’ll recognize them everywhere.
The if/else graveyard
Here’s where every project starts:
def handle_error(error_type: str, context: dict):
if error_type == "network":
retry_with_backoff(context)
elif error_type == "database":
rollback_and_reconnect(context)
elif error_type == "auth":
refresh_token_and_retry(context)
elif error_type == "rate_limit":
wait_and_retry(context)
elif error_type == "timeout":
increase_timeout_and_retry(context)
# ... 15 more of these
else:
raise ValueError(f"Unknown error type: {error_type}")
This is fine for three types. At fifteen, it’s painful. At thirty, it’s a disaster.
The problems are structural:
- Every new type modifies existing code. You’re editing a working function to add new behavior. That’s a regression risk.
- No encapsulation. The handling logic for network errors sits right next to the logic for database errors. They share nothing, but they share a file.
- Testing is awkward. You can’t test the network error handler in isolation — you have to go through the dispatcher.
- No discoverability. Want to know what error types are supported? Read the whole function.
The fix is to split the “what to do” from the “which one to pick.” That’s what these patterns do.
The contract: interface and abstract class
Before you can swap handlers around, you need a promise: every handler looks the same from the outside. It takes the same inputs, returns the same outputs, has the same method names. That’s the contract.
In Python, you write this with ABC:
from abc import ABC, abstractmethod
class ErrorHandler(ABC):
@abstractmethod
def can_handle(self, error_type: str) -> bool:
"""Return True if this handler knows how to handle this error type."""
...
@abstractmethod
def handle(self, context: dict) -> None:
"""Actually handle the error."""
...
Now any handler must implement can_handle and handle. If you forget one, Python raises TypeError at instantiation time — not at call time, not at 3 AM in production. At the moment you try to create the object. That’s the whole point.
The language spectrum
Different languages express contracts differently, and the differences matter:
Python’s ABC is opt-in enforcement. You inherit from ABC, decorate methods with @abstractmethod, and Python checks at instantiation. If you skip ABC entirely, nothing stops you from writing a “handler” that’s missing half the interface. Duck typing means it’ll work until it doesn’t.
# This is also valid Python. No ABC, no contract, no safety net.
class ChaosHandler:
def handle(self, context):
print("works until someone calls can_handle()")
Go’s interfaces are implicit — you don’t declare “I implement this interface.” You just… have the right methods. If your struct has CanHandle(string) bool and Handle(map[string]any), it satisfies the interface. No inheritance keyword. No registration. The compiler checks it at use-site.
type ErrorHandler interface {
CanHandle(errorType string) bool
Handle(ctx map[string]any)
}
// NetworkHandler satisfies ErrorHandler automatically.
// No "implements" keyword needed.
type NetworkHandler struct{}
func (h NetworkHandler) CanHandle(t string) bool { return t == "network" }
func (h NetworkHandler) Handle(ctx map[string]any) { /* retry logic */ }
This is structurally elegant. It’s also terrifying if you accidentally match an interface you didn’t mean to. In practice this almost never happens, but it’s worth knowing the tradeoff.
TypeScript’s interfaces are compile-time only — they vanish after transpilation. They exist entirely for the type checker. No runtime enforcement at all.
interface ErrorHandler {
canHandle(errorType: string): boolean;
handle(context: Record<string, unknown>): void;
}
C#’s abstract classes sit between Python and Go. You get compile-time enforcement, runtime type information, and the ability to mix abstract methods with concrete shared logic (template method pattern).
public abstract class ErrorHandler {
public abstract bool CanHandle(string errorType);
public abstract void Handle(Dictionary<string, object> context);
// Shared logic — all handlers get this for free
public void LogAndHandle(Dictionary<string, object> context) {
Console.WriteLine($"Handling error at {DateTime.Now}");
Handle(context);
}
}
When to use which
The rule of thumb: use the simplest thing that gives you the safety you need.
| Situation | Use |
|---|---|
| Just need method signature enforcement | Interface (or Python Protocol) |
| Need shared behavior across handlers | Abstract class |
| Small team, tests catch everything | Duck typing is fine |
| Public API other teams consume | Strictest option your language offers |
Python’s Protocol (from typing) deserves a mention — it gives you Go-style structural typing in Python. You define the shape, and any class matching that shape satisfies it. No inheritance required.
from typing import Protocol
class ErrorHandler(Protocol):
def can_handle(self, error_type: str) -> bool: ...
def handle(self, context: dict) -> None: ...
In practice, I reach for ABC when I want runtime enforcement (“you forgot to implement this”) and Protocol when I just want type checker support.
The factory: who decides which handler?
The contract says what handlers look like. The factory says which one to use.
Simple factory
The most basic version — a function or class that maps input to handler:
class ErrorHandlerFactory:
def get_handler(self, error_type: str) -> ErrorHandler:
handlers = {
"network": NetworkErrorHandler,
"database": DatabaseErrorHandler,
"auth": AuthErrorHandler,
}
handler_class = handlers.get(error_type)
if handler_class is None:
raise ValueError(f"No handler for: {error_type}")
return handler_class()
“Wait,” you’re thinking, “that’s just a dict.” Yes. It is. And for many cases, that’s all you need. The improvement over the if/else chain is that each handler is its own class with its own file, its own tests, its own lifecycle. The factory just picks one.
Factory method
The factory method pattern pushes the creation decision into subclasses. This matters when different contexts need different handlers for the same type:
class BaseErrorProcessor(ABC):
@abstractmethod
def create_handler(self, error_type: str) -> ErrorHandler:
...
def process(self, error_type: str, context: dict):
handler = self.create_handler(error_type)
handler.handle(context)
class ProductionErrorProcessor(BaseErrorProcessor):
def create_handler(self, error_type: str) -> ErrorHandler:
if error_type == "network":
return NetworkErrorHandler(retry_count=5, alert=True)
# production-specific configuration...
class TestErrorProcessor(BaseErrorProcessor):
def create_handler(self, error_type: str) -> ErrorHandler:
if error_type == "network":
return NetworkErrorHandler(retry_count=0, alert=False)
# test-specific: no retries, no alerts
Same handler types, different configurations. The processing logic stays identical.
Abstract factory
When you need families of related objects — not just “a handler” but “a handler + a logger + a metrics collector that all work together” — that’s the abstract factory. It creates a whole suite of compatible objects.
class InfraFactory(ABC):
@abstractmethod
def create_handler(self) -> ErrorHandler: ...
@abstractmethod
def create_logger(self) -> Logger: ...
@abstractmethod
def create_alerter(self) -> Alerter: ...
class AWSInfraFactory(InfraFactory):
def create_handler(self): return AWSErrorHandler()
def create_logger(self): return CloudWatchLogger()
def create_alerter(self): return SNSAlerter()
class AzureInfraFactory(InfraFactory):
def create_handler(self): return AzureErrorHandler()
def create_logger(self): return AzureMonitorLogger()
def create_alerter(self): return AzureAlerter()
You swap one factory, and everything changes together. No mix-and-match bugs where you’re sending Azure errors to an AWS alerter.
In practice? I almost never need this. Simple factory covers 90% of cases. Factory method covers the next 9%. Abstract factory is for when you’re building a framework.
The multi-handler pattern
Here’s where it gets interesting. The factory pattern assumes you know which handler to use based on some input. But what if you don’t? What if the handlers themselves should decide?
This is the strategy pattern meets a registry. Each handler declares what it can handle, and a dispatcher asks them one by one.
class ErrorDispatcher:
def __init__(self):
self._handlers: list[ErrorHandler] = []
def register(self, handler: ErrorHandler):
self._handlers.append(handler)
def dispatch(self, error_type: str, context: dict):
for handler in self._handlers:
if handler.can_handle(error_type):
handler.handle(context)
return
raise ValueError(f"No handler registered for: {error_type}")
Now the dispatcher doesn’t know anything about error types. It doesn’t import NetworkErrorHandler. It doesn’t have a dict mapping. It just asks each handler: “is this yours?” The first one that says yes gets the job.
dispatcher = ErrorDispatcher()
dispatcher.register(NetworkErrorHandler())
dispatcher.register(DatabaseErrorHandler())
dispatcher.register(AuthErrorHandler())
dispatcher.dispatch("network", {"url": "https://api.example.com"})
Why this is different from a factory
A factory is a lookup: input → handler. The factory owns the mapping.
A multi-handler dispatcher is a negotiation: input → ask each handler → first match wins. The handlers own the matching logic.
This distinction matters when:
- Handlers have complex matching logic. “I handle network errors, but only for URLs matching
*.internal.corp.” That logic belongs in the handler, not in a central factory. - Handlers are added dynamically. Plugins. Extensions. Things you don’t know about at compile time.
- Multiple handlers might apply. Maybe you want all matching handlers to run, not just the first.
Chain variant: all matching handlers run
A small tweak and you get middleware-style processing:
def dispatch_all(self, error_type: str, context: dict):
handled = False
for handler in self._handlers:
if handler.can_handle(error_type):
handler.handle(context)
handled = True
if not handled:
raise ValueError(f"No handler registered for: {error_type}")
This is how HTTP middleware works. Each middleware checks whether it cares about the request, does its thing (logging, auth, CORS, compression), and the request keeps moving through the chain. It’s how Express.js, Django, and ASP.NET all work.
The registry pattern
The multi-handler pattern plus a class-level decorator gives you automatic registration — handlers register themselves just by existing.
class HandlerRegistry:
_handlers: dict[str, type[ErrorHandler]] = {}
@classmethod
def register(cls, error_type: str):
def decorator(handler_class: type[ErrorHandler]):
cls._handlers[error_type] = handler_class
return handler_class
return decorator
@classmethod
def get_handler(cls, error_type: str) -> ErrorHandler:
handler_class = cls._handlers.get(error_type)
if handler_class is None:
raise ValueError(f"No handler for: {error_type}")
return handler_class()
@HandlerRegistry.register("network")
class NetworkErrorHandler(ErrorHandler):
def can_handle(self, error_type: str) -> bool:
return error_type == "network"
def handle(self, context: dict):
print(f"Retrying network request to {context.get('url')}")
@HandlerRegistry.register("database")
class DatabaseErrorHandler(ErrorHandler):
def can_handle(self, error_type: str) -> bool:
return error_type == "database"
def handle(self, context: dict):
print("Rolling back transaction and reconnecting")
Now adding a handler is: write a class, slap a decorator on it. No need to edit a factory. No need to touch a central mapping file. The handler registers itself at import time.
Auto-discovery
Take it one step further: automatically import all handler modules from a directory.
import importlib
import pkgutil
def discover_handlers(package_name: str):
"""Import all modules in a package, triggering @register decorators."""
package = importlib.import_module(package_name)
for _, module_name, _ in pkgutil.walk_packages(
package.__path__, prefix=package.__name__ + "."
):
importlib.import_module(module_name)
Drop a new handler file in handlers/, restart, it’s live. This is how plugin architectures work — pytest fixtures, Flask blueprints, Django apps, VSCode extensions. The framework discovers handlers at startup without knowing about them in advance.
In Go, the equivalent uses init() functions and blank imports:
// handlers/network.go
func init() {
registry.Register("network", &NetworkHandler{})
}
// main.go
import _ "myapp/handlers" // blank import triggers init()
In TypeScript, you’d typically use explicit barrel files or a runtime scanner with require.context (webpack) or import.meta.glob (Vite).
Real-world examples
These patterns aren’t academic. Once you see them, they’re everywhere.
HTTP middleware
Every web framework uses the chain-of-handlers pattern. Each middleware is a handler that checks the request, optionally does something, and passes it along:
# Simplified middleware chain
class Middleware(ABC):
@abstractmethod
def process(self, request: Request, next_handler) -> Response:
...
class AuthMiddleware(Middleware):
def process(self, request, next_handler):
if not request.headers.get("Authorization"):
return Response(401, "Unauthorized")
return next_handler(request)
class LoggingMiddleware(Middleware):
def process(self, request, next_handler):
print(f"{request.method} {request.path}")
response = next_handler(request)
print(f"→ {response.status}")
return response
The order matters. Auth before logging means unauthenticated requests don’t get logged. Logging before auth means they do. Same handlers, different behavior based on registration order.
CI failure analyzers
In our CI pipeline, we have a set of failure analyzers. Each one knows how to classify a specific type of failure:
class FailureAnalyzer(ABC):
@abstractmethod
def matches(self, log_output: str) -> bool: ...
@abstractmethod
def classify(self, log_output: str) -> FailureReport: ...
class ImportErrorAnalyzer(FailureAnalyzer):
def matches(self, log_output: str) -> bool:
return "ImportError" in log_output or "ModuleNotFoundError" in log_output
def classify(self, log_output: str) -> FailureReport:
# Parse the import error, identify missing module,
# check if it's a missing dep or circular import
...
class OOMAnalyzer(FailureAnalyzer):
def matches(self, log_output: str) -> bool:
return "OutOfMemoryError" in log_output or "CUDA out of memory" in log_output
def classify(self, log_output: str) -> FailureReport:
# Parse memory usage, identify the allocation that failed
...
A dispatcher runs through all analyzers. The first one that matches classifies the failure. Adding a new analyzer for a new failure type is one file, one class, one decorator.
Bazel rule classes
Bazel’s rule system is this pattern in Starlark. Each language has a “rule” that knows how to build that language’s targets — py_library, cc_library, java_library. They all follow the same interface (take sources, deps, produce outputs), but the build logic differs completely. Gazelle’s language plugins follow the same pattern: each plugin knows how to generate BUILD files for its language.
When NOT to use these
This is the over-engineering section. It’s shorter than you’d expect, because the temptation is real and frequent.
If you have 2-3 types and they won’t grow, a match statement is clearer:
match error_type:
case "network": retry_with_backoff(context)
case "database": rollback_and_reconnect(context)
case _: raise ValueError(f"Unknown: {error_type}")
Three lines. Everyone understands it immediately. No abstraction needed.
If all your “handlers” do the same thing with different parameters, use a dict of configs, not a class hierarchy:
RETRY_CONFIG = {
"network": {"max_retries": 5, "backoff": 2.0},
"database": {"max_retries": 3, "backoff": 1.0},
"auth": {"max_retries": 1, "backoff": 0},
}
def handle_error(error_type: str, context: dict):
config = RETRY_CONFIG[error_type]
retry_with_config(context, **config)
Data-driven beats class-driven when the logic is uniform and only the parameters differ.
If you’re the only person working on the code and it’s a script you’ll run twice, just use if/else. Design patterns exist to manage complexity at scale. A 50-line script doesn’t have complexity at scale.
The heuristic I use: would a new handler require new logic, or just new data? If it’s new logic — different control flow, different API calls, different error handling — the multi-handler pattern pays for itself. If it’s new data — different config values, different strings, different thresholds — a dict is fine.
Putting it all together
Here’s the full picture, from individual handler to auto-discovering registry:
from abc import ABC, abstractmethod
import importlib
import pkgutil
# 1. The contract
class ErrorHandler(ABC):
@abstractmethod
def can_handle(self, error_type: str) -> bool: ...
@abstractmethod
def handle(self, context: dict) -> None: ...
# 2. The registry (with decorator)
class Registry:
_handlers: list[type[ErrorHandler]] = []
@classmethod
def register(cls):
def decorator(handler_class):
cls._handlers.append(handler_class)
return handler_class
return decorator
@classmethod
def dispatch(cls, error_type: str, context: dict):
for handler_class in cls._handlers:
handler = handler_class()
if handler.can_handle(error_type):
handler.handle(context)
return
raise ValueError(f"Unhandled: {error_type}")
# 3. Handlers register themselves
@Registry.register()
class NetworkErrorHandler(ErrorHandler):
def can_handle(self, error_type: str) -> bool:
return error_type == "network"
def handle(self, context: dict):
print(f"Retrying {context['url']} with exponential backoff")
@Registry.register()
class DatabaseErrorHandler(ErrorHandler):
def can_handle(self, error_type: str) -> bool:
return error_type == "database"
def handle(self, context: dict):
print("Rolling back and reconnecting")
# 4. Auto-discover handlers from a package
def bootstrap(handler_package: str = "myapp.handlers"):
discover_handlers(handler_package)
# 5. Use it
Registry.dispatch("network", {"url": "https://api.example.com"})
The progression is: if/else → dict lookup → factory → multi-handler dispatcher → self-registering registry → auto-discovered plugins. Each step adds flexibility and removes coupling. You stop at whichever step matches your actual complexity.
Most of the time, that’s step two. And that’s fine.