Stop Using If-Else Chains: Use the Registry Pattern in Python Instead

Stop Using If-Else Chains: Use the Registry Pattern in Python Instead
 

Introduction

 
Every Python codebase has this problem. A function that starts small. Two branches, maybe three. Then someone adds a case, someone else adds another, and a year later you’ve got 200 lines of if/elif/else that nobody wants to touch. Here’s an example:

def get_model(name):
    if name == "logreg":
        return LogisticRegression()
    elif name == "random_forest":
        return RandomForestClassifier()
    elif name == "svm":
        return SVC()
    elif name == "xgboost":
        return XGBClassifier()
    # ... 15 more branches
    else:
        raise ValueError(f"Unknown model: {name}")

 

And yeah, it works. But it also breaks the Open/Closed Principle, which states that software entities (classes, modules, and functions) should be open for extension but closed for modification. There is a better way to handle this problem: the registry pattern. This article covers what the registry pattern is, how to build it up from a five-line dictionary to a production-grade reusable class, and when it actually earns its place in your code. So, let’s get started.

 

The Problem With If-Else Chains

 
A long conditional chain fails in a few specific ways:

  • It violates the Open/Closed Principle. New case, new edit to a function that already worked. Yesterday’s tested code gets cracked open, retested, and reviewed again. The unit of change should be “add a file,” not “modify the central dispatcher.”
  • It piles unrelated logic into one place. Say your payment dispatcher covers credit cards, PayPal, and crypto. Now three domains that have nothing to do with each other are sharing one function. The elif ladder forces them to share a room anyway.
  • It scales badly. Every new branch adds to the cognitive weight of the whole function. Twenty branches is twenty things to scroll past every time you are debugging branch number three.
  • It cannot be extended from outside. Ship a library with a hardcoded get_model() chain and your users are stuck. They cannot add their own model without monkey-patching or forking. The logic is sealed shut.

The registry pattern fixes all four by flipping the relationship. Instead of the dispatcher knowing about every option, each option announces itself to the dispatcher.

What is the registry pattern?

It is basically a central lookup table that maps keys to objects (functions, classes, instances), where each object registers itself instead of being hardcoded into some conditional. In Python, that lookup table is almost always a dictionary, and “registering” is usually done with a decorator.

 

Going From If-Else to a Dictionary

 
The smallest possible win is to swap the chain for a dictionary lookup. One step, and the linear scan is gone:

MODEL_REGISTRY = {
    "logreg": LogisticRegression,
    "random_forest": RandomForestClassifier,
    "svm": SVC,
    "xgboost": XGBClassifier,
}

def get_model(name):
    try:
        return MODEL_REGISTRY[name]
    except KeyError:
        raise ValueError(
            f"Unknown model: {name!r}. "
            f"Available: {list(MODEL_REGISTRY)}"
        ) from None

 

This is already a registry — just a hand-maintained one. Dispatch is O(1), the options are introspectable with list(MODEL_REGISTRY), and the dispatcher never changes. One wart remains: every new model still means editing the dict and importing its class at the top of the file. You can do better by letting each component register itself.

 

Building a Decorator-Based Registry

 
This is the version you’ll actually use day to day. Registration happens in a decorator, so every function or class declares its own key right where it is defined:

PAYMENT_HANDLERS = {}

def register(payment_type):
    def decorator(func):
        PAYMENT_HANDLERS[payment_type] = func
        return func
    return decorator

@register("credit_card")
def charge_credit_card(amount):
    return f"Charged ${amount} to credit card"

@register("paypal")
def charge_paypal(amount):
    return f"Charged ${amount} via PayPal"

@register("crypto")
def charge_crypto(amount):
    return f"Charged ${amount} in crypto"

def process_payment(payment_type, amount):
    handler = PAYMENT_HANDLERS.get(payment_type)
    if handler is None:
        raise ValueError(f"Unknown payment type: {payment_type!r}")
    return handler(amount)

 

Look at what changed. The process_payment dispatcher is four lines, and it will never grow. Want Apple Pay? Write a new function, slap @register("apple_pay") on it, put it in whatever file you like, and you’re done. No central list to edit. No merge conflict. No reopening tested code. The handler sits right next to its own key, which is exactly where the next reader will look for it.

 

Building a Reusable Registry Class

 
Once you have two or three registries lying around, you will get tired of rewriting the same decorator boilerplate. Wrap it in a small class and you get collision detection, better error messages, and a clean API for free:

class Registry:
    """A reusable name-to-object registry."""

    def __init__(self, name):
        self.name = name
        self._registry = {}

    def register(self, key):
        def decorator(obj):
            if key in self._registry:
                raise KeyError(
                    f"{key!r} already registered in {self.name!r}"
                )
            self._registry[key] = obj
            return obj
        return decorator

    def get(self, key):
        if key not in self._registry:
            raise KeyError(
                f"{key!r} not found in {self.name!r}. "
                f"Available: {list(self._registry)}"
            )
        return self._registry[key]

    def __contains__(self, key):
        return key in self._registry

    def keys(self):
        return self._registry.keys()

 

Now use it to build a text-processing pipeline driven entirely by config:

transforms = Registry("transforms")

@transforms.register("lowercase")
def to_lower(text):
    return text.lower()

@transforms.register("strip")
def strip_whitespace(text):
    return text.strip()

@transforms.register("remove_digits")
def remove_digits(text):
    return "".join(c for c in text if not c.isdigit())

# The pipeline is now just data. It could come from a YAML file,
# a CLI argument, or a database row.
pipeline = ["strip", "lowercase", "remove_digits"]
text = "  Order #4521 CONFIRMED  "

for step in pipeline:
    text = transforms.get(step)(text)

print(repr(text))

Output:
'order # confirmed'

 

This is where the pattern pays for itself. The behavior of the program is now described by data — a list of strings — not by code. Reordering the pipeline, adding a step, or handing the whole thing to a non-programmer through a config file all become trivial.

 

Auto-Registering Classes With __init_subclass__

 
When your registry holds classes instead of functions, Python has an even slicker trick. The __init_subclass__ hook (available since Python 3.6) fires automatically every time a subclass is defined, so subclasses register themselves with no decorator at all:

class DataLoader:
    _registry = {}

    def __init_subclass__(cls, fmt=None, **kwargs):
        super().__init_subclass__(**kwargs)
        if fmt:
            DataLoader._registry[fmt] = cls

    @classmethod
    def get_loader(cls, fmt):
        if fmt not in cls._registry:
            raise ValueError(
                f"No loader for {fmt!r}. "
                f"Available: {list(cls._registry)}"
            )
        return cls._registry[fmt]

class CSVLoader(DataLoader, fmt="csv"):
    def load(self, path):
        return f"Loading CSV from {path}"

class JSONLoader(DataLoader, fmt="json"):
    def load(self, path):
        return f"Loading JSON from {path}"

class ParquetLoader(DataLoader, fmt="parquet"):
    def load(self, path):
        return f"Loading Parquet from {path}"

loader = DataLoader.get_loader("parquet")
print(loader.load("sales.parquet"))   # Loading Parquet from sales.parquet

 

No decorator anywhere. Subclassing DataLoader with a fmt= argument is enough to register the new class. This is how a lot of frameworks build their plugin systems under the hood.

 

Where the Registry Pattern Is Useful in Practice

 
This is not an academic exercise. It is the backbone of tools you already use.

  • Machine learning experiment configs. Hugging Face Transformers, Detectron2, and MMDetection all use registries so you can pick a model, optimizer, or augmentation by string name in a YAML file. build_model({"model": "resnet50"}) beats a giant if backbone == ... block, and it lets researchers add architectures without ever touching the trainer.
  • File format and parser dispatch. Map extensions like "csv", "json", and "parquet" to loader classes. Supporting a new format becomes “write one class,” not “edit the loader.”
  • Web framework routing. Flask‘s @app.route("/users") and Click‘s @cli.command() are registries in disguise. The decorator maps a URL or command name to the function that handles it.
  • Plugin architectures. Any “drop a file in this folder and it just works” system — whether pytest fixtures, Airflow operators, or serializer backends — is almost always a registry collecting components at import time.
  • Event handlers and state machines. Map event names or states to handler functions instead of branching on them. The transition table turns into a readable dictionary rather than a nest of conditionals.

 

Practical Considerations and Things to Watch Out For

 

  • Registration only happens on import. A decorator runs when Python executes the file it lives in. If your handlers sit in handlers/apple_pay.py and nothing ever imports that module, the @register decorator never fires and the handler quietly goes missing. The fix is to make sure registration modules get imported — usually through an explicit import in a package’s __init__.py, or a small discovery loop with pkgutil.iter_modules that imports everything in a plugin folder.
  • Guard against silent overwrites. With a plain dict, two components registering the same key clobber each other without a peep. As the Registry class above shows, raising on a duplicate key turns a baffling runtime bug into an obvious error at import time.
  • Show people what is available. Always expose the keys with list(registry.keys()) and put them in your error messages. “Unknown model: ‘lgbm’. Available: [‘logreg’, ‘random_forest’, ‘xgboost’]” saves far more debugging time than a bare KeyError.
  • Do not reach for it too early. A registry is overkill for two or three stable branches whose logic genuinely differs. If the branches share no common signature, or the conditions are ranges rather than discrete keys (if score > 0.9 ... elif score > 0.5 ...), a plain conditional is clearer. The registry wins in one specific situation: you are dispatching on a discrete key to interchangeable behaviors, and you expect that set of behaviors to grow.

 

Wrapping Up

 
The registry pattern trades a growing, central, hard-to-extend if/elif/else chain for a lookup table that components fill in themselves. The payoff is concrete. Your dispatcher stops changing. New features show up as new files instead of edits to old ones. Behavior becomes something you can drive from a config. And users of your code get a real extension point instead of a locked door.

Start small. Next time you catch yourself typing a third elif name == ..., stop and ask whether a dictionary would do. Usually it would. From there, the decorator and class versions are a short hop away.

# Before
if kind == "a": ...
elif kind == "b": ...
elif kind == "c": ...

# After
@registry.register("a")
def handle_a(): ...

 

Your future self, scrolling past a four-line dispatcher instead of a 200-line ladder, will thank you.
 
 

Kanwal Mehreen is a machine learning engineer and a technical writer with a profound passion for data science and the intersection of AI with medicine. She co-authored the ebook “Maximizing Productivity with ChatGPT”. As a Google Generation Scholar 2022 for APAC, she champions diversity and academic excellence. She’s also recognized as a Teradata Diversity in Tech Scholar, Mitacs Globalink Research Scholar, and Harvard WeCode Scholar. Kanwal is an ardent advocate for change, having founded FEMCodes to empower women in STEM fields.

Similar Posts

Leave a Reply