Dependency Injection(DI) is a set of software design principles that enable engineers to develop loosely coupled code. This stack overflow post is the best ELI5 description of DI:
When you go and get things out of the refrigerator for yourself, you can cause problems. You might leave the door open, you might get something Mommy or Daddy doesn’t want you to have. You might even be looking for something we don’t even have or which has expired. What you should be doing is stating a need, “I need something to drink with lunch,” and then we will make sure you have something when you sit down to eat.
In this post, we’re going to see how we can leverage Google’s Pinject library to help us write loosely coupled Python code.
Throughout this post, we are going to be working with our familiar 3 layered application model. Our example is an intentionally convoluted application that employs the 3 layered application model to print “Hello World” on the user’s screen.
Consider the following as the first version of the code:
class Repository:
def __init__(self):
self.db = os.getenv('dbname')
def get_data(self):
return 'Hello World'
class Service:
def __init__(self):
self.repository = Repository()
def process(self):
return self.dal.get_data()
class UI:
def __init__(self):
self.service = Service()
def render(self):
print(self.service.process())
if __name__ == '__main__':
ui = UI()
ui.render()
The major goal of the 3 layered application: separation of concerns, we’d like to separate business logic from data access and UI. However, in the above code, it’s hard to test the 3 layers in isolation. After some deliberation we would write code like:
class Repository:
def __init__(self, db: str):
self.db = db
def get_data(self):
return 'Hello World'
class Service:
def __init__(self, repsoitory: Repository):
self.repsoitory = repsoitory
def process(self):
return self.repsoitory.get_data()
class UI:
def __init__(self, service: Service):
self.service = service
def render(self):
print(self.service.process())
if __name__ == '__main__':
ui = UI(Service(DAL(db=os.getenv('db'))))
ui.render()
Although this approach is more flexible/composable, it comes with a price. Now building our objects becomes complicated:
ui = UI(Service(Repository(db=os.getenv('db'))))
Dependency Injection can be used to aid with the assembly of these objects:
import pinject
import os
class Repository:
def __init__(self, db: str):
self.db = db
def get_data(self):
return 'Hello World'
class RepositoryBindingSpec(pinject.BindingSpec):
def provide_repository(self):
return Repository(db=os.getenv('dbname'))
class Service:
def __init__(self, repository: Repository):
self.repository = repository
def process(self):
return self.repository.get_data()
class UI:
def __init__(self, service: Service):
self.service = service
def render(self):
print(self.service.process())
if __name__ == '__main__':
object_graph = pinject.new_object_graph(binding_specs=[RepositoryBindingSpec()])
ui = object_graph.provide(UI)
ui.render()
Implicit Class Bindings:
In the example, you’d notice we never really specify how the service will be provided to the UI layer. This is because Pinject has reasonable defaults and implicitly binds classes based on PEP-8 naming conventions. So an arg named foo will automatically be bounded to the class Foo and so on.
Creating a Binding Spec:
Bindings can also be explicitly specified using pinject’s BindingSpec, where you can create methods like provideclassname and it’ll use that information to provide the corresponding class. For Example here, providerepository is used as a provider for the Repository class.
class RepositoryBindingSpec(pinject.BindingSpec):
def provide_repository(self):
return Repository(db=os.getenv('dbname'))
Binding specs in pinject are usually stored in a file called binding_spec.py, in our example, we’ve added everything in the same file for brevity.
Overall the code becomes more flexible, testable, and clear.