Dependency Injection (DI) in Angular is not merely 'constructor parameters get filled in automatically' — it is a hierarchical tree of injectors, each capable of providing a different instance of the same token depending on where in the component tree the request originates. Understanding that hierarchy is what separates developers who can debug a mysterious 'wrong service instance' bug from those who can't.
The Injector Hierarchy: Module/Root vs Element Injector
Angular maintains two parallel injector trees. The environment injector hierarchy (root injector, plus any lazy-loaded route's own environment injector) resolves services registered with providedIn: 'root' or in a route's providers array. The element injector hierarchy mirrors the component tree itself — each component and directive gets its own element injector, populated by that component's @Component({ providers: [...] }).
Resolution Order When a Component Injects a Token
- Angular first checks the requesting component's own element injector (its
providersarray, if any) - If not found, it walks UP the element injector tree through ancestor components — this is how a parent component's
providersarray can supply a different instance to all its descendants than the app-wide singleton - If no element injector in the ancestor chain provides it, Angular falls back to the environment injector (root, or the nearest lazy-loaded route injector)
- If still unresolved and the token has no default, Angular throws
NullInjectorError— a compile-time-safe error only whenstrictInjectionParametersis enabled (Module 1)
import { Injectable } from '@angular/core';
// No providedIn here — this service is deliberately NOT a root singleton.
// It must be added to a component's own `providers` array to get a
// fresh instance scoped to that component's subtree.
@Injectable()
export class WidgetInstanceCounterService {
private count = 0;
next(): number {
return ++this.count;
}
}import { Component } from '@angular/core';
import { WidgetInstanceCounterService } from './widget-instance-counter.service';
@Component({
selector: 'app-dashboard-panel',
standalone: true,
// Registering a service here creates a NEW element injector entry.
// Every DashboardPanelComponent instance — and everything nested
// inside its template — gets its own isolated counter.
providers: [WidgetInstanceCounterService],
template: `<ng-content />`,
})
export class DashboardPanelComponent {}A Production-Grade, Fully Typed HTTP Service
providedIn: 'root' is the enterprise default for the vast majority of services: it makes the service a tree-shakable singleton, registered lazily the first time it's actually injected, with zero module wiring required.
export type TaskStatus = 'queued' | 'in-progress' | 'blocked' | 'complete';
export interface OperationTask {
id: string;
title: string;
owner: string;
status: TaskStatus;
updatedAt: string; // ISO-8601 timestamp
}
export interface CreateOperationTaskRequest {
title: string;
owner: string;
}import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import type { OperationTask, CreateOperationTaskRequest, TaskStatus } from '../models/operation-task.model';
@Injectable({ providedIn: 'root' })
export class OperationsService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/operations/tasks';
getTasks(status?: TaskStatus): Observable<OperationTask[]> {
let params = new HttpParams();
if (status) {
params = params.set('status', status);
}
return this.http.get<OperationTask[]>(this.baseUrl, { params });
}
createTask(request: CreateOperationTaskRequest): Observable<OperationTask> {
return this.http.post<OperationTask>(this.baseUrl, request);
}
updateStatus(taskId: string, status: TaskStatus): Observable<OperationTask> {
return this.http.patch<OperationTask>(`${this.baseUrl}/${taskId}`, { status });
}
deleteTask(taskId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${taskId}`);
}
}inject() vs Constructor Injection
The inject() function (stable since Angular 14) retrieves a dependency from the current injection context without a constructor parameter. It is not a replacement that deprecates constructor injection — both are fully supported — but it solves specific problems constructor injection cannot.
import { inject } from '@angular/core';
import type { CanActivateFn } from '@angular/router';
import { Router } from '@angular/router';
import { SessionService } from '@core/services/session.service';
// CanActivateFn is a plain function, not a class — there is no
// constructor to inject into. inject() is the only option here.
export const requiresActiveSessionGuard: CanActivateFn = () => {
const session = inject(SessionService);
const router = inject(Router);
if (session.isActive()) {
return true;
}
return router.createUrlTree(['/login']);
};import { Component, Input } from '@angular/core';
import { OperationsService } from '../services/operations.service';
import type { OperationTask } from '../models/operation-task.model';
@Component({
selector: 'app-task-card',
standalone: true,
template: `<button type="button" (click)="markComplete()">{{ task.title }}</button>`,
})
export class TaskCardComponent {
@Input({ required: true }) task!: OperationTask;
constructor(private readonly operationsService: OperationsService) {}
markComplete(): void {
this.operationsService.updateStatus(this.task.id, 'complete').subscribe();
}
}import { Component, Input, inject } from '@angular/core';
import { OperationsService } from '../services/operations.service';
import type { OperationTask } from '../models/operation-task.model';
@Component({
selector: 'app-task-card',
standalone: true,
template: `<button type="button" (click)="markComplete()">{{ task.title }}</button>`,
})
export class TaskCardComponent {
@Input({ required: true }) task!: OperationTask;
// Field initializer instead of constructor parameter — functionally
// identical resolution, but the dependency is declared where it's used.
private readonly operationsService = inject(OperationsService);
markComplete(): void {
this.operationsService.updateStatus(this.task.id, 'complete').subscribe();
}
}| Scenario | Recommended Approach | Why |
|---|---|---|
| Class component with 1-3 dependencies | Constructor injection | Dependencies are visible in one place (the constructor signature); familiar to every Angular developer |
| Functional guards/resolvers/interceptors (CanActivateFn, HttpInterceptorFn) | inject() | These are plain functions — there is no class, so no constructor exists to inject into |
| Base class with many subclasses needing the same dependency | inject() as a field initializer | Avoids forcing every subclass constructor to repeat and forward the same super() parameters |
| Composable helper functions used inside a component's field initializers | inject() | Must run inside Angular's injection context, achievable outside a constructor only via inject() |
Module 4 Checkpoint
Q1. Why does registering WidgetInstanceCounterService in DashboardPanelComponent's `providers` array give each panel instance a separate counter, when the service has no `providedIn` at all?
Q2. Why must CanActivateFn guards use inject() instead of constructor injection?