The factory pattern is one of the foundational “Gang Of Four” design patterns. It’s so fundamental that you probably use it, perhaps without doing so intentionally. At its core, the factory pattern abstracts away the act of creating an object from an object’s constructor. This benefit of this separation is often emphasized in object-oriented terms, especially with respect to a factory’s ability to create subclasses of an object without the caller’s involvement.
At Globality, we’ve found that the benefit of the factory pattern has much more to do with formalizing the right way to create an object in terms of microservice configuration and dependencies. In a system with tens – or even hundreds – of microservices, a great deal of developer productivity comes from the consistency and simplicity enabled by the factory pattern, especially for developers working in microservices they’ve never seen before.
The Factory Pattern, In Python
Our preferred way to implement the factory pattern in Python uses
class MyClient: def __init__(self, url): self.url = url def load(self): """ This function does something interesting. """ return open(self.url) @classmethod def from_dict(cls, dct): """ Our factory constructs the class from a dictionary. """ return cls(url=dct["url"])
This style works great and has its place. It’s plainly better than putting this business logic into the constructor:
def __init__(self, dct): self.url = dct["url"]
because the constructor is a singleton and baking in the factory logic precludes any sort of alternative usage, e.g. you cannot also do:
my_client = MyClient("http://example.com")
Once you scale from one code base to many – as with microservices – it’s desirable to reuse components and their factories. For example, in our product, we wish to have consistent initialization of:
- Python logging, including logging levels and connections to a log aggregation system
- SQLAlchemy engines, including connection URLs and security configurations
- OpenAPI clients, including service URLs and authentication credentials
Working backwards, microservice code that uses these components wants as little to do with this initialization as possible, leading to something along the lines of Inversion of Control:
class MyComponent: def __init__(self, my_client): self.my_client = my_client def do_something(self): """ This function does something interesting using an instance of `MyClient` """ print(self.my_client.load()) @classmethod def create_my_component(cls, framework): """ Our factory constructs the class from an inversion of control framework. """ return cls(my_client=framework.my_client)
Of course, this design requires that the factory for
my_client be registered a priori with the
@framework.register def create_my_client(url): return MyClient(url=url)
And by symmetry, the factory for
MyComponent ought to work exactly the same way since other parts
of the service are just as likely to want to reference it within their own factories:
@framework.register def create_my_component(framework): return MyComponent(my_client=framework.my_client)
Allowing further references via:
The final challenge is to put these factories on the same footing by having consistent signatures. In this case, the function argument needs to provide access to several things:
- External configuration (e.g.
- Other components (e.g.
- Service metadata (e.g. the name of the service)
We can model these needs as:
@framework.register def create_my_client(config, framework, metadata): return MyClient(url=config.my_client.url) @framework.register def create_my_component(config, framework, metadata): return MyComponent(my_client=framework.my_client)
Alternatively, to reduce argument verbosity:
@framework.register def create_my_client(framework): return MyClient(url=framework.config.my_client.url) @framework.register def create_my_component(framework): return MyComponent(my_client=framework.my_client)
The Microcosm Framework
The preceding examples motivate the open-source microcosm library, which provides a framework along the lines of what’s described above, including:
- Loading configuration data from various sources.
- Registering factories via decorators and entry points.
- Lazy-initialization and caching of factory-generated components.
microcosm, the resulting code looks much the same:
from microcosm.api import binding @binding("my_client") def create_my_client(graph): return MyClient(url=graph.config.my_client.url) @binding("my_component") def create_my_component(graph): return MyComponent(my_client=graph.my_client)
microcosm across more than fifty Python services. Along the way, we’ve hooked up
configuration loading both to environment variables passed into our containers and to AWS Secrets
Manager for sensitive values. We’ve written core integrations as reusable, mostly open-source,
libraries with factory functions declared as entry points. By taking advantage of decoupling
via inversion of control and by factoring out reusable code into upgradeable libraries, we’ve seen
better code quality and improved maintainability.
Interested? Take a look.