Skip to content

Dependency Injection and Encapsulation

Odd scenarios

Let's immagine two different situations: in situation A, a person comes to a restaurant and tells the waiter: "I'd like to order this meal, but I brought my own chef who will cook it for me, so you're going to use them". In situation B, a person hires an electrician and tells them "I'd like to get my house fully featured with electrical wiring, but I don't have the house, you'll have to build it for me".

In situation A, a client tries to interface with a certain service requesting a certain result, but which should be serviced in a fully packaged manner, without the client having to know how it's done. Here, the client is injecting the dependency on the service, when the service shouldn't require it from the client.

In situation B, another client tries to interface with another service which should only do part of the work that's necessary to achieve a bigger outcome. Here, the client is expecting the service to achieve the bigger outcome by itself, while it's not the responsibility of this service to do so.

I brought these metaphors to depict a scenario when DI (Dependency Injection) is used but shouldn't, and a different one when it should be used, but isn't. And the reason for this is, I've seen DI being taught or used in a dogmatic way in a number of projects I worked on.

The problems with Dependency Injection

DI is a great technique, but it has a flaw: when you inject an object X into another object Y for it to use it, you don't encapsulate the knowledge of building object X inside object Y - whichever client code needs to build object Y will also need to know of object X as well. Sure, object Y ends up not having the responsibility of building object X, which is good, but something else will have to build it. In some situations this might be just fine, or even desirable; But sometimes object X is only ever used in object Y and nowhere else, and building it is a really simple task - maybe just instantiating it without any arguments - which could be encapsulated within object Y. Using DI naturally breaks this encapsulation.

Sure, DI makes it easier to test code - by allowing developers to pass test doubles (mocks, stubs etc.) as dependencies to classes under test, for example -, and it does remove the responsibility of building objects from other objects that use them (which might be crucial if these dependencies should be built differently under different circumstances), but it also spreads these pieces of knowledge to other parts of the code. While this reduces the complexity in the receiving object, it increases overall complexity, as more objects in the graph are involved in the operation to maintain the same functionality.

Examples

Take the following diagram, for example:

---
title: Without DI
---

classDiagram
    Customer ..> Restaurant
    Restaurant ..> Chef
    class Customer{
        order_meal() Meal
    }
    class Restaurant{
        chef Chef
        dispatch_order(order Order) Meal
    }
    class Chef{
        cook(order Order) Meal
    }

Here, the customer orders a meal for the restaurant (through the waiter - not depicted here to reduce the complexity), and the customer doesn't care who makes the meal, it just expects the meal in return for the order. They don't even care if there's one or two chefs, or even none at all, they just want the meal.

Now, let's go through a first iteration of DI:

---
title: With simple DI
---

classDiagram
    Customer ..> Restaurant
    Restaurant ..> Chef
    class Customer{
        chef Chef
        order_meal(chef Chef) Meal
    }
    class Restaurant{
        dispatch_order(order Order, chef Chef) Meal
    }
    class Chef{
        cook(order Order) Meal
    }

The idea here is that the Customer injects the Chef into the Restaurant, eliminating from the Restaurant the responsibility of "building" the Chef. However, you may notice that now this responsibility lies within the Customer; The only thing this approach did was to move this responsibility around, but putting it in a weird place - why would a Customer ever be responsible for "building" a Chef? So this current iteration is no better than the previous one, it's actually worse.

Alright, let's try to fix this by relying on a ServiceProvider instead, which will provide the Customer with a Chef:

---
title: With DI and Service Provider
---

classDiagram
    Customer ..> Restaurant
    Customer ..> ServiceProvider
    Restaurant ..> Chef
    class ServiceProvider{
        build_chef() Chef
    }
    class Customer{
        service_provider ServiceProvider
        order_meal() Meal
    }
    class Restaurant{
        dispatch_order(order Order, chef Chef) Meal
    }
    class Chef{
        cook(order Order) Meal
    }

Now the implementation makes a bit more sense; The Customer is no longer dependent on a Chef implementation, but rather it can ask the ServiceProvider to build one and return an interface for it, for example. So the ServiceProvider is dependent on the Chef, which neither the Customer nor the Restaurant are.

While this reduced the complexity and responsibilities of the Restaurant, it did make the overall complexity higher by introducing a new class and moving the responsibility to it. Also, it introduced a dependency from the Customer to the ServiceProvider. And, what's worse, it broke the encapsulation of the Restaurant by forcing it to use a Chef; What if the Restaurant decides to modernize its production line and wants to replace its whole kitchen staff with machines? If it were in the first iteration, we would be able to make this change within the Restaurant itself, but now we have to change the ServiceProvider, the Customer and the Restaurant to replace the Chef with a CookingMachine. 3 objects instead of 1 - one of the strongest symptoms of encapsulation violation.

The solution

The simplest solution to the last iteration would be to just assume that the Restaurant should be responsible for building the Chef, and that's it. Thus, going back to the first iteration.

But, if you're like me, and you use Test-Driven Development (which you should!), or even if you're a "test-later person", then you'll notice that you'll make the Restaurant harder to test this way - since you can't inject the Chef into the Restaurant from within your unit tests. This is where I propose my personal approach to solve this problem, which I call...

Dependency replacement

While it's not an official or community-recognized name (which is why I can't call it a "design pattern"), I've been using this technique for years, and it works wonderfully well for me. Let's see some Python code showing how it works. I'll follow the TDD dynamics here, bear in mind.

Start with the test

The very first thing I do is to start with a test that ensures that the Restaurant starts with a predefined Chef (which let's suppose that is already tested):

from some_module import Chef


class TestRestaurant:
    def test_starts_with_chef(self):
        restaurant = Restaurant()

        assert isinstance(restaurant.chef, Chef)

The reason why I do this is that I want to make sure that the Restaurant has everything it needs to operate under normal conditions, in production. Let's move on.

Implement the minimum production code

Let's now do the bare minimum for the test to pass:

from some_module import Chef

class Restaurant:
    def __init__(self):
        self.chef = Chef()


class TestRestaurant:
    def test_starts_with_chef(self):
        restaurant = Restaurant()

        assert isinstance(restaurant.chef, Chef)

Great, now we have a Restaurant that has what it needs. Now we can move to the fun part.

Replace the Chef

What if I want to test that the restaurant does what it needs with the Chef? In other words, what if I want to use a mock or spy as a replacement for the Chef? Well, I just do it, because my code allows me to!

from some_module import Chef

class Restaurant:
    def __init__(self):
        self.chef = Chef()


class MockChef(Chef):
    pass


class TestRestaurant:
    def test_starts_with_chef(self):
        restaurant = Restaurant()

        assert isinstance(restaurant.chef, Chef)

    def test_does_something_with_chef(self):
        restaurant = Restaurant()
        restaurant.chef = MockChef()

        # Now I exercise what I need to, with the Restaurant, and then I assert
        # that the Chef was told to do what I expected it to.

Advantages

I love my approach not because it's mine, but because it keeps my code simple, modular, and it respects the encapsulation of the objects more appropriately. To my point of view, this technique is simpler than using traditional Dependency Injection, and makes the production code easier to read (since there are no Chefs being passed around in multiple classes here).

Also, it makes the code really easy to test, as you can see.

Disadvantages

The only inherent disadvantage I see with the technique is that it exposes the dependency publicly, to be replaced at any time - even from other production code! So it comes at the cost of having the Restaurant trusting that the Customer won't sneakily replace the Chef for a Gorilla, for example. Supposing that the Customer respects the Liskov Substitution Principle, though, and replaces it with an AwardWinningChef for example, it should be just fine. In practice, though, this has never been a problem to me, I've never been bitten for allowing this replacement to happen.

Also, in some languages this technique might be a bit problematic; Take Rust, for example, where, if you want to replace a struct property, you have to make it mutable first, which might imply other changes in the program that can be undesirable.

Conclusion

Dependency Injection is a powerful tool, but it implies increased complexity and lower encapsulation. Before rushing on using it just for the sake of being able to test a class (which is a very strong motivation - testability is paramount!), try to think if there is another way around it. Like I showed above, there's at least one solution for it, to make the code testable while still keeping it simple and well encapsulated.