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 providers array, if any)
  • If not found, it walks UP the element injector tree through ancestor components — this is how a parent component's providers array 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 when strictInjectionParameters is enabled (Module 1)
widget-instance-counter.service.tstypescript
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;
  }
}
dashboard-panel.component.tstypescript
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.

features/operations-board/models/operation-task.model.tstypescript
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;
}
features/operations-board/services/operations.service.tstypescript
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.

task-status.guard.ts — inject() used where a constructor has no natural hometypescript
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']);
};
task-card.component.ts — constructor injection, the traditional formtypescript
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();
  }
}
task-card.component.ts — equivalent using inject()typescript
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();
  }
}
ScenarioRecommended ApproachWhy
Class component with 1-3 dependenciesConstructor injectionDependencies 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 dependencyinject() as a field initializerAvoids forcing every subclass constructor to repeat and forward the same super() parameters
Composable helper functions used inside a component's field initializersinject()Must run inside Angular's injection context, achievable outside a constructor only via inject()

Module 4 Checkpoint

  1. 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?

  2. Q2. Why must CanActivateFn guards use inject() instead of constructor injection?