What is dependency injection in object-oriented programming (OOP)?
Dependency injection is a technique used in object-oriented programming (OOP) to reduce the hardcoded dependencies between objects. A dependency in this context refers to a piece of code that relies on another resource to carry out its intended function. Often, that resource is a different object in the same application.
Dependencies within an OOP application enable objects to perform their assigned tasks by providing additional functionality. For example, an application might include two class definitions: Class A and Class B. As part of its definition, Class B creates an instance of Class A to carry out a specific task, which means that Class B is dependent on Class A to carry out its function. The dependency is hardcoded into the Class B definition, resulting in code that is tightly coupled. Such code is more difficult to test, modify or reuse than loosely coupled code.
Instead of the dependency being hardcoded, it can be injected through a mechanism such as a class constructor or public property. In this scenario, Class A gets passed into Class B via a parameter, rather than Class B creating the object itself. Class B can then be compiled without including the entire Class A definition, resulting in a class that functions independently of its dependencies. The result is code that is more readable, maintainable, testable, reusable and flexible than tightly coupled code.
Dependency injection and SOLID design principles
Dependency injection is consistent with the SOLID principles of object-oriented design. The principles provide a set of guidelines for developing code that is more maintainable and extensible than other types of code. SOLID is an acronym that represents the following five concepts:
- S -- Single responsibility.
- O -- Open/closed.
- L -- Liskov substitution.
- I -- Interface segregation.
- D -- Dependency inversion.
Dependency inversion is of particular importance when it comes to dependency injection. Dependency inversion focuses on decoupling and abstracting code, rather than relying too heavily on concretions, which are hardcoded concrete implementations. Dependency inversion also ensures that high-level modules do not depend on low-level modules.
Dependency injection supports the dependency inversion principle by injecting dependencies into the class definitions instead of hardcoding them. In this way, it abstracts the details and ensures that high-level modules don't depend on low-level modules.
Dependency injection and inversion of control
Dependency injection is also closely aligned with the inversion of control (IoC) software design principle, which states that a class should be configured by another class from the outside, as opposed to configuring dependencies statically. It asserts that a program is more pluggable, testable, usable and loosely coupled if the management of an application's flow is transferred to a different part of the application.
When interactions occur that need custom business logic, an IoC framework invokes code provided by the developer -- this is the inversion aspect. Ruby on Rails is an example of IoC in an application framework. The use of event-based user interfaces instead of ones controlled by procedural code are also examples of the IoC principle.
While there is some confusion and debate as to the relationship between IoC and dependency injection, the latter is generally considered to be a specific style of IoC.
According to Martin Fowler, chief scientist at Thoughtworks, a technology consultancy, the confusion arises due to the increase use of IoC containers. "The name is somewhat confusing since IoC containers are generally regarded as a competitor to Enterprise JavaBeans, yet EJB uses inversion of control just as much -- if not more," according to Fowler.
Dependency injection describes a specific use case of IoC where a container provides a configurable application context that creates, starts, catches and manages pluggable objects.
The 4 roles of dependency injection
Dependency injection is implemented in OOP development through an application's class definitions. The components that participate in the injection typically play one of these four roles:
- Service. A class that carries out some type of functionality. Any object can be either a service or client. Which one it is depends on the role the object has in a particular injection.
- Client. A class that requests something from a service. A client can be any class that uses a service.
- Interface. A component implemented by a service for use by one or more clients. The component enables the client to access the service's functions, while abstracting the details of about how the service implements those functions, thus breaking dependencies between lower and higher classes.
- Injector. A component that introduces a service to a client. The injector creates a service instance and then inserts the service into a client. The injector can be many objects working together.
The service, client and interface roles are also used when implementing the dependency inversion principle.
Types of dependency injection
OOP supports the following approaches to dependency injection:
- Constructor injection. An injector uses a class constructor to inject the dependency. The referenced object is passed in as a parameter to the constructor.
- Setter (property) injection. The client exposes a setter method that the injector uses to pass in the dependency.
- Method injection. A client class is used to implement an interface. A method then provides the dependency, and an injector uses the interface to supply the dependency to the class.
- Interface injection. An injector method, provided by a dependency, injects the dependency into another client. Clients then need to implement an interface that uses a setter method to accept the dependency.
Constructor injection is the most common approach to dependency injection. However, it requires that all software dependencies be provided when an object is first created. It also assumes the entire system is using this approach, which means the entire system must be refactored if a component needs to be updated, a process that can be difficult, risky and time-consuming.
An alternative approach to constructor injection is service locator, a pattern that software designers can implement slowly, refactoring the application one piece at a time as convenient. Slow adaptation of existing systems is often better than a massive conversion effort.
Some programmers criticize the service locator pattern, saying it replaces the dependencies rather than eliminating the tight coupling. However, other programmers insist that, when updating an existing system, it is valuable to use the service locator during the transition. Then, when the entire system has been adapted to the service locator, only a small additional step is needed to convert to constructor injection.
Implementing dependency injection
When a client class requires outside services to carry out its intended purpose, the client must know what resources are needed, where to locate them and how to communicate with them. One way of structuring code is to embed the logic for accessing the services within the clients themselves.
However, this tightly coupled approach can be problematic. If a resource changes location or other information changes, the embedded code must be rewritten, which can get quite complex.
For example, when a user clicks a button, the event creates a new instance of a business logic layer, and one of the event's methods is called. Within that method, a new instance of a data access layer class is also formed, and one of its methods is called. In turn, this method makes a database query.
Another way to structure the code is to have clients declare their dependency on resources and enable an external piece of code to assume the responsibility for instantiating and configuring software components and their dependencies.
The external piece of code, which is decoupled, can be handcoded or implemented with a special software module called a dependency injection container or dependency injection framework. Essentially, the container or framework provides a map of the dependencies a client might need and logic for adding new dependencies to the map.
These relationships are summed up in the dependency inversion principle, which states, "High-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details. Details should depend upon abstractions."
Advantages of dependency injection
Many development teams use dependency injection because it offers several important benefits:
- Code modules do not need to instantiate references to resources, and dependencies can be easily swapped out, even mock dependencies. By enabling the framework to do the resource creation, configuration data is centralized, and updates occur only in one place.
- Injected resources can be customized through Extensible Markup Language files outside the source code. This enables changes to be applied without having to recompile the entire codebase.
- Programs are more testable, maintainable and reusable because the client classes do not need to know how dependencies are implemented.
- Developers working on the same application can build classes independently of each other because they only need to know how to use the interfaces to the referenced classes, not the workings of the classes themselves.
- Dependency injection helps in unit testing because configuration details can be saved to configuration files. This also enables the system to be reconfigured without recompiling.
Disadvantages of dependency injection
Although dependency injection can be beneficial, it also comes with several challenges:
- Dependency injection makes troubleshooting difficult because much of the code is pushed into an unknown location that creates resources and distributes them as needed across the application.
- Debugging code when misbehaving objects are buried in a complicated third-party framework can be frustrating and time-consuming.
- Dependency injection can slow integrated development environment automation, as dependency injection frameworks use either reflection or dynamic programming.
Learn more about how dependencies can be a problem in microservices, particularly with the creation of circular dependencies. Also, explore how to use abstracted repositories in dependency injection, and check out in-demand programming languages devs should get to know.