Software has to be flexible in order to respond to change. Dependency injection is a technique for managing the dependencies between software components. It is a design pattern that uses inversion of control to provide the ability to swap out components for testing or other purposes without changing any other part of the system.
The main idea behind dependency injection is to pass the responsibility for providing services from constructor functions or direct instantiation methods onto other objects. In other words, an object is given its dependencies instead of creating them. This way, if we want to add new features, we don’t have to change the existing code but instead, pass new dependencies into it.
There are three steps in writing loosely coupled Python code with dependency injection. The first step is to identify what dependencies the code needs, the second step is to create interfaces for each dependency, and the third step is to pass them into the dependent object through its constructor or method parameters.
Recently, I implemented functionality that required a byte encoder and decoder to manipulate business data (I’ve simplified its implementation for readability purposes). I abstracted out their responsibilities to interfaces. Python doesn’t have an interface keyword like Golang, but you can replicate interface functionality with the ABC library.
Focusing on the IPacketEncoder interface, a concrete encoder class must implement it. The initial business requirements required all types to be encoded and strings to be padded with their length. Luckily, Python provides the struct library to do most of the hard work.
The encode_name function is a simple implementation for creating an encoded payload with the specified name. It takes an IPacketEncoder object and a string as parameters.
The two key implementation details are using the IPacketEncoder interface instead of the PaddedPacketEncoder concrete class and encoder is an argument rather than being initialized inside the function. This is dependency injection. Because IPacketEncoder is passed in, it can be easily extended, changed or provided with a stub, thus keeping the system loosely coupled.
A few weeks later, there was a new requirement for an encoder to handle null terminated encodings. Luckily, due to the existing implementation, it can be easily implemented.
NullTerminatedPacketEncoder essentially adds NULL_BYTE after each encoded string. Going back to the encode_name function, an instance of NullTerminatedPacketEncoder is a valid encoder argument since NullTerminatedPacketEncoder implements the IPacketEncoder interface similarly to PaddedPacketEncoder.
If dependency injection wasn’t used here, it could result in code that is tightly coupled and difficult to change, for example:
If a new encoder is required, the encode_name function will have to be changed almost entirely along with its tests, extending delivery times. What’s worse, it could result in hidden side effects, especially if the original developer is no longer available.
To conclude, I’ll provide a less simplistic example using this technique. SocketService is responsible for decoding an operation code, calling its relevant handler and returning its result. All of its argument types are interface abstractions, including its constructor. The CLI is responsible for determining which encoder to use, i.e. NullTerminated or Padded, and passing its instance down to the SocketService class.
As projects continue to grow, its recommended to utilise a dependency injection framework to “inject” these dependencies automatically, such as Dependency Injector.
Hopefully, this demonstrates how to write loosely coupled Python code with dependency injection. Many Python developers dislike dependency injection because they feel it's not “pythonic”, but I would argue that its benefits can’t be discredited, especially when it's present in so many other programming languages.