Dependency Injection Demystified: The Key to Modular, Scalable and Maintainable Code
Dependency injection is a technique that allows a software component to receive its dependencies from an external source, rather than creating them itself. This can improve the modularity, testability, and maintainability of the code, as well as reduce coupling and increase cohesion.
Dependency injection is a design pattern that follows the principle of Inversion of Control (IoC), which states that a software component should not depend on how its dependencies are created or obtained, but rather on how they are used. In other words, a component should not be responsible for creating or locating its dependencies, but only for using them.
Injection could be done directly by using the object of the class or through interface. This type of dependency injection decouples the component from the injector, but it also adds an extra layer of abstraction and indirection.
Types of Dependency Injection
Constructor Injection
Constructor injection is when we pass the dependencies as parameters to the constructor of the dependent class. This is the most common and recommended type of dependency injection, as it ensures that the dependent class has all its required dependencies before it is instantiated.
It also makes the dependencies immutable, which prevents accidental changes or leaks
public class EmailService {
private EmailSender emailSender;
public EmailService(EmailSender emailSender) {
this.emailSender = emailSender;
}
}
Setter Injection
Setter injection is when we provide setter methods for the dependencies in the dependent class, and call them after the dependent class is instantiated. This type of dependency injection allows us to change or add dependencies at runtime, which can be useful for optional or dynamic dependencies.
However, it also introduces mutability and complexity, as we need to ensure that the dependent class is in a consistent state after setting its dependencies
public class NotificationService {
private EmailService emailService;
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
}
}
Field Injection
Field injection is when we annotate the fields that represent the dependencies in the dependent class with a special annotation, such as @ Autowired
in Spring or @ Inject
in Guice. The framework or container then uses reflection to inject the dependencies into these fields at runtime. This type of dependency injection reduces boilerplate code and makes the dependencies more concise.
However, it also makes them less visible and explicit, as they are not declared in the constructor or setter methods. It also makes testing more difficult, as we need to use reflection or other tools to access these fields
public class OrderService {
@Autowired
private UserService userService;
}
Why use dependency injection?
- Loose coupling: By separating the creation and usage of objects, we can reduce the dependencies between different modules of our system, making them more independent and reusable
- Testability: By injecting mock or stub objects instead of real ones, we can isolate and test the behavior of our objects without relying on external factors or side effects
- Maintainability: By centralizing the configuration and management of our dependencies, we can easily change or replace them without affecting the rest of our code
- Flexibility: By using interfaces or abstract classes to define our dependencies, we can inject different implementations depending on the context or environment
What is dependency injection?
Suppose we have a class called UserService
that depends on another class called UserRepository
to access the database
public class UserService {
// UserService creates its own dependency
private UserRepository userRepository = new UserRepository();
public User getUserById(int id) {
// UserService uses its dependency
return userRepository.findById(id);
}
}
This code has several problems
- Single Responsibility Principle (SRP): which states that a class should have only one reason to change. By creating its own dependency, UserService is not only responsible for providing user-related services, but also for managing the creation and lifecycle of UserRepository. This makes the code harder to test, reuse, and modify
- Open/Closed Principle (OCP): which states that a class should be open for extension but closed for modification. By hard-coding the dependency to
UserRepository
,UserService
is tightly coupled to a specific implementation of the repository interface. This makes it difficult to change or replace the dependency without modifying the code ofUserService
. For example, if we want to use a different repository implementation, such asMockUserRepository
for testing purposes, we would have to change the code ofUserService
- Dependency Inversion Principle (DIP): which states that high-level modules should not depend on low-level modules, but both should depend on abstractions. By depending on a concrete class rather than an interface or an abstract class,
UserService
is coupled to a low-level detail of how the data is accessed. This makes it harder to adapt to changes in the data source or the data access technology
To solve these problems, we can use dependency injection. Dependency injection means that instead of creating its own dependency, UserService
receives it from an external source, such as a framework or a container. This way, UserService
does not need to know or care about how the dependency is created or obtained, but only about how it is used
public class UserService {
// UserService receives its dependency from an external source
private UserRepository userRepository;
// Constructor injection: the dependency is passed as a parameter to the constructor
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User getUserById(int id) {
// UserService uses its dependency
return userRepository.findById(id);
}
}
This code is much better than the previous one. It follows the SRP, OCP, and DIP principles. It is easier to test, reuse, and modify. It is loosely coupled to any specific implementation of UserRepository
. It can work with any object that implements the repository interface, such as MockUserRepository
for testing purposes. It can also adapt to changes in the data source or the data access technology without affecting UserService
.
Dependency injection is a widely used technique that is supported by many frameworks and tools in various programming languages.
Some of the most popular and well-known ones are:
- Java: Spring Framework, Google Guice, Android Dagger
- .NET: Autofac, Ninject, ASP.NET Core
- JavaScript: Angular, React
How does it work?
Dependency injection works by using reflection or annotations to inspect the components and their dependencies, and then using dynamic proxies or code generation to create and inject the dependencies at runtime.
- Reflection: is a mechanism that allows inspecting and manipulating the metadata of classes, methods, fields, parameters, annotations, at runtime. Reflection can be used to discover the dependencies of a component by analyzing its constructor, setter methods, or interface methods. Reflection can also be used to create instances of dependencies by invoking their constructors or factory methods
- Annotations: are special markers that provide additional information about classes, methods, fields, parameters. Annotations can be used to specify the dependencies of a component by annotating its constructor, setter methods, or interface methods with metadata such as names, types, scopes, qualifiers
- Dynamic proxies: are objects that act as placeholders for other objects. Dynamic proxies can be used to create and inject dependencies by implementing interfaces or extending classes that define the dependencies of a component. Dynamic proxies can also intercept method calls on the dependencies and perform additional actions such as logging, caching, validation
- Code generation: is a technique that creates new code based on existing code. Code generation can be used to create and inject dependencies by generating subclasses or wrappers that implement or extend the components and their dependencies. Code generation can also optimize performance by avoiding reflection or dynamic proxies
Suppose we have the following classes annotated with Component
and Autowired
in Spring
@Component
public class UserService {
@Autowired
private UserRepository userRepository;
}
@Component
public class UserRepository { }
TheComponent
annotation indicates that the class is a component that can be managed by the Spring container. The Autowired
annotation indicates that the field is a dependency that needs to be injected by the Spring container.
When the Spring container starts, it scans the classpath
for classes annotated with Component
and creates instances of them. Then, it uses reflection to inspect the fields of these classes and look for annotations such as Autowired
. If it finds such an annotation, it searches for a matching component in its registry and injects it into the field. If it does not find a matching component, it throws an exception.
In this way, the Spring container manages the lifecycle and dependencies of the components using reflection and annotations.
Implementation of DI Using Reflection and Annotations
Implementation of Dependency Injection in Java can be done in 3 steps
- Scan: The framework or container scans the classpath for classes that are annotated with certain annotations, such as
Component
,Service
,Repository
,Controller
, etc. These annotations indicate that the classes are components that can be managed by the framework or container and can participate in dependency injection. The framework or container then creates and registers instances of these classes in a component registry - Wire: The framework or container inspects the fields and constructors of the components for annotations such as
Autowired
,Inject
,Resource
, etc. These annotations indicate that the fields or constructors are injection points where dependencies can be injected. The framework or container then uses reflection to find and inject the matching dependencies from the component registry into the injection points - Init: The framework or container inspects the methods of the components for annotations such as
PostConstruct
,PreDestroy
, etc. These annotations indicate that the methods are lifecycle callbacks that can be invoked by the framework or container before or after the components are initialized or destroyed. The framework or container then uses reflection to invoke these methods at the appropriate time
// An interface that defines the contract for accessing user data
public interface UserRepository {
User findById(int id);
}
// A class that implements UserRepository using JDBC
@Repository // Indicates that this class is a component that can be managed by the framework or container
public class JdbcUserRepository implements UserRepository {
// A field that holds a reference to a data source
@Resource // Indicates that this field is an injection point where a dependency can be injected
private DataSource dataSource;
@Override
public User findById(int id) {
// Use JDBC to query the data source and return a user object
}
}
// A class that implements UserRepository using a mock data source for testing purposes
@Repository // Indicates that this class is a component that can be managed by the framework or container
@Profile("test") // Indicates that this class should only be used when the test profile is active
public class MockUserRepository implements UserRepository {
// A field that holds a reference to a mock data source
@Resource // Indicates that this field is an injection point where a dependency can be injected
private MockDataSource mockDataSource;
@Override
public User findById(int id) {
// Use the mock data source to return a user object
}
}
// A class that provides user-related services
@Service // Indicates that this class is a component that can be managed by the framework or container
public class UserService {
// A field that holds a reference to a user repository
@Autowired // Indicates that this field is an injection point where a dependency can be injected
private UserRepository userRepository;
// A constructor that accepts a user repository as a parameter
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User getUserById(int id) {
// Use the user repository to return a user object
return userRepository.findById(id);
}
@PostConstruct // Indicates that this method is a lifecycle callback that should be invoked after the component is initialized
public void init() {
// Perform some initialization logic, such as logging, caching, etc.
}
@PreDestroy // Indicates that this method is a lifecycle callback that should be invoked before the component is destroyed
public void destroy() {
// Perform some cleanup logic, such as releasing resources, etc.
}
}
Create Custom DI
- Define an annotation for marking the classes that need dependency injection
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Injectable {
}
This annotation has two meta-annotations: Retention
and Target
. The Retention
annotation specifies that the annotation should be retained at runtime, so that it can be accessed by reflection. The Target annotation specifies that the annotation can only be applied to types (classes or interfaces)
2. Define an annotation for marking the fields that need dependency injection
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Inject {
}
This annotation has the same meta-annotations as the previous one, but with a different target: fields
3. Define a class that acts as a container for managing and injecting the dependencies
public class Injector {
private Map<Class<?>, Object> instances;
public Injector() {
instances = new HashMap<>();
}
public void register(Class<?> type, Object instance) {
instances.put(type, instance);
}
public void inject(Object target) throws IllegalAccessException {
Class<?> targetClass = target.getClass();
if (targetClass.isAnnotationPresent(Injectable.class)) {
Field[] fields = targetClass.getDeclaredFields();
for (Field field : fields) {
if (field.isAnnotationPresent(Inject.class)) {
Class<?> fieldType = field.getType();
Object fieldValue = instances.get(fieldType);
if (fieldValue != null) {
field.setAccessible(true);
field.set(target, fieldValue);
} else {
throw new IllegalStateException("No instance registered for type: " + fieldType);
}
}
}
} else {
throw new IllegalArgumentException("Target class is not injectable: " + targetClass);
}
}
}
This class has a map that stores the instances of the dependencies by their types. It also has two methods: register
and inject
. The register
method allows you to register an instance of a dependency by its type. The inject
method allows you to inject the dependencies into a target object, by scanning its fields for the Inject
annotation and assigning the corresponding instances from the map
4. Annotate the classes and fields that need dependency injection with the Injectable
and Inject
annotations
@Injectable
public class EmailSender {
@Inject
private EmailService emailService;
public void sendEmail(String to, String subject, String body) {
emailService.send(to, subject, body);
}
}
Here, the EmailSender
class is annotated with Injectable
, indicating that it needs dependency injection. The emailService
field is annotated with Inject
, indicating that it needs an instance of EmailService
.
5. Create an instance of the Injector
class and register the instances of the dependencies
Injector injector = new Injector();
injector.register(EmailService.class, new GmailService());
Here, an instance of GmailService is registered as an implementation of EmailService.
6. Create an instance of the target class and inject the dependencies using the Injector
instance
EmailSender emailSender = new EmailSender();
injector.inject(emailSender);
Here, an instance of EmailSender is created and injected with an instance of GmailService.
Conclusion
Dependency injection is a powerful design pattern that allows us to decouple the dependencies of a class from its implementation. It has many benefits for software development, such as reducing coupling, increasing testability, promoting modularity and enabling inversion of control. However, it also has some drawbacks and challenges, such as introducing complexity, leading to over-engineering and making our code less readable.
By understanding how dependency injection works under the hood and how to use frameworks to simplify the process, we can leverage its benefits and overcome its drawbacks.