Quick Links

Dependency injection is a software engineering technique where objects are passed instances of the other objects they depend on. Instead of reaching to fetch outside dependencies themselves, objects should directly receive everything they need to function.

A Basic Example

The term "dependency injection" and its definition can seem complex. In practice, it's a simple concept. Let's look at two approaches to creating a basic object. We're using PHP here but the concepts apply to all object-oriented codebases.

Without Dependency Injection

class UserCreator {
    

protected Mailer $Mailer;

public function __construct() {

$this -> Mailer = new Mailer();

}

public function create(string username, string $email) : void {

$this -> Mailer -> send($email, "Your account", "Welcome.");

}

}

With Dependency Injection

class UserCreator {
    

protected Mailer $Mailer;

public function __construct(Mailer $mailer) {

$this -> Mailer = $Mailer;

}

public function create(string $username, string $email) : void {

$this -> Mailer -> send($email, "Your account", "Welcome.");

}

}

The difference is subtle but significant. Both classes have a dependency on a

        Mailer
    

instance. The first class constructs its own

        Mailer
    

, whereas the second works with a

        Mailer
    

provided by the outside world. The

        Mailer
    

dependency has been injected into the class.

Benefits of Dependency Injection

The main advantage of dependency injection is the decoupling of classes and their dependencies that it provides. Dependency injection is a form of inversion of control - instead of classes controlling their own dependencies, they work with instances provided by their outside environment.

In more concrete terms, injecting your dependencies simplifies changing those dependencies in the future. In a real codebase,

        Mailer
    

in our example above would probably be an interface with implementations such as

        SendmailMailer
    

,

        SendGridMailer
    

and

        FakeMailer
    

.

Taking the first example, having classes directly construct one of the

        Mailer
    

instances results in extra work if you then need to replace that

        Mailer
    

implementation. With dependency injection, you can type-hint the

        Mailer
    

interface to accept any compatible implementation. The implementation to use is determined by the outside world.

Most of the time, you'll be using dependency injection in conjunction with a dependency injection container. Containers usually integrate with the application framework you're using. They automatically resolve and inject class dependencies.

Asking a container for a

        UserCreator
    

would first construct a

        Mailer
    

instance. This would be passed to the

        UserCreator
    

via its constructor parameter. You "wire" the container to define the implementation to use when an interface is typehinted. Under this model, changing the active

        Mailer
    

across the entire codebase only requires rewiring the container. This can be one line of code in the container's configuration.

Impacts on Testing

Dependency injection simplifies mocking your dependencies when testing. Because dependencies are sourced externally to the class, you can provide a fake implementation in your unit tests:

class FakeMailer implements Mailer {
    

public function send(string $to, string $subject, string $body) : void {

return;

}

}

We don't need to send the welcome email when testing our

        UserCreator
    

. Nonetheless, it would be unavoidable in the first example, where

        UserCreator
    

always constructs its own live

        Mailer
    

. With dependency injection, we can provide a special

        Mailer
    

which satisfies the interface's contract but eliminates the side effects.

Loose Coupling

Dependency injection isn't about eliminating dependencies altogether - they'll always be there, but they should be loosely coupled. Think of a dependency as something that is attached for a period of time, not a fixture that's permanently glued on.

Tight coupling as seen in the first example makes for inflexible code that's difficult to relocate. Dependencies become opaque to outside observers such as test runners. Typehinting interfaces passed into your class keeps your dependencies as loosely coupled as possible.

Single Reponsibility Principle

A final advantage of dependency injection is its ability to help you adhere to the Single Responsibility Principle. This states that each class should have responsibility for a single self-contained unit of functionality in your codebase.

Without injection, classes not only provide functionality but also construct their own dependencies. This requires each class to possess detailed knowledge about the requirements of other sub-systems. With injection, the class' knowledge of the outside world is limited to the contracts provided by the interfaces it's dependent on.

Conclusion

Dependency injection makes it easier to maintain object-oriented codebases. Classes which construct their own dependencies are usually a code smell. They end up tightly coupled to their surroundings and are tricky to test.

Hoisting dependencies out of the classes that use them inverts control and creates a stronger separation of concerns. You can think of this as akin to the removal of hardcoded constants: you'd never write a database schema name into your source, as it should reside in a configuration file.

The implementation of

        Mailer
    

is a configuration detail too.

        UserCreator
    

shouldn't concern itself with setting up a mail system. The chances are you'll be wanting to reuse the same system elsewhere in your codebase. Configuration changes more frequently than code; you'll likely want to change the way mail is sent long before you revise the business requirement that an email is sent when a user signs up.

In summary, dependency injection encourages your components to ask for the functionality they need, instead of grabbing it piecemeal on a case-by-case basis. Application-level instance wiring via a container exposes dependencies for what they are: configuration details which are liable to change. Code your classes to accept abstractions from the environment, instead of concrete implementations constructed internally.