Dependency Injection is a design pattern that allows creating objects that use other objects inside them (we will explore this definition in more detail below).
Let’s begin with an example: Injecting into a service component is an example of using DI.
Why do we need this DI mechanism at all? What advantages does it provide us? Let’s break it down with examples:
In this code snippet, we have implemented a relationship called Composition. It occurs when one entity incorporates another entity inside itself and fully manages its lifecycle. This concept is particularly useful in application development where different components need to be tightly coupled to ensure proper functionality. The process is quite clear and straightforward. We simply labeled the constructor of the ElectricEngine class, allocated memory for it, and directed the variable this.engine to this memory location. Now, through the reference, we can access the ElectricEngine class from the Car class.
Let’s consider the next example:
Here, we have added another entity. Now, the constructor of our electric engine will take an object of the Starter type as a parameter. In turn, the Car class, when creating an instance of the ElectricEngine class, must pass the new ElectricEngine(new Starter()) as an argument. Now, imagine that ElectricEngine takes not just one class in its constructor but at least 3-4. Then, the invocation of the ElectricEngine class constructor will increase accordingly. And this is not the limit:
The DI mechanism lets us avoid worrying about which entities ElectricEngine uses internally.
Let’s now go back to services and components. Imagine a situation where our service internally uses an httpClient and two other services. When we want to create an instance of our service in a component, we would also have to create instances of httpClient and the other two services, which significantly complicates our task and reduces code readability.
Injector in Angular
An injector is a component in the Dependency Injection (DI) system that looks for a dependency based on a key in the container or creates a dependency using a configuration taken from the provider. The provider specifies how to create and supply instances of dependencies.
First of all, it is necessary to consider the hierarchy of injectors as a kind of tree with its own rules of construction. Based on this scheme, we can conclude that our tree consists of:
- Platform Injector: This is the global injector responsible for providing dependencies before the creation of the first components and even the modules. This injector, for example, handles services like the page Title. It is worth noting that by marking a service with providedIn: “platform,” the service becomes shared across all sub-applications in our application. (We can connect one main module or several, essentially making our application consist of two or more applications).
- Root Injector: When we specify providedIn: “root” inside a service, we create a singleton for the entire application (or sub-applications if there is more than one main module). What is a singleton? In simple terms, it’s a service that exists in a single instance for the entire application where we provide it.
- Lazy Route Injector / Module Injector: The Angular DI structure is designed so that each module has its injector. The difference between a lazy-loaded module and a regular one is that the lazy module’s injector will resolve dependencies only after the module is loaded into the bundle.
- Node Injector: This injector is designed to resolve dependencies within components and directives.
Let’s now delve into the details:
We’ll begin by understanding when our service becomes a singleton in DI and when it can have multiple instances.
- Consider a situation where we have an AppModule, inside of which we provided some UserService. This service becomes a singleton for other modules that we include in the imports array. In other words, if we inject UserService into a component or directive, it will be in a single instance. HOWEVER! If in the component/module/directive itself, we do not override the providers’ array by passing UserService there (we will discuss this further).
P.S. Check the console log, and if the number obtained from UserService is the same, our service is a singleton.
Now, let’s consider a situation where we provided UserService in the providers’ array at the AppModule level and at the level of some SomeTestComponent. In this case, the service becomes a singleton for all components and directives linked to AppModule but not for SomeTestComponent and (importantly!) all its child components.
Injectors like Platform and Root are global for the application/applications. This means that by providing the service once, it will have a single instance for the entire application, making it a singleton. Let’s not forget that at the level of 1. Module 2. Component 3. Directive, it can be provided again, leading to the creation of another instance of the service in the application.
Now, let’s talk about lazy modules. Each of them has its own injector, which is activated only when the module has been loaded into the bundle. But it “inherits” dependencies from the main module to which it was imported. So, if we provide some UserService in AppModule and it is the root for the lazily-loaded SitesModule, the instance of UserService in SitesModule will be the same as for AppModule. It is essential to remember that we can provide the service at the module, component, and directive levels (see point 2).
Next, let’s consider an implementation example. Let’s create a service:
Let’s provide our service in the AppModule:
Next, you need to create SitesModule and SiteComponent, where we will immediately inject UserService.
For testing purposes, let’s also inject UserService into AppComponent:
Lazy loading:
And now you will see, when navigating through the route, that the console outputs the same number for both the App and User components.
Provisioning Types
From the previous article we learned about injectors; now we need to understand various situations we may encounter while providing:
- Providers: [UserService] The simplest and most familiar type of service provided in a module. We just pass the name of the service we want to provide at the level of a specific module in the application, in this case, we will consider the AppModule. Our service becomes a singleton and is available for injection in 1. Components
- Directives within the scope of the AppModule and the modules imported into it. These rules also apply to lazy modules, for which AppModule is the root.
Providers: [{ provide: UserService, useClass: UserService }]
- Similarly, in the Providers array, you can pass an object instead of just the service name. In the useClass property, we explicitly specify which service to use when injecting UserService. This is equivalent to the notation [UserService], but under the hood, everything happens as in example #2, Angular developers just make our lives easier.
- Providers: [{ provide: UserService, useClass: NewUserService }]
Let’s consider another providing option from example #2. As mentioned earlier, in useClass, we pass the service that will be used when injecting UserService. HOWEVER! In this way (example 3), in the modules where needed, we can substitute UserService with NewUserService, and the NewUserService class will be used. When can this be useful? For example, if the logic changed in a certain part of the application, and specifically for that part, a new service was implemented. Thanks to the capabilities of Dependency Injection (DI), we only need to change one line in the module instead of replacing the service in the constructor of each component.
- Providers: [{ provide: UserService, useValue: { name: ‘Some Name’ }}]
Instead of passing a service to useClass, DI provides the option to pass a specific object. So, when we inject UserService, DI will provide us not with an instance of the service but with the object that we have provided through provisioning.
- Providers: [NewUserService, { provide: UserService, useExisting: NewUserService }]
And now, let’s register NewUserService separately. In doing so, if we keep the option with useClass: NewUserService, we will notice that two instances of NewUserService are created, two different singletons. To avoid this situation, DI provides a method called useExisting, which ensures that only one instance is created, not two. In simple terms, we ask DI not to create a new instance of the class but to use the existing one. This is useful when we are not sure that by providing the service in the module, it will be in a single instance.
- Providers: [{ provide: UserService, useFactory: (local: string): string | null {return….} ]
Another providing option is to use useFactory. This can be useful when we want to extract some logic that will be calculated “on-the-fly,” and we can also add some conditions there. Instead of creating a whole service, we can provide a specific piece of logic through DI (DATE_AND_TIME_FORMAT — InjectionToken, which we’ll cover it later):
It’s worth noting that in deps: [LOCALE_ID], we pass an object from which values will be passed to the parameter useFactory: (local: string). HOWEVER! Nothing prevents us from returning instances of services in useFactory! We can simulate a similar situation:
In conclusion, we can say that useFactory can be used when we want to calculate something “on-the-fly” or based on certain conditions.
- InjectionToken<type>(description);
And finally, the last providing option is used when we want to provide an entity that is not a service. It works well in combination with useFactory. Now, let’s complete the picture from the previous example by creating a folder and naming it “config.” Inside it, we’ll declare two constants using InjectionToken:
In this situation, LOCALE_ID in the deps: [LOCALE_ID] array for our provider will provide values to useFactory(locale: string). When we provide DATE_AND_TIME_FORMAT, we will get date options depending on what is stored in the local storage, obtained from LOCALE_ID.
Conclusion
Dependency Injection (DI) is a programming method that allows objects in an application to be loosely coupled, reusable, easily refactored, and testable. DI proves valuable in the development of various solutions and products, especially those with complex architectures, multi-layered logic, different implementations of a single interface, external dependencies, or the need to adapt to changing requirements.
DI can help address issues such as:
- Tight coupling between classes, making replacement, extension, or reuse challenging.
- Violation of the Single Responsibility Principle, where a class autonomously creates and manages its dependencies instead of delegating this task to an external mechanism.
- Complexity in testing classes with hidden or hard-coded dependencies that cannot be isolated or substituted.
- Low modularity and flexibility in an application that cannot adapt to various usage scenarios, configurations, or platforms.
In summary, DI is a useful and powerful programming method that can enhance the quality, performance, and scalability of applications.