|
| 1 | +# Service Decorators |
| 2 | + |
| 3 | +The dependency injection container supports a powerful decorator pattern that allows you to wrap, intercept, or modify service instances *after* they are created but *before* they are returned to the caller or cached by their lifetime manager. This is an implementation of the [Decorator design pattern](https://en.wikipedia.org/wiki/Decorator_pattern) applied to services managed by the DI container. |
| 4 | + |
| 5 | +Decorators are registered against a specific service type and are applied in the reverse order of their registration (the last decorator registered is the outermost). |
| 6 | + |
| 7 | +## Use Cases |
| 8 | + |
| 9 | +Service decorators are useful for a variety of cross-cutting concerns, such as: |
| 10 | + |
| 11 | +* **Caching:** Wrap a service with a caching layer. |
| 12 | +* **Logging/Auditing:** Log method calls to a service or audit actions. |
| 13 | +* **Transaction Management:** Start and commit/rollback transactions around service method calls. |
| 14 | +* **Retries/Circuit Breakers:** Add resilience to service operations. |
| 15 | +* **Data Validation/Transformation:** Validate input or transform output of service methods. |
| 16 | +* **Feature Toggling:** Conditionally alter behavior by applying different decorators. |
| 17 | + |
| 18 | +## Registering Decorators |
| 19 | + |
| 20 | +You register decorators using the `decorate` method on the `ServiceCollection`: |
| 21 | + |
| 22 | +```python |
| 23 | +from wd.di import ServiceCollection, ServiceProvider |
| 24 | + |
| 25 | +# 1. Define your service interface and implementation |
| 26 | +class IDataService: |
| 27 | + def get_data(self, item_id: str) -> str: |
| 28 | + raise NotImplementedError |
| 29 | + |
| 30 | +class RealDataService(IDataService): |
| 31 | + def get_data(self, item_id: str) -> str: |
| 32 | + print(f"RealDataService: Fetching data for {item_id}") |
| 33 | + return f"Data for {item_id}" |
| 34 | + |
| 35 | +# 2. Define your decorator(s) |
| 36 | +class LoggingDecorator(IDataService): |
| 37 | + def __init__(self, inner: IDataService, provider: ServiceProvider): |
| 38 | + self._inner = inner |
| 39 | + self._provider = provider # Example: if decorator needs other services |
| 40 | + print("LoggingDecorator: Initialized") |
| 41 | + |
| 42 | + def get_data(self, item_id: str) -> str: |
| 43 | + print(f"LoggingDecorator: Before calling get_data for {item_id}") |
| 44 | + result = self._inner.get_data(item_id) |
| 45 | + print(f"LoggingDecorator: After calling get_data for {item_id}, result: {result}") |
| 46 | + return result |
| 47 | + |
| 48 | +# 3. Define a decorator factory |
| 49 | +# A decorator factory is a callable that takes two arguments: |
| 50 | +# - The current ServiceProvider instance. |
| 51 | +# - The "inner" service instance that is being decorated. |
| 52 | +# It must return the decorated instance. |
| 53 | +def logging_decorator_factory(provider: ServiceProvider, inner_service: IDataService) -> IDataService: |
| 54 | + return LoggingDecorator(inner_service, provider) |
| 55 | + |
| 56 | +# 4. Configure services |
| 57 | +services = ServiceCollection() |
| 58 | +services.add_transient(IDataService, RealDataService) |
| 59 | + |
| 60 | +# 5. Apply the decorator |
| 61 | +services.decorate(IDataService, logging_decorator_factory) |
| 62 | + |
| 63 | +# 6. Build and use |
| 64 | +provider = services.build_service_provider() |
| 65 | +data_service = provider.get_service(IDataService) |
| 66 | +data_service.get_data("item123") |
| 67 | + |
| 68 | +# Output would be: |
| 69 | +# LoggingDecorator: Initialized |
| 70 | +# LoggingDecorator: Before calling get_data for item123 |
| 71 | +# RealDataService: Fetching data for item123 |
| 72 | +# LoggingDecorator: After calling get_data for item123, result: Data for item123 |
| 73 | +``` |
| 74 | + |
| 75 | +### Decorator Factories |
| 76 | + |
| 77 | +A **decorator factory** is a callable with the signature: |
| 78 | + |
| 79 | +```python |
| 80 | +Callable[[ServiceProvider, InnerServiceType], OuterServiceType] |
| 81 | +``` |
| 82 | + |
| 83 | +* `ServiceProvider`: The current service provider, which can be used by the decorator to resolve other dependencies if needed. |
| 84 | +* `InnerServiceType`: The instance of the service being wrapped. |
| 85 | +* `OuterServiceType`: The (potentially) wrapped instance that will be returned. Often, this is the same type as `InnerServiceType`, but it can be different if the decorator changes the interface (though this should be done with care). |
| 86 | + |
| 87 | +### Order of Application |
| 88 | + |
| 89 | +If multiple decorators are registered for the same service type, they are applied sequentially. The `ServiceDescriptor` stores them in a list. When an instance is created: |
| 90 | +1. The base instance (from `implementation_type` or `factory`) is created. |
| 91 | +2. The decorators are applied in **reverse order of registration**. This means the decorator registered *last* becomes the *outermost* wrapper, and the decorator registered *first* becomes the *innermost* wrapper (closest to the original service instance). |
| 92 | + |
| 93 | +```python |
| 94 | +services.decorate(IMyService, inner_decorator_factory) # Applied first (becomes inner) |
| 95 | +services.decorate(IMyService, outer_decorator_factory) # Applied second (becomes outer) |
| 96 | + |
| 97 | +# Execution flow: outer_decorator -> inner_decorator -> actual_service |
| 98 | +``` |
| 99 | + |
| 100 | +## Advanced: Decorators with Parameters |
| 101 | + |
| 102 | +Sometimes, your decorator itself might need configuration. You can achieve this by creating a decorator factory *function* that takes parameters and *returns* the actual decorator factory callable: |
| 103 | + |
| 104 | +```python |
| 105 | +class RetryingDataServiceDecorator(IDataService): |
| 106 | + def __init__(self, inner: IDataService, max_retries: int): |
| 107 | + self._inner = inner |
| 108 | + self._max_retries = max_retries |
| 109 | + |
| 110 | + def get_data(self, item_id: str) -> str: |
| 111 | + attempts = 0 |
| 112 | + while True: |
| 113 | + try: |
| 114 | + return self._inner.get_data(item_id) |
| 115 | + except Exception as e: |
| 116 | + attempts += 1 |
| 117 | + if attempts >= self._max_retries: |
| 118 | + print(f"RetryingDecorator: Max retries ({self._max_retries}) reached. Failing.") |
| 119 | + raise |
| 120 | + print(f"RetryingDecorator: Attempt {attempts} failed. Retrying...") |
| 121 | + # In a real scenario, you might add a delay here |
| 122 | + |
| 123 | +# Factory that creates the decorator factory |
| 124 | +def create_retrying_decorator_factory(max_retries: int): |
| 125 | + def actual_decorator_factory(provider: ServiceProvider, inner: IDataService) -> IDataService: |
| 126 | + return RetryingDataServiceDecorator(inner, max_retries) |
| 127 | + return actual_decorator_factory |
| 128 | + |
| 129 | +# Usage: |
| 130 | +services.add_transient(IDataService, RealDataService) |
| 131 | +retry_factory_with_config = create_retrying_decorator_factory(max_retries=3) |
| 132 | +services.decorate(IDataService, retry_factory_with_config) |
| 133 | + |
| 134 | +provider = services.build_service_provider() |
| 135 | +service = provider.get_service(IDataService) |
| 136 | +# service.get_data("test") will now have retry logic |
| 137 | +``` |
| 138 | + |
| 139 | +## Circular Decorator Detection |
| 140 | + |
| 141 | +The container includes a mechanism to detect circular decorator applications at runtime. If a decorator, during its application to a service, causes itself to be re-applied to the *same service instance resolution*, a `CircularDecoratorError` will be raised. This helps prevent infinite loops during service construction. |
| 142 | + |
| 143 | +For example, if `DecoratorA` for `IServiceX` internally tries to resolve `IServiceX` again in a way that re-triggers the application of `DecoratorA` to the same `IServiceX` instance being formed, a cycle is detected. |
0 commit comments