When designing for Dependency Injection, what starts to happen is that initializing classes with the right things quickly becomes unweildy. Imagine you have three classes and want to add a fourth that depends on the rest of the classes. Sure, that’s one line changed, you just have to figure out where to put that so that the other three are already initialized and can be depended on:

main.py
class NewService():
	def __init__(
		self,
		logger: Logger,
		database_connection: DatabaseConnection,
		authenticator: Authenticator
	):
		self.__logger = logger
		self.__database_connection = database_connection
		self.__authenticator = authenticator

	def do_thing():
		self.__logger.info("Starting the thing.")
		with self.__database_connection.connect() as conn:
			result = conn.execute("SELECT * FROM things;")
			if self.__authenticator.is_authenticated(result):
				print("Lets go!")
		self.__logger.info("Done executing the thing.")

def main():
	logger = Logger()
	database_connection = DatabaseConnection(logger)
	authenticator = Authenticator(logger, database_connection)
	new_service = NewService(logger, database_connection, authenticator)
	new_service.do_thing()

if __name__ == "__main__":
	main()

That’s cool when everything is linear, but normally it’s a little bit more messy; you have a web of classes that depend on each other in a directed acyclic graph, and one big entrypoint class that does the logic that you actually want. In this case, it’s NewService.

Wouldn’t it be cool if we could just say “Hey, give me a NewService and figure out all of its dependencies,” and our code would figure that out for us?

Enter Dependency Containers.

Dependency Containers

Many languages have libraries (or even built-in functionality) that provide something akin to a Dependency Container, allowing you to collect dependencies in one place and then ask for specific ones. Some containers provide interesting functionalities like allowing you to request a class from the container, and the container constructing any of the dependencies of that class that do not already exist.

Others allow you to create tokens that help to disambiguate values stored in the container. For example, if you need to store an authentication token for an external service as a string, and another token used to sign web keys as a string, you can tag those values as specific instances and pull those from the container instead of just trying to pull based on type.

The biggest benefit as I see it, however, is allowing you to really dig down into the Single Responsibility principle. If you end up writing a big, complex class, you can split it into multiple services and create a dependency between them.

I’m going to be using the dependency-injector library for this guide since I already picked Python, but most libraries and languages will have similar interfaces. As a reminder from the last part, here’s what we’re starting with:

main.py
from abc import ABC, abstractmethod

# Create an interface for logging messages
class Logger(ABC):
    @abstractmethod
    def debug(self, message: str) -> None:
        ...

    @abstractmethod
    def info(self, message: str) -> None:
        ...


# Create an implementation. Part 1 had some more subclasses but this
# is the one we're using for this part.
class ConsoleLogger(Logger):
    def debug(self, message: str):
        print(f"[DEBUG]\t{message}")

    def info(self, message: str):
        print(f"[INFO]\t{message}")

For this specific library, we need to create a container. Containers define how we should create and use classes. For example, if we want to require certain parameters but provide the rest, we can use a Factory. I generally use Singletons and have the framework put together the injections on its own. Let’s write a container!

main.py
from dependency_injector import containers, providers

# ...

class Container(containers.DeclarativeContainer):
	logger = providers.Singleton(ConsoleLogger)
Aside: Types and Containers
Some languages with stronger typing will use the type as the injection key, which allows you to do some cool things with inheritance. In this case, the injection key is the name of the field on the container class.

Wiring

This isn’t doing much for us currently, but we can use the dependency container to do something called Wiring, where the container will replace constructor parameters with keys that we use in our class:

main.py
from dependency_injector.wiring import inject, Provide

# ...

@inject
class NewService:
    def __init__(
        self,
        logger: Logger =
        # Pull the Logger from the container, but allow it to be
        # given as a parameter if we want special behavior. Note
        # that it is using the `logger` key that we created in the
        # previous step.
        Provide[Container.logger],
    ):
        self.__logger = logger

    def do_thing(self):
        self.__logger.info("Doing the thing!")

if __name__ == "__main__":
	# create our container
    container = Container()
    # do the wiring on this module
    container.wire(modules=[__name__])
    # create a new service without needing to provide a logger
    newService = NewService()
    # watch the service use our logger
    newService.do_thing()

Another Example

Because I mentioned that types have a role in dependency injection, let me show you an example in TypeScript using type-di (from my heta bot):

app.ts
// Set up reflection
import 'reflect-metadata';


import Container from 'typedi';
import Logger, { createLogger } from 'bunyan';

// Add a Logger to the global container
Container.set(
    Logger,
    createLogger({
        name: 'bot',
        stream: process.stdout,
        level:
            process.env['NODE_ENV']?.toLowerCase() != 'production'
                ? 'debug'
                : 'info',
    })
);

// Construct an AppInitializer and provide all of its dependencies
// that we can
const app = Container.get(AppInitializer);