There are several main goals in Dependency Injection technique, including (but not limited to):
- Lowering coupling between parts of your system. This way you can change each part with less effort. See "High cohesion, low coupling"
- To enforce stricter rules about responsibilities. One entity must do only one thing on its level of abstraction. Other entities must be defined as dependencies to this one. See "IoC"
- Better testing experience. Explicit dependencies allow you to stub different parts of your system with some primitive test behaviour that has the same public API than your production code. See "Mocks arent' stubs"
The other thing to keep in mind is that we usually shall rely on abstractions, not implementations. I see a lot of people who use DI to inject only particular implementation. There's a big difference.
Because when you inject and rely on an implementation, there's no difference in what method we use to create objects. It just does not matter. For example, if you inject requests
without proper abstractions you would still require anything similar with the same methods, signatures, and return types. You would not be able to replace this implementation at all. But, when you inject fetch_order(order: OrderID) -> Order
it means that anything can be inside. requests
, database, whatever.
To sum things up:
What are the benefits of using inject?
The main benefit is that you don't have to assemble your dependencies manually. However, this comes with a huge cost: you are using complex, even magical, tools to solve problems. One day or another complexity will fight you back.
Is it worth to bother and use inject framework?
One more thing about inject
framework in particular. I don't like when objects where I inject something knows about it. It is an implementation detail!
How in a world Postcard
domain model, for example, knows this thing?
I would recommend to use punq
for simple cases and dependencies
for complex ones.
inject
also does not enforce a clean separation of "dependencies" and object properties. As it was said, one of the main goal of DI is to enforce stricter responsibilities.
In contrast, let me show how punq
works:
from typing_extensions import final
from attr import dataclass
# Note, we import protocols, not implementations:
from project.postcards.repository.protocols import PostcardsForToday
from project.postcards.services.protocols import (
SendPostcardsByEmail,
CountPostcardsInAnalytics,
)
@final
@dataclass(frozen=True, slots=True)
class SendTodaysPostcardsUsecase(object):
_repository: PostcardsForToday
_email: SendPostcardsByEmail
_analytics: CountPostcardInAnalytics
def __call__(self, today: datetime) -> None:
postcards = self._repository(today)
self._email(postcards)
self._analytics(postcards)
See? We even don't have a constructor. We declaratively define our dependencies and punq
will automatically inject them. And we do not define any specific implementations. Only protocols to follow. This style is called "functional objects" or SRP-styled classes.
Then we define the punq
container itself:
# project/implemented.py
import punq
container = punq.Container()
# Low level dependencies:
container.register(Postgres)
container.register(SendGrid)
container.register(GoogleAnalytics)
# Intermediate dependencies:
container.register(PostcardsForToday)
container.register(SendPostcardsByEmail)
container.register(CountPostcardInAnalytics)
# End dependencies:
container.register(SendTodaysPostcardsUsecase)
And use it:
from project.implemented import container
send_postcards = container.resolve(SendTodaysPostcardsUsecase)
send_postcards(datetime.now())
See? Now our classes have no idea who and how creates them. No decorators, no special values.
Read more about SRP-styled classes here:
Are there any other better ways of separating the domain from the outside?
You can use functional programming concepts instead of imperative ones. The main idea of function dependency injection is that you don't call things that relies on context you don't have. You schedule these calls for later, when the context is present. Here's how you can illustrate dependency injection with just simple functions:
from django.conf import settings
from django.http import HttpRequest, HttpResponse
from words_app.logic import calculate_points
def view(request: HttpRequest) -> HttpResponse:
user_word: str = request.POST['word'] # just an example
points = calculate_points(user_words)(settings) # passing the dependencies and calling
... # later you show the result to user somehow
# Somewhere in your `word_app/logic.py`:
from typing import Callable
from typing_extensions import Protocol
class _Deps(Protocol): # we rely on abstractions, not direct values or types
WORD_THRESHOLD: int
def calculate_points(word: str) -> Callable[[_Deps], int]:
guessed_letters_count = len([letter for letter in word if letter != '.'])
return _award_points_for_letters(guessed_letters_count)
def _award_points_for_letters(guessed: int) -> Callable[[_Deps], int]:
def factory(deps: _Deps):
return 0 if guessed < deps.WORD_THRESHOLD else guessed
return factory
The only problem with this pattern is that _award_points_for_letters
will be hard to compose.
That's why we made a special wrapper to help the composition (it is a part of the returns
:
import random
from typing_extensions import Protocol
from returns.context import RequiresContext
class _Deps(Protocol): # we rely on abstractions, not direct values or types
WORD_THRESHOLD: int
def calculate_points(word: str) -> RequiresContext[_Deps, int]:
guessed_letters_count = len([letter for letter in word if letter != '.'])
awarded_points = _award_points_for_letters(guessed_letters_count)
return awarded_points.map(_maybe_add_extra_holiday_point) # it has special methods!
def _award_points_for_letters(guessed: int) -> RequiresContext[_Deps, int]:
def factory(deps: _Deps):
return 0 if guessed < deps.WORD_THRESHOLD else guessed
return RequiresContext(factory) # here, we added `RequiresContext` wrapper
def _maybe_add_extra_holiday_point(awarded_points: int) -> int:
return awarded_points + 1 if random.choice([True, False]) else awarded_points
For example, RequiresContext
has special .map
method to compose itself with a pure function. And that's it. As a result you have just simple functions and composition helpers with simple API. No magic, no extra complexity. And as a bonus everything is properly typed and compatible with mypy
.
Read more about this approach here: