Decorators
Clean IoC allows you to add extra functionality to dependencies via decoration. It achieves this with what would look traditionally like an Object Orientated decorator pattern. This is very different to traditional python decorator functions.
Why use a different decorator pattern?. This looks like Java
Python class decorators wither mutate or wrap the decorated class in order to add new dependencies to the decorators it would have to dynamically change the the classes init function, but since we can define a separate Service Type from the Implementation Type changing the __init__
method just becomes messy.
In Clean IoC we decorate the instance rather than the class itself, this has the following advantages.
- It allows the decorators to have their own dependencies that are registered within the container
- It allows you to focus on decorating the abstract type rather then the implementation type
- When we just register an instances, it can also be decorated.
- Allows us to selectively apply decorators
Are you suggesting that python should change how it traditionally does decoration for cross cutting concerns?
Nope. When adding cross cutting concerns to classes and functions in python, traditional python decorators can work fantastically. It's also not a case of one or the other. For example if you want to register dataclasses
and also add decorators instance of those classes you can absolutely do that.
class MessageSender(Protocol):
def send(self, message: str):
...
class EmailMessageSender(MessageSender):
def send(self, message: str):
## DO EMAIL STUFF
...
class LoggingMessageSender(MessageSender):
def __init__(self, sender: MessageSender):
self.sender = sender
def send(self, message: str):
logger.info("SENDING MESSAGE")
self.sender.send(message)
logger.info("MESSAGE SENT")
container = Container()
container.register(MessageSender, EmailMessageSender)
container.register_decorator(MessageSender, LoggingMessageSender)
sender = container.resolve(MessageSender)
sender.send("HELLO") # logs while sending
Decorator ordering
By default decorators are resolved in order of when first registered. So the first registered decorator is the highest the object tree.
class Abstract:
pass
class Concrete:
pass
class DecoratorOne(Abstract):
def __init__(self, child: Abstract):
self.child = child
class DecoratorTwo(Abstract):
def __init__(self, child: Abstract):
self.child = child
container = Container()
container.register(Abstract, Concrete)
container.register_decorator(Abstract, DecoratorOne)
container.register_decorator(Abstract, DecoratorTwo)
root = container.resolve(Abstract)
type(root) # returns DecoratorOne
type(root.child) # returns DecoratorTwo
type(root.child.child) # returns Concrete
However if you want more control over his you can also set the position
arg when registering the decorator.
The position
arg is an integer.
Info
The higher the position
number the higher the position in the tree.
By default position
is 0.
class Abstract:
pass
class Concrete:
pass
class DecoratorHigh(Abstract):
def __init__(self, child: Abstract):
self.child = child
class DecoratorMid(Abstract):
def __init__(self, child: Abstract):
self.child = child
class DecoratorLow(Abstract):
def __init__(self, child: Abstract):
self.child = child
container = Container()
container.register(Abstract, Concrete)
container.register_decorator(Abstract, DecoratorMid)
container.register_decorator(Abstract, DecoratorLow, position=-10)
container.register_decorator(Abstract, DecoratorHigh, position=10)
root = container.resolve(Abstract)
type(root) # returns DecoratorHigh
type(root.child) # returns DecoratorMid
type(root.child.child) # returns DecoratorLow
type(root.child.child.child) # returns Concrete
Manually setting decorated arg
Clean IoC will try to work out the decorated arg on the decorator based on the arg types. If for any reason that doesn't work you can fallback to setting it explicitly
Taking our previous message sending example.
class MessageSender(Protocol):
def send(self, message: str):
...
class EmailMessageSender(MessageSender):
def send(self, message: str):
## DO EMAIL STUFF
...
class LoggingMessageSender(MessageSender):
def __init__(self, sender: MessageSender):
self.sender = sender
def send(self, message: str):
logger.info("SENDING MESSAGE")
self.sender.send(message)
logger.info("MESSAGE SENT")
container = Container()
container.register(MessageSender, EmailMessageSender)
container.register_decorator(MessageSender, LoggingMessageSender, decorated_arg="sender")
sender = container.resolve(MessageSender)
sender.send("HELLO") # logs while sending
Using functions and generators as decorators
You can also use functions and generators (sync and async) as decorators. These can be useful if you want to wrap the use of the object in a context manager for any reason.
class MessageSender(Protocol):
def send(self, message: str):
...
class EmailMessageSender(MessageSender):
def send(self, message: str):
## DO EMAIL STUFF
...
def use_sender_within_some_context_manager(sender: MessageSender):
with my_context_manager():
yield sender
container = Container()
container.register(MessageSender, EmailMessageSender)
container.register_decorator(MessageSender, use_sender_within_some_context_manager, decorated_arg="sender")
sender = container.resolve(MessageSender) # sender is now within a context manager