How the Registry Pattern saved my codebase and how to implement it in both Python and C#.
I was tired of scrolling through endless if-else chains every time I added a new feature.
One small change turned my messy logic into a clean plugin system.
Here’s how the Registry Pattern helped me refactor it in both Python and .NET.
📚 Table of Contents
- The Problem: The Open-Closed Violation
- The Solution: The Python Registry
- Applying This to .NET / C#
- Refactoring in Action (.NET)
- Why This Matters: From Script to System
- Pros, Cons, and When Not to Use It
- Conclusion
I have a confession to make. I used to love if statements.
They are easy. They are logical. When you are learning to code, if is the hammer, and every problem looks like a nail. But recently, I looked at a project of mine and realized I had built a monster.
I was working on a feature to export data, and my code looked like a staircase to nowhere. if format == 'pdf', do this. elif format == 'csv', do that. elif, elif, elif...
It worked, but it was ugly. Every time I wanted to add a new feature, I had to scroll through hundreds of lines of code, risking breakage with every keystroke. I knew there had to be a better way.
That’s when I stumbled upon the Registry Pattern. It didn’t just clean up my code; it changed how I think about software architecture.
What You Will Learn in This Article
-
The Trap: Why infinite
if-elsechains kill maintainability. - The Fix: Implementing the Registry Pattern in Python using Decorators.
- The Comparison: How to achieve the exact same architecture in .NET (C#) using Keyed Services and Reflection.
- The Big Picture: How to move from writing “Scripts” to building “Systems.”
The Problem: The “Open-Closed” Violation
Let’s look at the bad code first. This is a classic example of what not to do, even though we have all done it.
# 🚫 The Old Way: The "Spaghetti" Approach
def export_data(data: dict, format: str):
if format == "pdf":
export_pdf(data)
elif format == "csv":
export_csv(data)
elif format == "json":
export_json(data)
else:
raise ValueError(f"Unknown format: {format}")
Why is this bad?
This violates a core software rule called the Open-Closed Principle (OCP). The principle states that your code should be open for extension (you can add new stuff) but closed for modification (you shouldn’t have to touch working code).
In the example above, if I want to add an XML exporter, I have to open this core function, find the right spot in the if chain, and surgically insert a new condition. In a small script, that's fine. In a massive application? That is a recipe for bugs.
🔑 The Real Change Is Not Syntax; It’s Thinking
Before using the registry pattern, my code was asking questions. “Is this PDF?” “Is this CSV?” “Is this JSON?”
Every new feature meant adding one more question.
After using the registry pattern, my code stopped asking questions. Instead, it just says: “Give me the exporter for this format.”
That’s it. This small change matters because:
- Code that asks many questions grows fast and breaks easily.
- Code that looks things up stays stable, even when features grow.
This is the moment where code stops behaving like a script and starts behaving like a system.
The Solution: The Python Registry
The solution is surprisingly simple: Stop asking “If”. Start using a Map.
Instead of checking logic step-by-step, we create a central Registry. a simple dictionary. The keys are the format names (like “pdf” or “csv”), and the values are the actual functions that do the work.
Step 1: The Registry Without Decorators (The Simple Version)
Before we get fancy with decorators, let’s see the simplest version of the idea. We just use a dictionary to map names to functions.
# The Registry: A simple dictionary
exporters = {
"pdf": export_pdf,
"csv": export_csv,
}
def export_data(data: dict, format: str):
# No more if-statements! Just look it up.
exporter = exporters.get(format)
if not exporter:
raise ValueError(f"Unknown format: {format}")
exporter(data)
This already solves the big problem:
- No
if-elif-elsechains. - No touching this function when adding new exporters.
Step 2: The “Pro” Way (Using Decorators)
But we can go one step cooler. Since Python is a dynamic language, we can use Decorators. I created a @register decorator. Now, whenever I write a new function, I just slap a sticker on it saying "I am a PDF exporter!" and the system automatically adds it to the list.
Here is the clean version:
from typing import Callable
# 1. The Registry
# This dictionary will hold our commands.
exporters: dict[str, Callable] = {}
# 2. The Decorator
# Think of this as a sticker gun. We use it to label our functions.
def register_exporter(format: str):
def decorator(func: Callable):
exporters[format] = func
return func
return decorator
# 3. The Implementation
# Now we just "tag" our functions!
@register_exporter("pdf")
def export_pdf(data):
print(f"Exporting to PDF: {data}")
@register_exporter("csv")
def export_csv(data):
print(f"Exporting to CSV: {data}")
# 4. The Clean Usage
def export_data(data: dict, format: str):
exporter = exporters.get(format)
if not exporter:
raise ValueError(f"Unknown format: {format}")
exporter(data)
⚠️ A Note on Imports: For the decorator to work, Python needs to actually read the file containing the function. If you split your exporters into different files (e.g.,
pdf_exporter.py) but never import that file in your main script, the decorator code never runs, and the registry will stay empty!
Small Note: This Pattern Has a Name
In practice, this approach is often a mix of Strategy, Registry, and Factory patterns. The exact name is less important than the idea.
- One interface.
- Many implementations.
- Selected at runtime using a key.
Names are optional. Clean thinking is not.
“But wait… I’m a .NET Developer!”
The Universal Truth: Applying This to .NET / C
As I was implementing this, I started wondering: I also work with C# and .NET. Does this concept exist there?
This is where things get interesting. The Concept is universal, but the Implementation depends on the language’s philosophy.
- Python is Dynamic: We use a dictionary and decorators because Python lets us modify code structures on the fly.
- C# is Static: We can’t just “monkey-patch” a dictionary as easily. Instead, .NET gives us two powerful tools to solve this: Dependency Injection (DI) and Attributes.
First, we need a shared interface so our system knows what an “Exporter” looks like:
// First, we define the contract.
public interface IExporter
{
void Export(object data);
}
Option A: The “Modern” Way (.NET 8 Keyed Services)
In modern .NET, the Dependency Injection container is your registry. You don’t need to build a dictionary manually; Microsoft built one for you.
With .NET 8, you can use Keyed Services , which works almost exactly like our Python dictionary:
// Program.cs
// "Registering" the exporters with a key (string)
builder.Services.AddKeyedSingleton<IExporter, PdfExporter>("pdf");
builder.Services.AddKeyedSingleton<IExporter, CsvExporter>("csv");
Option B: The “Magic” Way (Reflection + Attributes)
If you want that true “plugin” feel — where you just create a new class file and it magically starts working without registering it anywhere — we use Reflection.
Step 1: Create a Custom Attribute This is like the sticker we put on our classes.
[AttributeUsage(AttributeTargets.Class)]
public class ExporterAttribute : Attribute
{
public string Format { get; }
public ExporterAttribute(string format) => Format = format;
}
Step 2: Tag Your Classes Now, we just create classes and tag them. We don’t touch any other code.
[Exporter("pdf")] // 👈 This tag handles the registration!
public class PdfExporter : IExporter
{
public void Export(object data) => Console.WriteLine("Exporting to PDF...");
}
Step 3: The “Auto-Discovery” Engine This is the setup code that replaces that massive switch statement. It scans your entire application for any class wearing the [Exporter] sticker.
public class ExporterFactory
{
private readonly Dictionary<string, Type> _registry;
public ExporterFactory()
{
_registry = new Dictionary<string, Type>();
// The Magic: Scan the assembly for our attribute
var exporterTypes = Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.GetCustomAttribute<ExporterAttribute>() != null);
foreach (var type in exporterTypes)
{
var attr = type.GetCustomAttribute<ExporterAttribute>();
_registry.Add(attr.Format, type);
}
}
public IExporter Create(string format)
{
if (!_registry.ContainsKey(format))
throw new Exception($"No exporter found for {format}");
// Create an instance of the class dynamically
return (IExporter)Activator.CreateInstance(_registry[format]);
}
}
Finally, we write a simple “scanner” that looks for these tags when the app starts. It finds every class wearing the [Exporter] sticker and adds it to our list automatically.
⚠️ A Quick Warning About Reflection
Reflection feels powerful and it is but it comes with trade-offs.
- Harder to debug: If something breaks, the stack trace can be scary.
- Slower startup: Scanning all your files takes time.
- “Magic” feeling: New team members might not understand how PdfExporter is being called since it's never explicitly referenced.
My advice: Use Reflection if you are building a real plugin system where users drop DLLs into a folder. For most normal business apps, Keyed Services (Option A) is simpler and safer.
🛠Refactoring in Action: The .NET Transformation
Let’s bring it all together. Just like our Python example, we want to see the “Before” and “After” to really appreciate the cleanup.
🚫 The “Before” (The Switch Statement of Doom)
Here is the C# code we are trying to kill. This logic usually lives in a “Service” class, and it grows every time you add a feature.
public class ExportService
{
public void ExportData(string format, object data)
{
// Every time you add a format, you must touch this file.
// This is a Merge Conflict waiting to happen.
switch (format.ToLower())
{
case "pdf": new PdfExporter().Export(data); break;
case "csv": new CsvExporter().Export(data); break;
case "xml": new XmlExporter().Export(data); break;
default: throw new ArgumentException("Unknown format");
}
}
}
✅ The “After” (Clean & Decoupled)
Whether you chose Option A (Keyed Services) or Option B (Reflection), your core service code now looks like this. Notice how the switch statement is completely gone.
public class ExportService
{
private readonly IServiceProvider _services;
// We inject the "Registry" (the ServiceProvider)
public ExportService(IServiceProvider services)
{
_services = services;
}
public void ExportData(string format, object data)
{
// 1. Ask the system: "Do you have an exporter for 'pdf'?"
var exporter = _services.GetKeyedService<IExporter>(format);
if (exporter == null)
throw new ArgumentException($"No exporter found for {format}");
// 2. Use it. We don't care if it's PDF, CSV, or XML.
exporter.Export(data);
}
}
The Takeaway:
-
Zero Modification: To add a new
JsonExporter, you simply create the class. You never openExportService.csagain. -
Testable: You can easily mock
IExporterto test the service without actually generating PDF files.
Why This Matters: From “Script” to “System”
This pattern is the difference between writing a script and building a System.
Imagine you are building a Command Line Tool (CLI) or a Payment Gateway.
-
The Script Way: You write a massive
ifstatement for every payment provider (Stripe, PayPal, Adyen). - The System Way: You define an interface. You let other developers (or your future self) add new providers by simply dropping a new file into the folder.
With the Registry Pattern, you aren’t just cleaning up code; you are building an architecture that can grow without collapsing.
The Reality Check (Pros & Cons)
Of course, no pattern is perfect.
The Pros ✅
- Scalable: You can have 5 exporters or 500; the code complexity stays the same.
-
Git Friendly: No more merge conflicts because two developers tried to edit the same big
if/elseblock at the same time.
The Cons ⚠️
- “Magic” Behavior: Because the registration happens automatically, it can sometimes be hard for a new developer to find where a command is defined.
- Import Order (Python specific): If you don’t import the file containing the decorator, the decorator never runs, and the command won’t register.
When You Should Not Use the Registry Pattern
Be honest with yourself. If you have:
- Only 2 or 3 conditions
- Logic that will never grow
- A small script or one-off tool
Then if-else is perfectly fine. Patterns are tools, not rules.
Use the registry pattern when:
- You expect growth.
- Multiple developers work on the code.
- You want to add features without touching old logic.
Conclusion
If you find yourself writing an if-elif-else chain that is longer than your screen, stop. Take a breath. And try building a Registry.
It might feel like “over-engineering” at first, but your future self will thank you when they can add a feature in 5 minutes instead of 5 hours.
Have you ever refactored a big if-else chain like this?
Or do you still prefer explicit conditionals?
Let me know in the comments!
If You Wish To Support Me As A Creator
Leave a comment telling me your thoughts
Thank you! These tiny actions go a long way, and I really appreciate it!
LinkedIn: https://www.linkedin.com/in/Shadhujan/
Github: https://github.com/Shadhujan
Portfolio: https://shadhujan-portfolio.vercel.app/
Insta: https://www.instagram.com/jeya.shad38/

Top comments (0)