The Case for inject() Over Constructor-Based DI
Angular 14 introduced the inject() function, presenting an alternative to declaring dependencies through the providers array or by passing them via the constructor. Today, inject() has become the preferred choice for many developers. This piece examines the advantages that make inject() the superior option and explains the rationale behind adopting it.
We will compare how inject() and constructor injection perform across different scenarios. Before diving into that comparison, we will cover the fundamentals of dependency injection, the Angular injection context, and the constraints that govern injection.
Dependency Injection at a Glance
Dependency injection, commonly abbreviated as DI, is a widely adopted design pattern rooted in the Inversion of Control principle. Angular ships with a robust built-in DI mechanism that handles the creation and distribution of application components to the parts that depend on them. Through this system, you gain flexibility in how dependencies are consumed throughout your application.
Within the DI framework, there are two central roles: the dependency consumer and the dependency provider.
Providers come in several forms, with the most typical being a class decorated with @Injectable and configured with providedIn. For instance:
@Injectable({
providedIn: 'root'
})
class HeroService {}
When providedIn is set to 'root', the class becomes a singleton and is registered with the application's root injector. An alternative configuration looks like this:
@Injectable()
class HeroService {}
// ...
@Component({
selector: 'app-example',
template: '...',
providers: [HeroService]
})
export class ExampleComponent {
private _heroService = inject(HeroService);
}
This second approach produces a scoped instance rather than a singleton, and it must be manually listed in the providers array of the component where you intend to use it.
In practice, the dependencies you provide are typically services and custom injectables.
The Injector is an abstraction that maintains a registry. When a dependency is requested, it checks whether an instance already exists; if not, it creates one and registers it.
Injectors are instantiated automatically during application bootstrap, so manual creation is unnecessary. You can find more details on dependency providers here.
Understanding the Injection Context
DI depends on a runtime environment known as the injection context. When dependency consumers want to bring in services, directives, pipes, or other custom injectables, they must do so within this context. According to the rules of DI, all forms of injection must happen inside the injection context for the application to function correctly; otherwise, an error is thrown. A quick example illustrates this principle:
class MyComponent {
private _service1: Service1;
private _service2: Service2 = inject(Service2); // In context
private _service3: Service3;
constructor(private _service4: Service4) { // In context
this._service1 = inject(Service1) // In context
}
data = getData(); // In context
onSubmit() {
this._service3 = inject(Service3) // Out of context
this._service1.method() // Still allowed
}
}
export function getData(): HttpClient {
return inject(HttpClient);
}
The injection context is available under these circumstances:
- Within the construction block of a class marked with
@Injectableor@Component, which covers theconstructor()and the initializer fields of those classes (as shown in the example above) - Inside the factory function designated for
useFactoryor aProvideror an@Injectable - Within the
factoryfunction assigned to anInjectionToken - Inside a stack frame that executes while an injection context is active
For a deeper look at the injection context, check out the documentation here.
Attempting to inject a service outside this context causes Angular to raise the NG0203 error.
Consider this example:
main.ts
import { Component, OnInit, inject, signal } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app.config';
import { TodoService } from './todo.service';
import { User, UserService } from './user.service';
import 'zone.js';
@Component({
selector: 'app-root',
standalone: true,
template: `
<h2>ToDos</h2>
<select (change)="onSelected($event)">
<option value="">--Select a user--</option>
@for(user of users(); track user.id) {
<option [value]=user.id>{{ user.name }}</option>
}
</select>
<button (click)='onClick()'>New Item</button>
@for(todo of todosForUser(); track todo.id) {
<div>* {{ todo.title }}</div>
}
`,
})
export class App implements OnInit {
private readonly _todoService = inject(TodoService);
protected todosForUser = this.todoService.todosForDisplay;
// private readonly _userService = inject(UserService);
// users = this._userService.users;
users = signal<User[]>([]).asReadonly();
// constructor(private _userService: UserService,
// private _todoService: TodoService) {}
ngOnInit() {
const userService = inject(UserService);
this.users = userService.users;
}
onClick() {
this._todoService.addNewItem();
}
onSelected(event: Event) {
const selectedVal = (event.target as HTMLSelectElement).value;
if (typeof selectedVal !== 'string') return;
this._todoService.getTodosForUser(selectedVal);
}
}
bootstrapApplication(App, appConfig);
Here, the code attempts to inject userService within the OnInit lifecycle hook for initialization purposes. The code itself won't crash, but the logic breaks down because injection is meant to occur during the construction phase, which is inside the injection context, not after that phase ends. Consequently, you'll see the following error in your console:
Since the logic fails, your application will not display any users in the list:
This example comes from a StackBlitz project, which you can access via this link to experiment with it.
Comparing inject() and Constructor Injection
Constructor-based DI is the conventional approach and remains fully supported in the latest Angular versions. Angular 14 added the inject() function, which retrieves a token from the currently active Injector. Like other DI methods, it operates exclusively within the injection context. While the constructor can never escape the injection context, unlike inject(), the inject() function provides a more flexible and often simpler mechanism for managing dependencies, along with additional capabilities. It has now become the more commonly used method. This section highlights the key distinctions between the two.
Injecting into Standalone Functions
Standalone functions are external functions that can be called from anywhere within a class.
Let's start by revisiting the constructor approach to DI:
export class App implements OnInit {
// ...
constructor(private _userService: UserService, private _todoService: TodoService) {}
ngOnInit() {/* ... */}
// ...
}
This pattern is shorthand for injection during the construction phase. When using the constructor for injection, you're limited to the class's construction phase, as functions lack constructors. This is where inject() shines—it enables injection within standalone functions. Whether a function that calls inject() runs in the injection context depends on the call site. Take this example:
import { HttpClient } from "@angular/common/http";
import { Injectable, inject } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
export interface User {
id: string;
name: string;
username: string;
email: string;
website: string;
}
export function getUsers() {
const userUrl = "https://jsonplaceholder.typicode.com/users";
const http = inject(HttpClient);
return toSignal(http.get<User[]>(userUrl), {initialValue:[] });
}
@Injectable({
providedIn: 'root'
})
export class UserService {
// private _userUrl = "https://jsonplaceholder.typicode.com/users";
// private readonly _http = inject(HttpClient); -> injection context
// constructor(private _http: HttpClient) { } -> injection context
// users = toSignal(this._http.get<User[]>(this._userUrl), {initialValue:[] });
// injection context
users = getUsers();
}
Notice that the getUsers() function plays a part in setting up the variables during the construction phase of the UserService class. As a result, calling inject(HttpClient) happens within the injection context. However, this approach requires you to use inject(), as constructor-based injection simply isn't an option here. Beyond being callable in various spots within a class's construction block, inject() also allows you to offload some initialization duties to exported functions outside the class.
Dealing with Inheritance
inject() also eases DI when inheritance comes into play. We're back to classes, so let's talk about what you no longer need to worry about thanks to inject(). We'll use a BaseComponent class and a ChildComponent that extends it:
// base.component.ts
import { OnInit } from '@angular/core';
import { LoggerService } from './logger.service';
import { ErrorHandlerService } from './error-handler.service';
export abstract class BaseComponent implements OnInit {
constructor(
protected logger: LoggerService,
protected errorHandler: ErrorHandlerService
) {}
ngOnInit() {
this.logger.log('BaseComponent Initialized');
}
}
The BaseComponent class pulls in LoggerService and ErrorHandlerService via its constructor.
// child.component.ts
import { Component } from '@angular/core';
import { BaseComponent } from './base.component';
import { LoggerService } from './logger.service';
import { ErrorHandlerService } from './error-handler.service';
import { AnalyticsService } from './analytics.service';
@Component({
selector: 'app-child',
template: `...`
})
export class ChildComponent extends BaseComponent {
constructor(
protected override logger: LoggerService,
protected override errorHandler: ErrorHandlerService,
private analytics: AnalyticsService
) {
super(logger, errorHandler);
}
trackEvent() {
this.analytics.track('Child Event');
this.logger.log('Event tracked from ChildComponent');
}
}
The ChildComponent class injects AnalyticsService for its own use. But note that its constructor also accepts the services that BaseComponent needs, so it can pass them along by calling super() in the correct order. This pattern becomes unwieldy in larger projects.
Now let's look at the version that uses the inject() function instead:
// base.component.ts
import { OnInit, inject } from '@angular/core';
import { LoggerService } from './logger.service';
import { ErrorHandlerService } from './error-handler.service';
export abstract class BaseComponent implements OnInit {
protected logger = inject(LoggerService);
protected errorHandler = inject(ErrorHandlerService);
constructor() {}
ngOnInit() {
this.logger.log('BaseComponent Initialized');
}
}
The BaseComponent constructor now takes no arguments. In fact, you could even omit the constructor() block entirely for cleaner code.
// child.component.ts
import { Component, inject } from '@angular/core';
import { BaseComponent } from './base.component';
import { AnalyticsService } from './analytics.service';
@Component({
selector: 'app-child',
template: `...`
})
export class ChildComponent extends BaseComponent {
private analytics = inject(AnalyticsService);
constructor() {
super();
}
trackEvent() {
this.analytics.track('Child Event');
this.logger.log('Event tracked from ChildComponent');
}
}
ChildComponent injects only what it requires. Since it already extends BaseComponent, the amount of constructor boilerplate is drastically reduced. You don't need to worry about the ordering of properties; calling an empty super() is all that's required (JavaScript mandates it, after all).
Conditional Injection
Conditional injection is another area where we're reliant on the inject() function, as it allows you to inject a dependency based on a specific condition. You can't place a constructor() call inside an if block. Instead, you evaluate a condition within the constructor and then invoke inject(), which is permitted within the injection context. Here's an illustration:
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { AnimationService } from './animation.service';
@Injectable({ providedIn: 'root' })
export class UiOrchestrationService {
private readonly _animationService = isPlatformBrowser(inject(PLATFORM_ID))
? inject(AnimationService)
: null;
// constructor() {
// const platformId = inject(PLATFORM_ID);
// if (isPlatformBrowser(platformId)) {
// this.animationService = inject(AnimationService);
// }
// }
triggerAnimation() {
this.animationService?.start();
}
}
In this example, there's an AnimationService designed to run only in a browser environment. It first injects PLATFORM_ID, a special token indicating the platform your app is running on. You start by setting the AnimationService instance to null, then check the condition inside the constructor before injecting it. This strategy prevents runtime errors on the server side and enhances performance by avoiding the creation of services that aren't needed.
Advanced Usage of inject()
So far, you've grasped the fundamentals of DI, the regulations of the injection context, and how inject() stacks up against constructor-based injection. You now understand where injection is appropriate, where it isn't, and how rigidly the injection context rules are enforced.
Nevertheless, even though this is more advanced material, I'd like to cover other Angular features that permit the use of inject() in scenarios you might think are off-limits. This includes a few utility functions.
runInInjectionContext:
This utility allows you to invoke inject() within an injection context, even when you're technically outside one (like in methods or lifecycle hooks).
// hero.service.ts
@Injectable({
providedIn: 'root',
})
export class HeroService {
private _environmentInjector = inject(EnvironmentInjector);
someMethod() {
runInInjectionContext(this._environmentInjector, () => {
inject(SomeService); // Do what you need with the injected service
});
}
}
Example taken from https://angular.dev/guide/di/dependency-injection-context#run-within-an-injection-context
This approach requires that you also have access to the current injector.
injector.runInContext()
This method behaves similarly to the previous one, with the distinction that runInInjectionContext is a standalone function, while runInContext is a method on the EnvironmentInjector.
import { inject, Injectable, EnvironmentInjector } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class DataService {
private _injector = inject(EnvironmentInjector);
loadDataAsynchronously() {
setTimeout(() => {
this._injector.runInContext(() => {
const httpClient = inject(HttpClient);
// ...
});
}, 2000);
}
}
Key Takeaways
Classical DI mechanisms are tightly bound to the injection context, so they fail when invoked outside of it. This discussion has focused on the two main styles available in Angular: constructor-based injection and function-based injection. The constructor approach represents the older pattern and has strict limitations—it can only operate within the injection context. This makes the inject() function the more versatile option for modern development. We have explored the scenarios where inject() is the right tool and where the constructor remains suitable, including techniques to bypass context restrictions while keeping the code within the DI scope. The advantages are clear: inject() dramatically streamlines class inheritance, supports dynamic or conditional dependency selection, and allows for dependency resolution within plain functions without requiring a class structure. Given these capabilities and the developer experience improvements they provide, function-based injection has established itself as the leading choice for managing dependencies in Angular applications today.
Further reading: https://angular.love/dependency-injection-in-angular-everything-you-need-to-know





