Chapter 6 · Senior Developer

The plugin registry pattern

A plugin registry replaces a sprawling if name == 'a': ... elif name == 'b': ... chain with a simple dict that maps names to functions. To add a new behaviour you register a new entry; to use one you look it up by name and call it. No giant conditional to edit each time.

The elegant way to fill the registry is a registration decorator: @register('upper') over a function stores that function in the dict under 'upper' and hands the original function straight back unchanged. The decorator's only job is the side effect of registering — the function itself is untouched.

Calling a handler by its name is dynamic dispatch: at runtime you pick which function to run based on data (a string), not on code written ahead of time. registry[name](arg) does the lookup and the call in one step.

The big win is extensibility — the open/closed principle in practice. The dispatch code is *closed* for modification (you never touch it) but the set of behaviours is *open* for extension (anyone can register more, even from another module). This is how Flask routes, pytest plugins, and Click commands all hang together.

Syntax

REGISTRY = {}

def register(name):
    def decorator(fn):
        REGISTRY[name] = fn        # the side effect
        return fn                  # hand the original back unchanged
    return decorator

@register("upper")
def to_upper(text): ...

# Dynamic dispatch — pick the function by name at runtime:
REGISTRY["upper"]("hello")

Worked examples

Register handlers with a decorator, dispatch by name

HANDLERS = {}

def register(name):
    def decorator(fn):
        HANDLERS[name] = fn
        return fn
    return decorator

@register("upper")
def to_upper(text):
    return text.upper()

@register("reverse")
def reverse(text):
    return text[::-1]

print(sorted(HANDLERS))         # the registered names
print(HANDLERS["upper"]("hi"))  # dynamic dispatch
# Output:
# ['reverse', 'upper']
# HI

A dispatch helper with a clear error on unknown names

HANDLERS = {"upper": str.upper, "lower": str.lower}

def dispatch(name, text):
    if name not in HANDLERS:
        raise KeyError(f"No handler: {name}")
    return HANDLERS[name](text)

print(dispatch("upper", "hi"))   # HI
try:
    dispatch("ghost", "hi")
except KeyError as e:
    print(e)
# Output:
# HI
# 'No handler: ghost'

Open for extension — add a behaviour without touching dispatch

HANDLERS = {}

def register(name):
    def decorator(fn):
        HANDLERS[name] = fn
        return fn
    return decorator

@register("shout")
def shout(text):
    return text.upper() + "!"

# Later, anywhere, a new plugin slots in — no edit to the dispatcher:
@register("whisper")
def whisper(text):
    return text.lower() + "..."

print(HANDLERS["whisper"]("HELLO"))
# Output:
# hello...

Common mistakes & gotchas

The decorator must return the original function

A registration decorator's job is the side effect of storing fn, so it must return fn afterwards. Forget the return and the decorated name becomes Noneto_upper is no longer callable. The function is registered but locally destroyed.

Registering the same name twice silently overwrites

Two @register('upper') decorators leave only the last one in the dict — dicts don't complain about duplicate keys. If collisions matter, guard it: if name in REGISTRY: raise ValueError(...) inside the decorator.

Plugins only register when their module is imported

The decorator runs at import time, so a plugin in a module nobody imports never registers — the registry looks mysteriously empty. Frameworks solve this by explicitly importing or auto-discovering plugin modules at startup; remember to actually load them.

Why it matters

The registry pattern is how real systems stay extensible without becoming a tangle of conditionals. New features plug in by registering, and the core dispatch logic never changes — which means less risk, cleaner diffs, and the ability for third parties to extend your system without forking it. It's one of the most reused architectural patterns in the Python world.