Previously we discussed the API that our factory abstraction exposes, now we want to talk about how we provide that API.

Our API for component interaction is pretty simple and can be summarized as:

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)

The above snippet allows us to take two components: MyClient and MyComponent and allows MyComponent to depend on MyClient. Assuming MyComponent and MyClient have barebones definitions like these:

class MyClient:
    def __init__(self, url):
        self.url = url

    def work(self):
        print("Work!")

class MyComponent:
    def __init__(self, my_client):
        self.my_client = my_client

    def do_meaningful_work(self, **parameters):
        self.my_client.work()

Then our graph abstraction let’s us bring this altogether in a simple usage like this:

graph = Graph()
graph.my_component.do_meaningful_work()

Under the hood binding does the work to add the components my_client and my_component to a shared registry which the graph uses to provide access to instances of these components.

Let’s take a look at a possible definition for binding:

registry = {}


def binding(component_name):
    def wrapper(factory_func):
        registry[component_name] = factory_func
        return factory_func
    return wrapper

binding itself is extremely simple. It is a decorator which assigns the function it decorates and the component name passed as its argument to some shared registry.

To understand what the registry does, substituting the actual microcsom registry for a dictionary will illustrate how simple the demands on the registry are. In the microcosm registry, there are some additional features to make it easier to import factories from other projects, but the way it works and how it is used is basically the same. It is still a single, shared global object that collects all of the definitions for making our components along with some name to associate with them.

Next let’s take a look at how our graph might work:

class Graph:
    def __init__(self):
        for component_name, component_factory in registry.items():
            setattr(self, component_name, component_factory(self))

    @property
    def config(self):
        return dict(
            my_client=dict(
                url="http://my-url",
            ),
        )

The graph is a little more complicated than binding but still not bad. All the magic comes on these two lines:

for component_name, component_factory in registry.items():
    setattr(self, component_name, component_factory(self))

So long as we have a registry of components that exposes some API which allows us to iterate over the registered components, the graph can use that to take all of our known components and make them available to anything that has access to the graph.

On top of the registry, the graph allows us to provide features around accessing the components which make them easier to use in an application.

Our definition is pretty barebones, but it does give callers of the graph the ability to reference components as properties of the graph, e.g.:

graph.my_component.do_meaningful_work()

This separates the logic of finding where my_component is defined and how to instantiate it from the rest of application which is the goal of the microcosm graph object.

The full microcosm graph has a few more features which are omitted for the sake of simplicity. These include:

  • Using lazy loading by overloading __getattr__ to only assign components to the graph when they are used.
  • An additional property for containing metadata regarding the context in which the graph is used. One place where this is extremely useful is in tests.
  • A more nuanced config loading mechanism. This is orthogonal to the workings of the graph itself and exists in a completely separate package with additional loaders coming from separate projects. For example our secrets loader.

We didn’t start with all of these features abstracted out, but we learned as we went until microcosm got where it is today. The graph we defined above captures the bare minimum the graph needs to do to be useful.

Now that we have a definition for a graph we can try it out and see how easy it is to use:

graph = Graph()
graph.my_component.do_meaningful_work()

Work!

And that’s it! Going through the steps above we have built up a simple graph which let’s us register and resolve components so those components can depend on each other in a seamless way.

Conclusions

The graph abstraction in microcosm lays the foundation for how a core part of our framework works. We put a lot of thought into how we wanted it to work, and have made many enhancements over the past 3 years that we have been using it in production.

Along the way we’ve learned various lessons including how best to work with config, how to make our graph able to describe itself to make our applications more transparent to developers, and much more. We have open sourced the project with hopes that other developers can share in our learnings and add their own. I hope that this post illuminates some of those workings and encourages people to adopt and contribute.

Dino is a senior engineer at Globality who has built the platform from its early stages.