Ch. 4 · Angular

Angular Dependency Injection: Providers, Scopes & inject()

How Angular finds a dependency: element and environment injectors, providedIn root, provider recipes, InjectionToken, resolution modifiers and duplicate singletons.

~7 min readintermediate

Dependency injection is how Angular hands your classes the things they need, instead of letting them construct those things themselves. You ask for a token, an injector finds a provider for it, and you get back an instance. The interesting interview questions are all about the “finds” part: which injector answers, how long the instance lives, and how the same service ends up being created twice. This note uses the modern standalone APIs (inject(), ApplicationConfig, route providers) and mentions the classic decorator equivalents along the way.

The two injector hierarchies

Angular resolves dependencies through two trees:

  • Element injectors follow the component tree. A component or directive that declares providers (or viewProviders) gets an injector on its host element.
  • Environment injectors hold application-wide services. The root environment injector is configured by providedIn: 'root' and by the providers you pass to bootstrapApplication. Routes with their own providers, and lazily loaded NgModules, create child environment injectors. A lazily loaded array of standalone routes doesn’t create one by itself. Above root sit the platform injector and finally the NullInjector.

When a component asks for a token, Angular searches in this order:

  1. The requesting element’s injector, then each ancestor element’s injector up the component tree.
  2. If nothing matched, the environment injector for that part of the app (for example, a route’s), then its parents up to root and the platform injector.
  3. If still nothing matched, the NullInjector throws, unless the dependency was marked optional. Recent versions report it as NG0201, “No provider found for X”; older ones said NullInjectorError: No provider for X!.

The first provider found wins, and the injector that owns it also owns the instance. Keep that sentence in mind; it explains every scoping question below.

providedIn: ‘root’ and app providers

@Injectable({ providedIn: 'root' })
export class CartService {
  private readonly http = inject(HttpClient);
  readonly items = signal<CartItem[]>([]);
}
TypeScript

providedIn: 'root' registers the service with the root environment injector. You get one instance for the whole app, created lazily the first time something injects it. It’s also tree-shakable: the provider lives on the class rather than in a providers array, so if nothing injects the service, the bundler drops it. A class listed in a providers array is always referenced and always bundled.

Services that need configuration, and framework features, go in the application config:

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor])),
    { provide: API_URL, useValue: 'https://api.example.com' },
  ],
};

bootstrapApplication(App, appConfig);
TypeScript

Note

Angular v22 added @Service() as a shorthand for @Injectable({ providedIn: 'root' }). It supports only inject() (no constructor parameters) and can opt out of auto-providing with { autoProvided: false }. Stick with @Injectable() when you need constructor injection or its other provider options.

Component and route providers

@Component({
  selector: 'app-editor',
  providers: [EditorState], // a fresh EditorState for every <app-editor>
  template: `<app-toolbar /><app-canvas />`,
})
export class Editor {
  private readonly state = inject(EditorState);
}
TypeScript

Providers on a component create a new instance for each component instance. The toolbar, the canvas and any projected content all share that editor’s EditorState, and it’s destroyed along with the component, so its ngOnDestroy runs. That’s ideal for state that should be scoped: an editor, a wizard, one row of a complex form. viewProviders works the same way but hides the service from content projected through <ng-content>.

Route providers scope a service to a section of the app:

export const routes: Routes = [
  {
    path: 'admin',
    providers: [AdminReportsService],
    loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
  },
];
TypeScript

providers on a route create an environment injector for that route and its children. Everything under /admin shares one AdminReportsService, and the rest of the app can’t see it.

Provider recipes and InjectionToken

A provider maps a token to a recipe for producing the value:

providers: [
  // Swap an implementation: inject(Logger) returns a ConsoleLogger
  { provide: Logger, useClass: ConsoleLogger },

  // A fixed value, usually behind an InjectionToken
  { provide: API_URL, useValue: 'https://api.example.com' },

  // Computed at creation time; inject() works inside the factory
  {
    provide: STORAGE,
    useFactory: () => (isPlatformBrowser(inject(PLATFORM_ID)) ? localStorage : new MemoryStorage()),
  },

  // An alias: both tokens resolve to the SAME Logger instance
  { provide: AuditLogger, useExisting: Logger },
]
TypeScript

Classic code passes factory dependencies through a deps: [...] array; calling inject() inside the factory is the modern equivalent. Watch the difference between the last two recipes: { provide: AuditLogger, useClass: Logger } creates a second, independent Logger, while useExisting returns the one that already exists. If you list two plain providers for the same token, the last one wins.

Tokens don’t have to be classes. TypeScript interfaces vanish at runtime, so a config object or a string needs an InjectionToken:

export interface AppConfig {
  apiUrl: string;
  retries: number;
}

export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');

// A token can carry a tree-shakable default, just like providedIn: 'root'
export const API_URL = new InjectionToken<string>('API_URL', {
  providedIn: 'root',
  factory: () => '/api',
});
TypeScript

The string is only a debugging label; Angular identifies the token by object reference.

inject() vs constructor injection

// Modern
export class OrderPage {
  private readonly orders = inject(OrderService);
  private readonly route = inject(ActivatedRoute);
}

// Classic, still fully supported
export class OrderPage {
  constructor(private orders: OrderService, private route: ActivatedRoute) {}
}
TypeScript

Both resolve through the same injectors, so the difference is ergonomics. The reasons to prefer inject():

  • It works in any injection context, not just constructors: field initializers, provider and InjectionToken factories, functional guards, resolvers and interceptors, and code wrapped in runInInjectionContext(). Calling it anywhere else, such as in a click handler or a setTimeout, throws NG0203.
  • Inheritance is simpler. A subclass doesn’t have to accept its parent’s dependencies just to forward them to super(...).
  • Options are typed. inject(Logger, { optional: true }) has the type Logger | null.
  • It composes. You can write reusable helper functions that call inject() internally.
export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  return auth.isLoggedIn() || inject(Router).createUrlTree(['/login']);
};
TypeScript

Resolution modifiers and multi providers

Modifiers change where the search starts, where it stops, and what happens on a miss:

inject() option Decorator Effect
optional: true @Optional() Return null instead of throwing
self: true @Self() Look only in the current injector
skipSelf: true @SkipSelf() Start the search at the parent injector
host: true @Host() Stop the search at the host component’s boundary

self can’t be combined with skipSelf or host. A classic use of skipSelf is a recursive component that needs its parent’s instance of a service it also provides:

@Component({
  selector: 'app-tree-node',
  providers: [TreeNodeState],
  template: `<ng-content />`,
})
export class TreeNode {
  readonly state = inject(TreeNodeState); // this node's own instance
  readonly parent = inject(TreeNodeState, { skipSelf: true, optional: true }); // null at the root
}
TypeScript

Multi providers let several providers contribute to one token, and injecting it returns an array:

export const SEARCH_SOURCES = new InjectionToken<SearchSource[]>('SEARCH_SOURCES');

providers: [
  { provide: SEARCH_SOURCES, useClass: ProductSearch, multi: true },
  { provide: SEARCH_SOURCES, useClass: DocsSearch, multi: true },
]

const sources = inject(SEARCH_SOURCES); // [ProductSearch, DocsSearch]
TypeScript

You’ll meet this pattern in the framework itself: NG_VALIDATORS, NG_VALUE_ACCESSOR, and class-based interceptors registered with HTTP_INTERCEPTORS (which need withInterceptorsFromDi()). Every entry needs multi: true; mixing multi and regular providers for one token throws an error.

Why a singleton gets created twice

A “singleton” is only single per injector. These are the usual ways a second instance sneaks in:

  • A root service is also listed in a component’s providers. That component and its subtree get their own copy, so state written in one place never shows up in the other.
  • It’s listed in a route’s providers (or, in NgModule apps, provided by a lazily loaded module). The child environment injector creates a separate instance for that part of the app. The old forRoot()/forChild() convention existed to prevent exactly this.
  • An alias uses useClass instead of useExisting, which builds a second instance instead of pointing at the first.
  • A component with providers is rendered in a loop. Every @for item gets its own instance. Sometimes that’s the goal; make sure it’s deliberate.

Interview tip

When someone says “my service state keeps resetting”, ask which injector created the instance they’re looking at. Then search the codebase for the class name inside every providers array. The answer is almost always a stray provider.

The interview answer

“Angular resolves a token by walking injectors: first the element injectors up the component tree, then the environment injectors (route injectors, then root, then platform). The first provider found wins, and if there’s none it throws a no-provider error unless the dependency is optional. providedIn: 'root' gives an app-wide, lazily created, tree-shakable singleton. Component providers create an instance per component, and route providers share one instance across that route’s subtree.

A provider can supply a class, a value, a factory or an alias, and non-class dependencies use InjectionToken. I prefer inject() because it works in any injection context, including functional guards and interceptors, and it keeps inheritance simple. The classic gotcha is that a singleton is only single per injector, so listing a root service again in a component or route quietly creates a second instance.”

More in Angular

read ✓System Design · hard

Frontend System Design: Build an Autocomplete

A structured walkthrough of the autocomplete design round: requirements, architecture, race-free fetching, caching, rendering, the ARIA combobox and metrics.

~7 min readread →
esc