dopatraman
dopatraman

Reputation: 13908

Is this the right way to do dependency injection in Django?

I'm trying to inject dependencies into my Django view (controller?). Here's some background.

Normally, the urls.py file is what handles the routing. It is usually something like this:

 urlpatterns = [
     path("", views.get_all_posts, name="get_all_posts"),
     path("<int:post_id>", views.get_post, name="get_post"),
     path("create", views.create_post, name="create_post"),
 ]

The problem with this, is that once you get to create_post for instance, you might have a dependency on a service that creates posts:

# views.py
...

def create_post(self):
    svc = PostCreationService()
    svc.create_post()

This kind of pattern is difficult to test. While I know python testing libraries have tools to mock this sort of thing, I'd rather inject the dependency into the view. Here's what I came up with.

A Controller class that has a static method, export(deps) that takes in a list of dependencies and returns a list of url pattern objects:

class ApiController(object):

    @staticmethod
    def export(**deps):
        ctrl = ApiController(**deps)
        return [
            path("", ctrl.get_all_posts, name="get_all_posts"),
            path("<int:post_id>", ctrl.get_post, name="get_post"),
            path("create", ctrl.create_post, name="create_post"),
        ]

    def __init__(self, **deps):
        self.deps = deps

    def get_all_posts():
        pass
    ...

This looks janky, but I'm not aware of any other way to do what I'm trying to do. The controller needs to return a list of url patterns, and it also needs to take in a list of dependencies. Using the above technique, I can do this in urls.py:

urlpatterns = ApiController.export(foo_service=(lambda x: x))

I am now free to use foo_service in any of the methods of ApiController.

Note:

One alternative would be for the constructor to return the list of urls, but I don't see that as a huge improvement over this. In fact, it strikes me as being more confusing because the class constructor would return a list instead of an instance of the class.

Note 2:

I'm aware that python has mocking tools for mocking class members. Please don't suggest using them. I'd like to use DI as the way to control and manage dependencies.

Any ideas on what the best way to do this is?

Upvotes: 23

Views: 27128

Answers (6)

Sebasti&#225;n Bevc
Sebasti&#225;n Bevc

Reputation: 111

While reading Dependency Injection Principles, Practices, and Patterns and trying to apply the examples to a django app I came up with the following:

# views.py

class IndexView(View):
    # Must include this to bypass django's validation
    product_service: IProductService = None
    
    # Init method not necessary but more explicit
    def __init__(self, product_service: IProductService):
        self.product_service = product_service

    def get(self, request):
        self.product_service.do_stuff()
        ...

# urls.py

# Construct dependencies. I guess this is the closest to the entry-point we can get
# with Django.
repo = DjangoProductRepository()
product_service = ProductService(repo)

urlpatterns = [
    path('admin/', admin.site.urls),
    path("", 
         IndexView.as_view(product_service=product_service),
         name="index"),
]

Upvotes: 9

Evan Byrne
Evan Byrne

Reputation: 1155

The most boring solution I could come up with involves using class variables:

# Module services.post_service
def default_create_post():
    return "foo"

class Provider:
    create_post = default_create_post

Then you could import and use normally in a view or elsewhere:

from services import post_service

post_service.Provider.create_post()
# Should return "foo"

And when testing it could be swapped out before being called:

from django.test import TestCase
from services import post_service
from unittest.mock import patch

class MyTestCase(TestCase):

    @patch('services.post_service.default_create_post')
    def test_some_view(self, mock_create_post):
        mock_create_post.return_value = "bar"
        post_service.Provider.create_post = mock_create_post
        # Now when calling post_service.Provider.create_post it should just return "bar"

Upvotes: 0

David Vartanian
David Vartanian

Reputation: 470

This is only an updated version of rabbit.aaron reply above. My idea is to be able to specify which dependencies to inject instead of getting a dictionary with all registered dependencies.

from functools import wraps

class ServiceInjector:
    deps = {}

    def register(self, name=None):
        name = name

        def decorator(thing):
            """
            thing here can be class or function or anything really
            """

            if not name:
                if not hasattr(thing, '__name__'):
                    raise Exception('no name')
                thing_name = thing.__name__
            else:
                thing_name = name
            self.__class__.deps[thing_name] = thing
            return thing

        return decorator

    class inject:
        def __init__(self, *args):
            self.selected_deps = args

        def __call__(self, func):
            @wraps(func)
            def decorated(*args, **kwargs):
                selected_deps = {k: v for k, v in ServiceInjector.deps.items() if k in self.selected_deps}
                new_kwargs = {**kwargs, **selected_deps}
                return func(*args, **new_kwargs)

            return decorated

Usage:

si = ServiceInjector()

# use func.__name__, registering func
@si.register()
def foo(*args):
    return sum(args)

Custom naming still works

@si.register(name='uppercase')
class UpperCaseRepresentation:
    def __init__(self, value):
        self.value = value

    def __str__(self):
        return self.value.upper()

Register float

si.register(name="PI")(3.141592653)

Inject into functions

@si.inject('foo', 'PI', 'uppercase')
def bar(a, b, c, uppercase: UpperCaseRepresentation, **kwargs):
    """
    You can specify dependencies as keyword arguments and add typehint annotation.
    """
    UpperCase, foo = kwargs['UpperCase'], kwargs['foo']
    print(uppercase('abc')) # ABC
    print(PI) # 3.141592653
    print(foo(a, b, c, 4, 5)) # = 15

bar(1, 2, 3)

Inject into class methods

class Bar:
    @si.inject('foo')
    def my_method(self, a, b, foo, kwarg1=30):
        return foo(a, b, kwarg1)

print(Bar().my_method(1, 2, kwarg1=50)) # = 53

Upvotes: 4

rabbit.aaron
rabbit.aaron

Reputation: 2589

Consider injecting using decorators:

from functools import wraps

class ServiceInjector:

    def __init__(self):
        self.deps = {}

    def register(self, name=None):

        name = name
        def decorator(thing):
            """
            thing here can be class or function or anything really
            """

            if not name:
                if not hasattr(thing, "__name__"):
                    raise Exception("no name")
                thing_name = thing.__name__
            else:
                thing_name = name
            self.deps[thing_name] = thing
            return thing

        return decorator

    def inject(self, func):

        @wraps(func)
        def decorated(*args, **kwargs):
            new_args = args + (self.deps, )
            return func(*new_args, **kwargs)

        return decorated

# usage:


si = ServiceInjector()

# use func.__name__, registering func
@si.register()
def foo(*args):
    return sum(args)


# we can rename what it's been registered as, here, the class is registered 
# with name `UpperCase` instead of the class name `UpperCaseRepresentation`
@si.register(name="UpperCase")
class UpperCaseRepresentation:
    def __init__(self, value):
        self.value = value

    def __str__(self):
        return self.value.upper()

#register float
si.register(name="PI")(3.141592653)


# inject into functions
@si.inject 
def bar(a, b, c, _deps): # the last one in *args would be receiving the dependencies
    UpperCase, PI, foo = _deps['UpperCase'], _deps['PI'], _deps['foo']
    print(UpperCase('abc')) # ABC
    print(PI) # 3.141592653
    print(foo(a, b, c, 4, 5)) # = 15

bar(1, 2, 3)

# inject into class methods
class Foo:

    @si.inject
    def my_method(self, a, b, _deps, kwarg1=30):
        return _deps['foo'](a, b, kwarg1)

print(Foo().my_method(1, 2, kwarg1=50)) # = 53

Upvotes: 18

Maus
Maus

Reputation: 1843

You could go the flask route and export a class instance with a property that initializes and caches the service on first access. E.g:

def default_factory():
    pass

# service.py
class ServiceProvider:
    def __init__(self, create_instance=default_factory):
        self.create_instance = create_instance

    _instance = None

    @property
    def service(self):
       if self._instance:
           return self._instance
       self._instance = self.create_instance()
       return self._instance

service_provider = ServiceProvider()
from .service import service_provider

# views.py
def view(request):
    service_provider.service.do_stuff()
    # etc.

This has the advantages of being easy to mock and not having any magic.

Upvotes: 0

Krukas
Krukas

Reputation: 677

You could take a look at https://github.com/ets-labs/python-dependency-injector, but that is a pretty big setup.

You could also create something small like a Service factory

# services.py
class ServiceFactory:
    def __init__(self):
        self.__services = {}

    def register(self, name, service_class):
        # Maybe add some validation
        self.__services[name] = service_class

    def create(self, name, *args, **kwargs):
        # Maybe add some error handling or fallbacks
        return self.__services[name](*args, **kwargs)

factory = ServiceFactory()


# In your settings.py for example
from services import factory
factory.register('post_creation', PostCreationService)


# Or maybe in apps.ready do auto_load that will loop all apps and get config from services.py


# In your views.py
from services import factory

def create_post(self):
    svc = factory.create('post_creation')
    svc.create_post()


# In your tests.py
from services import factory

def setUp(self):
    factory.register('post_creation', FakePostCreationService)

Upvotes: 15

Related Questions