Clean code 1: Dependency Injection explained simply
It is not enough for code to work. (Robert C Martin).
Intro
Faire
This part 1 is the first of a series centered upon a real world application: Faire.(Which is French for “To do”). A GTD-based set of tools for self-organization we'll build with CQRS and Event Sourcing
The goal is to guide you through the entire process of writing code is the right way.
Method
Learning software architecture is no easy task. Not only there is a lot of concepts to grasp. My struggle entering this world, was that the way it is often explained is overwhelming.
The way I like things explained to me, is by introducing one concept at a time, thoroughly.
And only use as a basis to build upon, concepts with a why and a how made totally clear.
That's what this series aims for.
Prerequisites
You need to know Python, and have it installed. That's it. We'll use Python3.8
Dependency Injection, what, why, how ?
The first time I heard the term Dependency Injection I thought to myself:
Just based on how the name sounds, that looks like a super advanced concept
It's really not.
Elevators
To show you how simple and intuitive DI really is, I'll start by an analogy. Though I don't know you I'll make an assumption: You know how to use an elevator (good for you :), congrats !).
When you enter an elevator aiming for the third floor: you press the button with 3 on it (well, if you actually didn't know how to use an elevator, that's how. You're welcome).
Do you know if the elevator works with cables, rails, magnetic suspension ?
Do you know if magnetic suspension elevators are even a thing at all ? (I have no clue, I just made that up).
You don't know, and most importantly, You don't care !
What you know is that pressing a button will bring you to the corresponding floor, regardless of the underlying mechanism.
Interfaces
The very first time I was introduced Interfaces I wondered how on earth a weird "empty class that can't contain code" could ever help me in any way.
And, yeah, that's more or less what it looks like at first glance.
What interfaces realy are: contracts. When a class implements an interface, what it does is saying to its consumers "I'll expose those methods".
Back to our elevator example:
When you use one, you're presented —give or take— this interface:
interface ElevatorInterface{
button_0()
button_1()
button_2()
button_3()
button_4()
}
The way it works is Implementation detail.
As a consumer you want to use any elevator the exact same way.
Elevator companies are in charge of making elevators work properly.
In code that's called Separation of concerns (It's the S in the acronym SOLID)
Classes should be like elevators. The concerns we'll want to separate are:
Business logic and Technical logic.
Real world situation
You've just been hired as a coder. Your boss has heard about NoSQL and thinks that's super cool, for no particular technical reason. So you're asked to refacto the code for it to work with xxxNoSQL instead of the current xxxSQL.
Exploring the code, you stumble upon that beauty:
class UserManager:
def signin(self, user:User):
if db.exec_query(f"SELECT count(*) FROM user WHERE id='{user.email}'")>0:
raise UnnecessarilyTypedException(f"A user with email address {user.email} already exists")
else:
db.exec_query(f"INSERT INTO user(id, email, password) VALUES(NULL, {user.email}, {user.password})")
The first reaction any decent dev should have here is to puke a little.
The second reaction should be to be to remove that ugly strong coupling.
See, that's a toy example. But should it occur in real life, you could expect all the code to be build that way.
What's the problem here ?
Well, aside probable SQL injections, what should really shock you here is
strong coupling. Business logic is written aside communication
with the DB layer. To change the DB while keeping the spirit of this code
you'd have to go through every handwritten queries.
You'd have bugs. Nasty and time consuming bugs.
To be extra clear
Two users can't have the same email address
Is Business logic (kinda, that's arguable. But that's a toy example so just go with it)
Asking the DB all users with a given email address
Is technical logic.
Whatever the project, whatever the technical stack, those two should never be side by side in a block of code.
What to do about it ?
Keep coupling as loose as possible. Don't ever mix business logic and low
level DB stuff (or any low level stuff for that matter)
How to achieve that ?
That's where Interfaces come to scene.
Since interfaces are not a thing in Python, in a first time we'll pretend that given this class:
class FooInterface:
def bar(self, bla:str) -> int: pass
This one, when declared, will raise an exception because it has no
method bar
class FooImplementation(FooInterface):
pass
And this one will throw an exception because eventhough it has declared a bar
method, the types of args and return don't match those of the interface.
class FooImplementation(FooInterface):
def bar(self, bla:int) -> list:
return []
We'll later see how to make that happen.
Anyway, back to our code.
Our goal is to separate the business and technical logic.
First we'll create an interface:
class UserRepositoryInterface:
def find_by_email(self, email:str) -> List[User]: pass
def create(self, user:User) -> None: pass
def commit(self) -> None: pass
Then we'll rewrite the code of UserManager
(assuming you want to keep a class named so vaguely, which you shouldn't)
class UserManager:
def __init__(self, repository:UserRepositoryInterface):
self.repository = repository
def signin(self, user:User):
if self.repository.find_by_email(user.email):
raise UnnecessarilyTypedException(f"A user with email address {user.email} already exists")
self.repository.create(user)
What just happened ?
When you use an elevator, you're implicitly asking present me buttons with numbers, that's all I want to know.
We've done just that, UserManager
is asking: When you instanciate me, give me an object with those methods
What's next ?
Since this is refacto and your first step is to separate concerns, you should in a first place have a code that behaves in the exact same way.
class UserRepositoryXXXSQL(UserRepositoryInterface):
def find_by_email(self, email:str) -> List[User]:
return [User(**row)
for row in db.exec_query(f"SELECT * FROM user WHERE email='{email}'")
def create(self, user:User) -> None:
...
def commit(self) -> None:
...
(We've kept the same ugly query. That's not what this article is about, but please don't use user input in a f-string to build SQL queries. Just don't)
Then, at runtime, when building UserManager
, we'll give it that repository
user_manager = UserManager(UserRepositoryXXXSQL())
UserManager
depends on an implementation of UserRepositoryInterface
.
When initialized, we inject an instance of an object that implements said interface.
That's Dependency Injection