Over the past couple of years, Angular, one of the most used frontend frameworks, has undergone significant changes that have reshaped how development is done.
While improvements like typed reactive forms or hydration for server-side rendering have captured attention, one of the most influential changes came early on: the addition of standalone components and, thus, orientation towards functional programming. This change, in turn, brought along important updates to Angular’s Dependency Injection (DI) system, making it more flexible and easier to use.
What is dependency injection?
Dependency injection is a powerful design pattern that underpins Angular’s architecture. It enables component modularity and the separation of concerns by delivering the needed parts of the application to the consumers (components or services) that request them. At its core, DI consists of several key concepts:
-
Injector
-
Injectable
-
Provider
-
Consumer
Injector
The injector is the backbone of the DI system. It maintains a registry of providers and is responsible for creating and delivering instances of classes when requested. When a component or service declares a dependency, Angular’s injector checks its registry: if an instance already exists, it returns that instance; otherwise, it creates a new one.
Angular uses a hierarchical injector system, meaning that while there’s a root-level injector for the entire application, child injectors can also be created for specific modules or components. This hierarchy not only helps manage dependencies but also allows for more granular control over the scope of services.
Injectable
The @Injectable decorator marks a class as available for DI. When a class is decorated with @Injectable, it tells Angular that it can inject instances of this class wherever needed. In most cases, developers also specify the provider’s scope with the providedIn property:
In this example, MyService is registered in the root injector, meaning a single instance is shared across the application. However, services can also be provided at a more granular level if their scope needs to be limited.
Provider
A provider tells Angular’s injector how to create a dependency. Providers can be defined in several ways:
-
Class Provider: The most common approach, where a developer simply provides the class.
-
Value Provider: When a developer wants to inject a constant value.
-
Factory Provider: When the dependency creation is more complex and requires a custom factory function.
-
Alias Provider: When a dependency token needs to be created to resolve to another.
-
Multi-Provider: When multiple values need to be associated with a single token.
By configuring providers correctly, the developer controls how and when the services are instantiated. For instance, providing a service at the component level will result in a new instance every time that component is created.
Consumer
Consumers are the components or services that use the injected values. They declare their dependencies in their constructors, and Angular’s DI system takes care of providing them. For example:
Here, MyComponent acts as a consumer by requesting an instance of MyService in its constructor. The DI system ensures that the correct instance is provided, whether it’s the singleton from the root or a new one if registered at a lower level.
Dependency injection with standalone components
Angular’s introduction of standalone components further simplifies the DI landscape. Standalone components remove the need for Angular modules (NgModules) in many cases, allowing a developer to declare providers directly within the component metadata.
For example:
This approach streamlines development by keeping component declarations and their dependencies in one place, leading to better organization and easier testing.
Functional injection: A modern approach
Starting with Angular 14, developers can now leverage the inject function to retrieve dependencies, which allows for a more functional and concise style compared to the traditional constructor-based injection.
The new functional approach simplifies the process by allowing to directly inject dependencies into class fields. Here is an example with a service and with a constant:
Benefits of using inject
-
Cleaner syntax: By removing the need for constructor parameters, classes become less cluttered and more focused on their core logic.
-
Immediate availability: The dependency is available as soon as the field is declared, which can be advantageous in certain scenarios, such as lazy-loaded values or initializing class properties.
-
Flexibility: This approach aligns well with standalone components and functional programming patterns, providing a consistent way to manage dependencies across different parts of the application.
Things to keep in mind
-
Context-specific: The inject function is designed to be used in contexts where Angular’s DI system is active (e.g., during component or service initialization). Calling it outside of these contexts may result in errors.
-
Testing considerations: When writing unit tests, a developer needs to ensure that the DI context is correctly set up for classes using inject. This is generally similar to testing components with constructor injection, but it's something to be aware of.
Choosing between approaches
Both methods ultimately achieve the same goal—providing the component or service with the dependencies it needs. The choice between constructor injection and functional injection depends on the project's style, readability preferences, and specific use cases.
If one prefers a more declarative and concise style, especially in standalone components or utility functions, the inject function can be a great addition to one's Angular toolkit.
Advanced DI Concepts
Hierarchical Injectors
Angular’s DI system is hierarchical. This means that providers declared in a component are only available to that component and its children, while providers declared in the root are available globally.
This hierarchy is particularly useful when one needs to override a dependency in a specific part of the application without affecting the entire app.
Multi-Providers
Sometimes, a developer needs multiple values to be associated with a single token. Angular’s multi-provider system allows this by letting an array of values be provided. Multi-providers are often used for things like logging or configuring multiple strategies for a given operation.
Injection Tokens
When injecting values that aren’t classes (like configuration objects or strings), Angular provides the InjectionToken class. This ensures that the DI system can safely reference these values without naming collisions.
Token can be provided in a module or component:
Best practices and common pitfalls
Choose the right scope
Decide whether a service should be provided at the root or component level. Global services reduce boilerplate but may lead to unwanted shared state, while component-level providers offer isolation.
Avoid circular dependencies
Ensure that the services and components do not depend on each other in a way that creates a cycle. Circular dependencies can lead to runtime errors and make debugging difficult.
Use Injection Tokens wisely
For non-class values, always use InjectionToken to prevent naming conflicts and to improve clarity.
Leverage Angular’s hierarchical DI
Understand how Angular’s injector hierarchy works to avoid unexpected behaviors, especially when overriding providers in nested components.
Conclusion
Angular’s dependency injection system is a powerful tool that helps manage complexity, improve testability, and promote a clean separation of concerns within your application. Whether you’re using non-standalone components or the newer standalone components, mastering DI will allow you to build more scalable, maintainable, and modular applications.
By understanding the roles of injectors, injectables, providers, and consumers and by applying best practices, you can leverage Angular’s DI to its fullest potential.