Build smarter, cleaner, declarative, and powerful Python APIs with class decorators.
When you use libraries like SQLAlchemy, Pydantic, or FastAPI, you might notice that you can declare behavior just by writing classes with certain decorators. This style is called declarative programming. You describe what you want, and the system figures out how to do it. One of the ways this magic happens in Python is through class decorators.
In this blog post, we’ll walk through what class decorators are, how they can be used to build declarative APIs, and build a simple example from scratch to make the idea clear.
Let’s start with a quick refresher. A class decorator is a function that takes a class object as input and returns a possibly modified or wrapped class.
def my_decorator(cls):
print(f"Decorating class: {cls.__name__}")
return cls
@my_decorator
class MyClass:
pass
When Python sees @my_decorator
, it runs my_decorator(MyClass)
. This is very similar to function decorators, but the input is a class.
A declarative API lets the developer express intent with very little code, often without explicitly invoking logic. For example:
@route("/hello")
class HelloWorld:
def get(self):
return "Hello, world!"
Here, @route("/hello")
might register this class as a handler for a web request. The user doesn't need to know how routing works, just declare the intent.
Let’s build a simple example where we can declare configuration for services using class decorators. Imagine we want to register all services that provide some functionality and access their metadata later.
We need a place to keep track of services:
service_registry = {}
Now, let’s make a decorator that registers any class with a name and optional config:
def service(name=None, **config):
def decorator(cls):
service_name = name or cls.__name__
service_registry[service_name] = {
"class": cls,
"config": config
}
return cls
return decorator
@service(name="email", retries=3, timeout=5)
class EmailService:
def send(self, message):
print(f"Sending email: {message}")
@service(name="sms", provider="Twilio")
class SMSService:
def send(self, message):
print(f"Sending SMS: {message}")
Now, we’ve created a declarative way to define services. All metadata is stored automatically:
from pprint import pprint
pprint(service_registry)
Output:
{
'email': {
'class': <class '__main__.EmailService'>,
'config': {'retries': 3, 'timeout': 5}
},
'sms': {
'class': <class '__main__.SMSService'>,
'config': {'provider': 'Twilio'}
}
}
They don’t need to know anything about how the registry works, it’s declarative.
You can do much more with class decorators:
__init__
For example, to inject config into a class:
def service(name=None, **config):
def decorator(cls):
class Wrapped(cls):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.config = config
service_name = name or cls.__name__
service_registry[service_name] = {
"class": Wrapped,
"config": config
}
return Wrapped
return decorator
Now you can do:
email = EmailService()
print(email.config) # {'retries': 3, 'timeout': 5}
Many popular Python frameworks use this pattern:
@app.get()
to register route handlers declaratively.@dataclass
or model classes to declare schemas and validation rules.Once you understand class decorators, you start to see the pattern behind these intuitive APIs.
Class decorators are great for simple behavior injection and registration. They:
Metaclasses are more powerful, but:
Use class decorators when you want to keep things readable, and reach for metaclasses when you need low-level control over class creation itself.
You can also add a helper function to instantiate registered services:
def get_service(name):
cls = service_registry[name]['class']
return cls()
email_service = get_service("email")
email_service.send("Test message")
This makes the system not only declarative but also easy to use dynamically.
Here’s a simple unit test for the registry logic:
def test_service_registry():
assert "email" in service_registry
service_info = service_registry["email"]
assert service_info["config"]["retries"] == 3
Or test the injected config:
def test_email_service_config():
service = EmailService()
assert service.config["timeout"] == 5
Tests help ensure your decorators behave as expected when used in larger systems.
super().__init__()
: When wrapping classes, don't forget to call the original constructor.Class decorators are a powerful tool in Python, especially when designing APIs that feel intuitive and expressive. They let you shift from writing code that says do this to code that says this is what I want.
If you’re building tools, frameworks, or even internal SDKs, consider how class decorators might help you build clean, declarative APIs that your future self and your teammates will thank you for.