Dependency Inversion is one of important principles defined in SOLID design. Understanding this principle leads to understanding principles behind clean architecture and unit testing. In this article we will find out how important it is.
Let's imagine that we are writing push noticiations component inside our backend project. We decided to use firebase API as push gateway. To do that we've installed third-party library developed by firebase.
from firebase_admin import messaging def send_push_notification(message: str, device_token: str) -> None: message = messaging.Message( ..., ) response = messaging.send(message)
We can see that our function
messaging module directly from third-party lbirary called
firebase_admin . New library serves as a new abastraction for interacting with complex things like
Firebase Server . Main reason of using new abstraction is to reduce number of actions required to send push notifications. In other words we save time by using third-party library.
Using new abstractions means that we depend on it. Now we depend on third-party library
firebase_admin . This kind of dependency is called
Direct dependencies are hard to change. Now your function is highly coupled with this firebase library API. We can say that this code is nailed to third-party library. In order to change your push gateway provider(let's say to Expo) you have to do some serious work in your code. In real project this code is pretty complex and changing to another library will take some time and pain.
Testing with direct dependencies means that your code triggers all coupled components from up to bottom. In our case
send_push_notification function calls
firebase library functions and classes. There's no way to change third party dependency for testing except that you can mock third party library. It means it's very hard to implement unit tests with this code. In this test we trigger every component, so it's more integration/E2E test.
I don't like to call something good or bad. I would say this direct dependenices(high coupling) has such tradeoffs:
➕ Pros :
- simple implementation;
- less time to implement and understand;
➖ Cons :
- we can't change dependencies quickly;
- we can't write proper unit tests without mocks;
Let's do a trick. Take our first image and inverse arrows(inverse dependencies). What do you see?
At first glance it doesn't make any sense. How third-party library can depend on our code? Should we fork the library and directly use our code to depend on it? Sounds bad.
We need to recall that third-part library is an abstraction that reduces complexity of interaction with Firebase server.
send_push_notification depends on this abstraction(third-party library).
We can't modify our third-party library, but we can create our own abstraction around it. We can use abstraction as our tool for inverting dependencies. We will invert our abstractions. Let's add another layer of abstraction. Custom abstraction designed by us.
Dependencies are still direct. Let's continue our process of thinking.
send_push creates an instance of class
PushNotification which is a wrapper using third-party library.
Depending on abstract class
Now we will make our
FirebasePushNotification to depend on an interface. Abstract classes in python serve as interface. You can define abstract class and some methods. Classes who inherit this abstract class must implement all the methods in parent class marked as abstract.
Let's take a look how we've done this:
Seems we have inversed dependencies for
FirebasePushNotification class. Now it depends on abstract class. Sound's like we're heading in right direction.
How can we make
send_push to depend on abstract class?
push_notification as kwarg.
def send_push(push_notification_service: PushNotification, ...) -> None: push_notification = push_notification_service() push_notification.send(...)
We made our code to depend on abstraction, rather than concrete implementation. That's what we wanted.
So, by introducing a couple of abstractions and using interfaces we can achieve inversed dependencies. We've
decoupled our components. Single interface compared to direct usage of the whole third-party library decoupled our code. Now it's possible to change notification system backend by implementing new class without huge efforts to change other parts of our code. Argument of interface type adds interchangebility.
Decoupling our push notification backend/gateway now allows us to test our functions with fake implementations. It means we don't need to mock the dependencies. Instead just implement
FakePushNotification class and pass it to our
push_notification function. Therefore with inversed dependencies we can implement unit tests.
- low coupling - interchangeable components;
- unit tests without mocks;
- new abstractions add complexity, code is harder to read;
- new abstractions require effort to maintain;
Code using third-party libraries directly has direct dependencies. It's a simple solution, but it nails our dependencies and doesn't allow to implement unit tests without mocks.
We can invert these dependencies by introducing new abstractions via abstract classes and their implementations. Client code should have an argument of abstract type to inverse its dependencies. It allows to decouple components, change dependencies on the fly and do unit tests without mocks.