proofit404 / dependencies

Constructor injection designed with OOP in mind.
https://proofit404.github.io/dependencies/
BSD 2-Clause "Simplified" License
361 stars 18 forks source link

Injection of dependencies #589

Open sbevc opened 1 year ago

sbevc commented 1 year ago

Is there a way to inject the depdencies defined in a container into a function/class?

As a best practice when applying DI, the containers should only be used within the composition root, as close as possible to the application entrypoint. However, some python frameworks/tools (such as django) don't allow customization of the entrypoints.

Taking Django as an example, the inject decorator would wire a container's dependencies into a view:

# containers.py
from dependencies import Injector
from . import views

class ProductsContainer(Injector):
    entrypoint = views.products    # This would tell the inject decorator that this container is to be wired into the products view
    # Some dependencies definitions
    repository = DjangoRepository
    product_service = ProductService

# views.py
@inject
def products(request, product_service: ProductService):
    products = product_service.get_products()
    return render(...)

However, currently there is no way to wire a dependency into an entrypoint. One has to directly ask for the dependency:

# views.py
def products(request):
    service = ProductsContainer.products_service
    products = service.get_products()
    return render(...)

Although the injection might seem useless because one can direcly ask for the dependency, this last example leads to the Service Locator antipattern taking away some of the main DI benefits:

  1. Instead of having clearly defined dependencies, the view drags along the container, which transivetely drags along all defined dependencies in it.
  2. the complexity of the view is not obvious: it might use several dependencies defined in the container instead of requiring them as arguments.
proofit404 commented 1 year ago

Hello,

I hope you'll find dependencies package useful.

4 years ago I would agree with you. All your points makes sense.

I did't tried to implement @inject decorator to solve problems you mention. I end up with bunch of contrib packages which were able to do tricky stuff like add Injector into Django routing, or register Injector as Celery task consumer, or create a py.test fixture from Injector.

Let say we agreed that the only responsibility of the view layer is to delegate to the service layer. You want your views to look like a oneliner with a call to the service object (or a use case in terms of clean architecture).

Obviously you need just a little bit of glue code inside that view to do the delegation. The responsibility of view layer in that case would take a request object, translate it to the service arguments, take service return value, translate it to the response object.

Stupid repetitive code. A very first intent would be to minimize it the smallest possible variant. And we ended up with the solution that does not require this code at all.

Unfortunately, things goes wrong almost instantaneously. There is a rule that always works:

If you remove boilerplate in some part of your system, you replace it with high coupling between components in the very same place.

Django views defines external contract to your users. And by picked convention this contract was defined by service object signature.

As a result we ended up with two situations:

  1. People changed definition of a service object to match new API endpoint requirements.
  2. People can not refactor service object because it would change an API endpoint.

It would not happen if we have our glue code written before, because you would change glue code itself in both situations.

If you do clean architecture correctly, the size of glue code vs service code is at least 1 to 10 (1 to 100 in all of my real projects).

With that in mind it does not matter THAT MUCH if you wrote you tiny view with DI or not.

For more info you could follow

Hope that helps, have a good day :palm_tree: :cocktail:

Best regards, Josiah.

sbevc commented 1 year ago

Thanks for your answer!

I agree with you that it is simple enough to wire those dependencies with a middleware for Django and a custom task for celery, but those were just examples to explain the concept.

The point I was trying to explain is that in python many tools don’t account for DI and therefore one has to manually hook into the framework/library to inject the dependencies. However, a decorator would fix that issue regardless of framework, potentially saving time and also encouraging the good practice of not referencing the containers from application code

hope this makes sense, thanks for your time!

proofit404 commented 1 year ago

Thanks for a deeper explanation!

dependencies library has one limitation in its design: you have to reference Injector subclass in order to get your object built. Over the years I spent weeks of my time thinking about different ways to invent an API for injection that would have the least amount of problems.

I think that we have a different definition of what application code is.

In my case, I don't treat django views as application code at all. It's a shell. A piece of configuration to hook my usecase objects into an API.

I do understand that this is a bold statement to say the least.

But with that in mind having a reference to the Injector inside a view would not affect application code. Because your entities, usecases, services and repositories does not know about Injector existence.

Lets imagine we have some code like this:

# views.py
@inject
def products(request, product_service):

How it should understand that request comes from caller code (djang url resolver) and that the only thing it needs to inject is product_service?

What would happen if Injector has request defined?

sbevc commented 1 year ago

Here is an example solution that would only attempt to resolve missing kwargs:

import functools
import inspect
import re

from dependencies import Injector
from dependencies.exceptions import DependencyError

class MissingDependency(Exception):
    pass

def inject(fn):
    @functools.wraps(fn)
    def wrapped(*args, **kwargs):
        resolved_kwargs = resolve_kwargs(fn, *args, **kwargs)
        return fn(*args, **resolved_kwargs)
    return wrapped

def resolve_kwargs(fn, *callargs, **callkwargs):
    container = resolve_container(fn)
    kwargs = {**callkwargs}
    try:
        inspect.getcallargs(fn, *callargs, **callkwargs)
    except TypeError as e:
        # Find missing kwargs given by getcallargs, for example
        # Error fn() missing 1 required positional argum 'service'
        missing_kwargs = re.findall(r"'(.*?)'", str(e), re.DOTALL)
        for missing in missing_kwargs:
            try:
                kwargs[missing] = getattr(container, missing)
            except DependencyError:
                raise MissingDependency(f"Cannot resolve dependency '{missing}' in container {container}")
    return kwargs

def resolve_container(fn):
    # Instead of returning a hardcoded container, this function should look at a
    # container that references fn as its entrypoint.
    return MyContainer

class ProductsService:
    def __init__(self, request):
        self.request = request

    def print_request(self):
        print(self.request)

class MyContainer(Injector):
    request = "Container defined request"
    service = ProductsService

@inject
def products(request, service: ProductsService):
    print("Django request:", request)
    service.print_request()

@inject
def bad_products(request, service: ProductsService, missing):
    print("Django request:", request)
    service.print_request()

if __name__ == "__main__":
    # Succeeds and prints
    products(request="A django request")
    # Django request: A django request
    # Container defined request

    class TestService:
        def print_request(self):
            print("Testing")

    # Because only missing args are resolved, direct calls can override
    # injected kwargs (useful for tests)
    products(request="Testing", service=TestService())
    # Django request: Testing
    # Testing

    # bad_products(request="A django request")
    # raises __main__.MissingDependency: Cannot resolve dependency 'missing' in container <class '__main__.MyContainer'>
proofit404 commented 1 year ago

Just a note for myself:

In [2]: from django.utils.functional import SimpleLazyObject

In [3]: class A:
   ...:     b = 1
   ...:

In [4]: from functools import partial

In [5]: def view(request, b=SimpleLazyObject(partial(getattr, A, 'b'))):
   ...:     print(request)
   ...:     print(b)
   ...:

In [6]: view({'user': 2})
{'user': 2}
1