Just a quick brain dump about dependency inversion, dependency injection and the “Russian dolls” style of object composition that a lot of developers ask me about.
Consider this piece of legacy code for a video rental application:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import requests | |
from video import Video | |
class Pricer(object): | |
def price(self, imdbID): | |
response = requests.get("http://www.omdbapi.com/?i=" + imdbID + "&apikey=6487ec62") | |
json = response.json() | |
title = json["Title"] | |
rating = float(json["imdbRating"]) | |
price = 3.95 | |
if rating > 7: | |
price += 1.0 | |
if rating < 4: | |
price -= 1.0 | |
return Video(imdbID, title, rating, price) |
The pricing logic of our app uses IMDB ratings that it fetches from a web API. If we wanted to unit test this logic, or use a different source of video ratings, we’re stuck because the code to fetch the rating is embedded with the pricing logic.
We can extract the fetching code into its own method in its own class and inject an instance of it in the constructor of Pricer.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from video import Video | |
class Pricer(object): | |
def __init__(self, imdb_api): | |
self.api = imdb_api | |
def price(self, imdbID): | |
rating, title = self.api.fetch(imdbID) | |
price = 3.95 | |
if rating > 7: | |
price += 1.0 | |
if rating < 4: | |
price -= 1.0 | |
return Video(imdbID, title, rating, price) |
It’s now possible to substitute a different implementation of ImdbAPI (e.g., a stub, for unit testing) from outside Pricer.
But if we look one level up in our call stack, we see we still have a problem.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from pricer import Pricer | |
from imdb_api import ImdbAPI | |
class Rental(object): | |
def __init__(self, customer, imdbID): | |
self.customer = customer | |
self.video = Pricer(ImdbAPI()).price(imdbID) | |
def __str__(self): | |
return "Video Rental – customer: " + self.customer \ | |
+ ". Video => title: " + self.video.title \ | |
+ ", price: £" + str(self.video.price) |
Our Rental class knows about Pricer and knows about ImdbAPI, so Rental is not unit-testable. We can fix this by injecting Pricer into Rental.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class Rental(object): | |
def __init__(self, customer, imdbID, pricer): | |
self.pricer = pricer | |
self.customer = customer | |
self.video = self.pricer.price(imdbID) | |
def __str__(self): | |
return "Video Rental – customer: " + self.customer \ | |
+ ". Video => title: " + self.video.title \ | |
+ ", price: £" + str(self.video.price) |
This is the “Russian dolls” style of composition I mentioned at the beginning. ImdbAPI is injected into Pricer, and Pricer is injected into Rental. So we swap the pricing logic without changing Rental, and we can swap the ratings source without changing Pricer, and every object in the chain only knows about the next object in the chain – and only its interface (method signatures). Notice how the object at the bottom of the call stack is created first.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from imdb_api import ImdbAPI | |
from pricer import Pricer | |
from rental import Rental | |
def main(): | |
customer = sys.argv[1] | |
imdbID = sys.argv[2] | |
rental = Rental(customer, imdbID, Pricer(ImdbAPI())) | |
print(rental) | |
if __name__ == "__main__": main() |
Notice now that our highest-level module, Program.py, is “wearing” those dependencies. Program knows which implementations for pricing and fetching movie ratings are being used. Our core internal logic knows none of these details.
This is a natural consequence of dependency inversion and the “Russian dolls” style of composition – effective modular systems wear their dependencies on the outside.
The eagle-eyed among you will have noticed that Pricer has one concrete dependency, on Video.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from video import Video | |
class Pricer(object): | |
def __init__(self, imdb_api): | |
self.api = imdb_api | |
def price(self, imdbID): | |
rating, title = self.api.fetch(imdbID) | |
price = 3.95 | |
if rating > 7: | |
price += 1.0 | |
if rating < 4: | |
price -= 1.0 | |
return Video(imdbID, title, rating, price) | |
But you should also notice that, while Pricer creates a Video, it doesn’t use the Video. This is very important: objects that reference other objects’s implementations shouldn’t call their methods, and objects that call other objects’ methods shouldn’t reference their implementations.
That’s a more essential form of dependency inversion. Any class that uses a Video‘s methods shouldn’t bind directly to an implementation. So we could stub Pricer to return any object that looks like a Video from the outside. This is only possible if Pricer is swappable.