Every codebase has that one factory/builder nobody wants to touch. It starts out innocent, but two years and ten features later, it's mutated into a 500-line if-else monstrosity:
class FishFactory:
def create_fish(self, fish_id: int, water_type: str, depth: int, is_holiday: bool) -> Fish:
if fish_id == 1:
return ClownFish()
elif fish_id == 28:
# TODO: have Brian fix this later - 06/12/2016
if water_type == "SALT" or water_type == "SALTY":
return BlueTang()
elif water_type == "FRESH":
return RoboticBlueTang()
elif fish_id == 3:
if depth > 100:
return DeepSeaGramma()
elif is_holiday:
return RoyalChristmasGramma()
elif fish_id == 4:
# URGENT: Holiday hack for the 'Santa Goby' skin. Remove after Xmas 2023!!
if is_holiday:
return SantaHatGoby()
return YellowGoby()
elif fish_id == 6:
# NOTE: ID 6 is now deprecated (the pufferfish incident), but we kept the code just in case
return LongfinBlackMolly_DEPRECATED()
elif fish_id == 7:
# ... (200 more lines of this)
return Cod()
# Default fallback that shouldn't exist but does
return GenericFish()
factory = FishFactory()
my_fish = factory.create_fish(fish_id=28, water_type="SALT", depth=50, is_holiday=False)
These factory classes are typically 1) critical to core logic and 2) frequently enhanced, which means they must be readable and maintainable. But they become the opposite - a dumping ground for every new edge case and feature flag.
But then how do we fix this? Some might try splitting it into multiple factories, others might reach for a strategy pattern. Both can work, but they often just move the complexity around rather than solving the root problem.
In real-world software engineering, the best answer is often the Registry Pattern.
A registry separates your lookup logic (the data structure that maps IDs to classes) from your routing logic (the business rules that decide which ID to use). This now:
- Maintains the O(1) speed of a hash table
- Enforces best practices for maintainable and clean code, preventing god objects by keeping concerns separated
- Allows for runtime configuration through feature flags/plugins
Learning this pattern and the principles that guide it will increase your software’s maintainability, cohesion, and your co-workers impression of your technical skills :).
Common Traps: What You Should Avoid
Metaclass registry method
This registry implementation uses the init_subclass dunder method to automatically add classes that inherit from it into its store.
# --- registry.py ---
SHAPE_REGISTRY = {}
class BaseShape:
def __init_subclass__(cls, key: str, **kwargs):
super().__init_subclass__(**kwargs)
SHAPE_REGISTRY[key] = cls
# --- shapes.py ---
from .registry import BaseShape
class Circle(BaseShape, key="circle"):
...
class Square(BaseShape, key="square"):
...
It’s a neat piece of code golf, but isn’t recommended because of how it “magically” registers classes, and is confusing to both debug and extend, hiding the logic of registration inside the parent class.
Decorator registry method
This method is a step up. It uses a custom decorator that explicitly “announces” registration of classes.
# --- registry.py ---
SHAPE_REGISTRY = {}
def register_shape(key: str):
def decorator(cls):
SHAPE_REGISTRY[key] = cls
return cls
return decorator
# --- shapes.py ---
from .registry import register_shape
@register_shape("circle")
class Circle:
...
@register_shape("square")
class Square:
...
# --- main.py ---
from .shapes import * # This import MUST happen (code smell already)
from .registry import SHAPE_REGISTRY
def create_shape(shape_name: str):
return SHAPE_REGISTRY[shape_name]()
The issue is that decorators only run when the file containing them is *imported*. If you define Circle in shapes.py but never explicitly import that file in main.py, the decorator will not work - “magic” behavior you want to avoid. This forces you to import modules you aren't explicitly using just to trigger the side-effect of registration.
This method relies on:
- Global state (debugging mess, hard to test).
- You needing to import the file the original dictionary is in (causes annoying & unexpected bugs).
- Doesn’t support registry editing at runtime or multiple registries.
At this point, I highly suggest opening up a Python file in your IDE and following along. You’ll learn the most by doing.
Tutorial: the right way
Let’s make a registry that can add and get key-value pairs. The most basic registry is a smart wrapper around a dictionary:
class Registry:
def __init__(self):
self._store: dict[str, str] = {}
def register(self, key: str, value: str) -> None:
self._store[key] = value
def get(self, key: Key) -> Value | None:
return self._store.get(key)
It’s important to have explicit error handling and we should probably support being able to remove a key:
def register(self, key: str, value: str) -> None:
if key in self._store:
raise ValueError(f"Key '{key}' is already registered.")
self._store[key] = value
def get(self, key: Key) -> Value | None:
# This means:
# "if variable `value` - which I'm assigning to be "self._store.get(key) -
#. exists, then return it, otherwise throw an error"
if value := self._store.get(key):
return value
else:
raise KeyError(f"Key '{key}' not found in registry.")
def unregister(self, key: Key) -> None
if key not in self._store:
raise KeyError(f"Key '{key}' not found in registry.")
del self._store[key]
💡
Tip: If a function cannot do what its name says (e.g., get), it should throw an error, not return None.
Tip: Instead of adding a dedicated function to check if a key exists, implement the __contains__ dunder method
def __contains__(self, key: KeyType) -> bool:
"""Allows for `if Key in Registry` to work easily"""
return key in self._store
Now we can do this:
my_registry: Registry = Registry()
registry.register("API_KEY", "abc12345")
if "API_KEY" in registry:
print("API_KEY is registered.")
# Output: "API_KEY is registered."
Make it Generic (Generics)
So far, our registry can only hold strings. If we needed to support an integer Key and an Object value, we’d need to re-create the entire class.
We can use Generics - placeholders for types we promise to fill in later - to give us first-class type safety
(Note: Syntax below requires Python 3.12+)
class Registry[Key, Value]:
"""Generic implementation of the Registry pattern"""
def __init__(self):
self._store: dict[Key, Value] = {}
# ... (other methods now use Key and Value types)
Now we can create registries holding anything we want with ultimate type hinting safety
Integer / string registry
user_registry: Registry[int, str] = Registry()
user_registry.register(101, "Alice")
user_registry.register(205, "Bob")
print(f"Is key 101 registered? : {101 in user_registry}")
print(f"Value for 205: {user_registry.get(205)}")
String / Object (very common)
from dataclasses import dataclass
@dataclass
class ConfigItem:
setting: str
value: int
# Registry[Key: str, Value: ConfigItem]
config_registry: Registry[str, ConfigItem] = Registry()
# Register a single item
config_registry.register("TIMEOUT", ConfigItem("Network Timeout (s)", 30))
# Quick lookup and check
item: ConfigItem = config_registry.get("TIMEOUT")
print(f"Retrieved: {item}")
print(f"Is TIMEOUT registered? {"TIMEOUT" in config_registry}")
This is now flexible and gets you one step closer to the “LGTM” review from your senior dev.
Important: Features you should utilize
Now that you’ve learned the fundamentals, here are practical ways to customize and target your implementation of this in your project
1. Runtime Flexibility
Because our registry is just a living object, we gain runtime agility: we can add, remove, or swap handlers while the application is running.
Why and how?
Imagine you want to roll out a new feature flag. You want to switch between StripeV2 and StripeV3 as your payment handler without restarting your servers.
Tip: Do not rely on fully in-memory state as the source of truth. Changes are lost on reboot. Your code should query a database or feature flag system and use that signal to update your registry.
- You should store the flag in your database/feature flag system
| Id | FlagName | Value |
|---|---|---|
| 1 | stripe_v3 | FALSE |
| 2 | stripe_v2 | TRUE |
- Your code should query and use that signal to update your registry
# The Registry allows the application to react to the world
if config.is_feature_enabled("stripe_v3"):
# We can swap implementation details on the fly!
registry.register("payment", StripeV3)
else:
registry.register("payment", StripeV2)
Dedicated matching logic
“What if I need more than a single string to match my values?“
”What if I need to match off 5 different parameters, including the > current weather and a random number between 1 and 20?
Instead of smashing it into a factory, use a Decision Engine.
A Registry should be a dumb lookup table. It shouldn't know about 'weather' or 'user mood'. Move complex logic into a dedicated decision engine that outputs a simple key.
Bad:
import random
def get_fishing_spot_from_complex_state(time: str, weather: str, mood: int) -> str:
"""Uncle dan's fishing spot algo
"""
match (time, weather, mood):
case ('morning', 'sunny', x) if x >= 15:
return "Go to Great Heron Lake."
case ('evening', _, 5):
return "Go to Pond O' Wonders"
# 20 more
...
case _:
return "Stay home"
This acts as a hard-coded "address book." To add new entries, you have to modify this function (violating Open/Closed principle).
It also contains all the complex conditional logic to parse and interpret raw data (violates Single Responsibility principle)
Instead, split the decision maker (the matching logic) from the lookup (the registry):
from dataclasses import dataclass
from typing import Protocol, override
@dataclass
class SystemEvent:
type: str
amount: float = 0.0
currency: str = "USD"
def decide_handler_key(event: SystemEvent) -> str:
match event:
case SystemEvent(type="payment", amount=amt) if amt > 1000:
return "payment:high_value"
case SystemEvent(type="payment"):
return "payment:standard"
case SystemEvent(type="new_user"):
return "onboarding:new_user"
case _:
return "default"
class EventHandler(Protocol):
def process(self, event: SystemEvent) -> None:
...
def handle_event(event: SystemEvent, registry: Registry[str, EventHandler]):
routing_key: str = decide_handler_key(event)
try:
handler: EventHandler = registry.get(routing_key)
handler.process(event)
except KeyError:
print(f"No handler registered for '{routing_key}'")
This separates the data (the mappings) from the logic (the application). Now we can test our business logic independently of our wiring.
Wiring It Up (plugins)
To register entries, use a dedicated registration function to be explicit about when and where registration happens.
# --- src/api/setup.py ---
def setup_app():
# Explicit is better than implicit.
# We can see exactly what is being loaded.
handler_registry.register("payment", PaymentHandler)
handler_registry.register("onboarding", OnboardingHandler)
Pro tip: Upgrade your registration process
You now easily support plugins to your system.
The registration logic should be modular, and live next to the code its registering.
This also makes it so when we need to add new entries, we’re not modifying main.py (following open/close)
# --- modules/shapes/registration.py ---
from .classes import Circle, Square, BaseShape
from ..registry import Registry
def register_shapes_module(registry: Registry[str, Type[BaseShape]]):
"""
This is the "entry point" for the shapes module.
It registers all classes this module provides.
"""
print("Registering Shapes Module...")
registry.register("circle", Circle)
registry.register("square", Square)
# When we add a Triangle, you ONLY edit this file
Main.py takes loader functions from specific modules and executes them to fill in the registries on startup
# --- main.py ---
from .registry import Registry
from .modules.shapes.registration import register_shapes_module
from typing import Callable
shape_registry = Registry()
# List of our "plugins" to load
MODULES_TO_LOAD: list[tuple[Callable, Registry]] = [
(register_shapes_module, shape_registry),
# 20 more here maybe
]
def setup_application() -> None:
for register_func, registry_instance in MODULES_TO_LOAD:
register_func(registry_instance)
setup_application()
Important Nuance
“In theory there is no difference between theory and practice, while in practice there is”
Not everything needs a generics-based, class-based, decoupled Registry system.
- If you have a small, fixed list that doesn’t need to be changed at runtime, just use a dictionary.
- If you’re writing a quick script or prototype, just use a if/elif function
Make it work, make it right, then make it fast.
Finish your feature, then let its pain points lead you to optimizations. But, knowing this is what helps you make those decisions when needed.
The Final Copy-Paste Solution
class Registry[Key, Value]:
"""Generic implementation of the Registry pattern using a hash map.
"""
def __init__(self):
"""Initialize the registry with an empty internal store."""
self._store: dict[Key, Value] = {}
def register(self, key: Key, value: Value) -> None:
"""Register a key-value pair to the registry.
This method checks for key collisions to ensure uniqueness.
Args:
key (Key): The unique identifier for the stored value.
value (Value): The item (object, class, or callable) to store.
Raises:
ValueError: If the key is already registered.
"""
if key in self._store:
raise ValueError(f"Key '{key}' is already registered.")
self._store[key] = value
def get(self, key: Key) -> Value:
"""Retrieve a value by its key.
Args:
key (Key): The unique identifier to look up.
Returns:
Value: The stored value associated with the key.
Raises:
KeyError: If the key is not found in the registry.
"""
if value := self._store.get(key):
return value
else:
raise KeyError(f"Key '{key}' not found in registry.")
def unregister(self, key: Key) -> None:
"""Remove a key-value pair from the registry.
Args:
key (Key): The unique identifier of the item to remove.
Raises:
KeyError: If the key is not found in the registry.
"""
if key not in self._store:
raise KeyError(f"Key '{key}' not found in registry.")
del self._store[key]
def __contains__(self, key: Key) -> bool:
"""Checks if a key exists in the registry.
This method enables the use of the 'in' operator (e.g., `if key in registry:`).
Args:
key (Key): The unique identifier to check.
Returns:
bool: True if the key is registered, False otherwise.
"""
return key in self._store
def list_keys(self) -> list[Key]:
"""Return a list of all keys currently registered.
Returns:
list[Key]: A list containing all registered keys.
"""
return list(self._store.keys())
Usage example
@dataclass
class Fish:
name: str
# Init
FishRegistry = Registry[int, Fish]()
# Register
FishRegistry.register(1, Fish(name="Clownfish"))
FishRegistry.register(2, Fish(name="Blue Tang"))
# Lookup
try:
fish = FishRegistry.get(2)
print(f"Found: {fish.name}")
except KeyError:
print("Fish not found")


Top comments (0)