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
With Dependency Injection
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:
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.