Decorators are a pattern that support enhancing functionality without having to mutate any current implementations. Decorators are a powerful tool which can help mitigate risk.Because of this they are especially compelling for SRE/DevOps who may be working across many teams and projects but may not be super familiar with every code base. This post outlines what the decorator pattern is, and why it is such a powerful pattern for SRE. It does not provide an exhaustive overview of the technicals of implementing a decorator pattern in any language.

What

Decorators are a software pattern that supports enhancing the functionality of another component. Decorators allow functionality to be enhanced both external to the original implementation and dynamically. This has the really cool property of adding functionality without changing an interface OR an implementation; all while keeping a caller unaware a change has occurred:

The diagram above shows that the actor calls the function in the same way but the through the decorator. Decorators happen at the software level usually on one of two levels: Function and Component/Object.

Function

Function decorators enhance a function by wrapping it. In order to do this they take a function as an argument. Suppose that there is a country wide sales tax function:

def national_sales_tax(cost):

"""

Calculates the required sales tax of a cost.

""" return price * 0.06

It’s called during checkout:

In [1]: national_sales_tax(1)

Out[1]: 0.06 In [2]: national_sales_tax(10)

Out[2]: 0.6

Next a jurisdiction with the nation is introducing its own tax on top of the national sales tax. It is a 1% tax applied to the raw amount. One way to model this is by introducing the concept of jurisdiction into the `national_sales_tax` implementation. This could quickly get out of hand if the number of jurisdictions grow. Another option is to use a decorator to add this functionality externally to the national_sales_tax function:

def jurisdiction_sales_tax(national_sales_tax_fn):

def wrapper(cost):

jurisdiction_tax = cost * 0.01

national_tax = national_sales_tax_fn(cost)

return jurisdiction_tax + national_tax return wrapper

Since the jurisdiction_sales_tax enhances a function it needs to take a function as an argument. Then since the client is expecting to execute a function to calculate a tax for a number cost it returns a function:

In [1]: sales_tax = jurisdiction_sales_tax(national_sales_tax) In [2]: sales_tax(1)

Out[2]: 0.07 In [3]: sales_tax(10)

Out[3]: 0.7

There are tons of awesome articles on the technical specifics of decorators in pretty much every language. The important thing to notice here is that the functionality was externally added onto the original method.

Component/Object

Another common type of decorator is one that wraps an object. This is done through traditional composition and requires that the decorator takes an object/component to wrap and re-exposes an interface to it:

class ComponentObject(): def method1(self):

return 'do_something' def method2(self):

return 'do_something_again' class Decorator():

def __init__(self, wrapped):

self.wrapped = wrapped def method1(self):

return 'wrapped1 ' + self.wrapped.method1() def method2(self):

return 'wrapped2 ' + self.wrapped.method2()

Then in order to use the decorator:

In [1]: decorated_obj = Decorator(

...: ComponentObject()

...: ) In [2]: decorated_obj.method1()

Out[2]: 'wrapped1 do_something' In [3]: decorated_obj.method2()

Out[3]: 'wrapped2 do_something_again'

Why

Low risk

Decorators have a number of properties that make them lower risk than alternatives. Since decorators accept a function or object, they contain a natural and strong partition (function call) which expands functionality instead of mutating. This can significantly reduce the risk of introducing functionality compared to changing an implementation. The chart below compares mutating a function to a decorator:

The first item shows a function with a number of clients. The chart in the middle shows a worst case scenario of introducing a regression. The pane on the far right illustrates introducing a decorator. A decorator can introduce functionality without affecting current clients.

This is commonly referred to Expand and Contract and helps to mitigate risk when introducing changes. Decorators implement this pattern for free.

Separation of Concerns

Encapsulation could allow for sharing functionality but decorators support even stricter separation of concerns. On the left side of the example below there is a function which supports retry functionality:

The right side shows the decorator version. The decorator completely removes the concept of retries from the function. On the left side, even if retry were encapsulated in a function it would still be called along side the business logic resulting in the function mixing concerns ie the concept of business logic AND retrying logic. Implementing retry as a decorator makes it completely external to the business logic and simplifies the business logic, enforcing a strict separation of concerns. The business function has become smaller, more focused, and easier to test and verify!

Reusable/Generic

There are many patterns that are common to SRE that could be applied across services and projects, things like retries, logging, metrics, timeouts, etc. In fact this is exactly how the most popular resilience libraries (resilience4j, polly) are implemented. They provide generic primitives that can apply to any operation that implements their required interface. This allows for SRE team to create primitives that can be applied across all of a language’s products, regardless of domain. Following the example above, once retry is external it can be generically applied to all (or certain classes) of functions:

Modeling retry as a decorator allows it to be applied to all sorts of functions AND for the functions to be completely agnostic of their retry policies.

Expand and Contract

Expand and Contract is a common pattern for implementing a breaking change by modeling a change as a two stage operation:

Expand - Breaking change exists side by side with current version. During this time clients are updated to use the new/breaking version.

- Breaking change exists side by side with current version. During this time clients are updated to use the new/breaking version. Contract- The original change is no longer available, leaving only the new (formerly breaking change)

Decorators provide a good tool to implement expand at the function level. Expansion may be in the form of a breaking interface change. I recently had to propagate a Request scoped context object throughout a dynamic language without thread local state. This required having to update each function signature to accept this context object as the first argument.

Decorators provided a way to offer this functionality to clients, without having to change any implementations. Instead of atomically swapping all clients at the same time, the new interface is provided and then clients are free to update.

Composable

Decorators can be combined to allow for complicated functionality from very focused pieces. Yegor Bugayenko author of Elegant Objects writes about this in detail.

Imagine a function that implements a retry policy but also has a timeout per each individual each and a global timeout across all actions. Imagine what a function body for this looks like. Using decorators an api may look like:

fn = timeout(

1 * SECOND,

retry(

timeout(

operation,

100 * MS

)

)

)

Decorators have enabled combining small discrete reusable units of functionality to create complex workflows. Yegor refers to the structure above as “Vertical” decoration. Operations occur in the direction they occur from the outside in which translates to a 1 second global timeout across all retry attempts, where each operation has a timeout of 100 milliseconds. Imagine what a function body that implements all of these would look like!

When

Within Site Reliability Engineering there are a number of places where decorators are a strong option: