Skip to main content

Advanced dependency injection patterns in Angular

Advanced dependency injection patterns in Angular use custom tokens, factory functions, multi-providers, and injector hierarchy decorators to build service architectures that go far beyond registering a single class.

Theory

TL;DR

  • Basic DI: one token, one instance. Advanced DI: arrays, factories, injector trees.
  • multi: true collects all providers for a token into an array. Without it, each new provider silently overwrites the previous one.
  • useFactory runs at resolution time, so you can call inject() inside for dynamic dependencies.
  • @Self, @SkipSelf, @Optional control which injector level Angular searches, not what it looks for.
  • Component-level providers create a new instance per component, not a shared singleton.

Quick example

Multi-provider for HTTP interceptors, the most common advanced DI pattern:

typescript
// app.config.ts providers: [ { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true }, ] // Injecting the token gives you an array, not a single instance constructor(@Inject(HTTP_INTERCEPTORS) private interceptors: HttpInterceptor[]) { // interceptors = [AuthInterceptor instance, LoggingInterceptor instance] } // Request chain: Auth -> Logging -> HttpClient

Without multi: true, the second provider overwrites the first. Angular does not warn you. The interceptor just disappears.

Key difference

Basic DI resolves one class or value per token. Advanced patterns change what "one" means. Multi-providers collect all matching entries into an array during injector tree traversal. Factory providers run a function at resolution time instead of calling new. Abstract class tokens let you swap implementations without touching consumer code. All three work inside the same hierarchical injector tree Angular builds at startup.

When to use

  • Multiple handlers for the same concern (interceptors, validators, plugins) → multi: true
  • Runtime logic to pick an implementation (browser vs SSR, dev vs prod) → useFactory
  • Third-party objects or legacy code that can't be decorated with @InjectableuseValue or useExisting
  • Feature module needs its own instance, not the root singleton → component or route-level providers
  • Swappable implementations behind a stable contract → abstract class as token

How Angular resolves dependencies

Angular builds a tree of Injector instances at startup. The root is the platform injector, below it sits the root app injector, then module injectors, then component injectors. When you call inject(Token) or declare a constructor parameter, Angular walks up this tree from the current node until it finds a matching provide key.

For multi-providers, it collects every matching entry from the entire walk and returns them as an array in registration order. For regular providers, it returns the first match on the way up. That is why a component-level provider shadows the root one for that component's subtree, but sibling components still see the root instance.

Injection decorators: @Self, @SkipSelf, @Optional

These three decorators tell Angular where to look.

@Self() restricts the search to the current injector only. If the token is not there, Angular throws immediately. Use it when a service only makes sense at the component level, like a form controller that must be declared on the same element.

@SkipSelf() does the opposite: it skips the current injector and starts the search from the parent. This lets child components access a parent's service while providing a different implementation to their own children.

@Optional() returns null instead of throwing when nothing is found. It pairs well with analytics services or feature flags that are not always registered.

typescript
@Component({ providers: [FormService] }) export class FormComponent { // Throws if FormService is not in THIS component's injector constructor(@Self() private form: FormService) {} } @Component({}) export class ChildComponent { // Uses the parent's LoggerService, ignores any local override constructor(@SkipSelf() private logger: LoggerService) {} }

Abstract class as token

TypeScript interfaces disappear at compile time, so they can't be DI tokens. Abstract classes survive compilation and produce real JavaScript constructor functions, which Angular can use as provider map keys.

typescript
abstract class StorageService { abstract get(key: string): string | null; abstract set(key: string, value: string): void; } @Injectable() class LocalStorageService extends StorageService { get(key: string) { return localStorage.getItem(key); } set(key: string, value: string) { localStorage.setItem(key, value); } } // In providers: { provide: StorageService, useClass: LocalStorageService } // In any consumer: constructor(private storage: StorageService) { this.storage.get('token'); // actual impl: LocalStorageService }

Swap LocalStorageService for SessionStorageService in one place. Nothing else changes.

Common mistakes

Forgetting multi: true on HTTP interceptors

This is the most frequent DI bug I see in Angular codebases. Works fine with one interceptor, breaks silently when a second developer adds another.

typescript
// Wrong: second provider replaces the first providers: [ { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor }, { provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor }, // replaces Auth ] // Correct providers: [ { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true }, ]

Calling inject() outside an injection context

typescript
@Injectable() class BadService { private dep = inject(OtherService); // OK - field initializer runs during creation someMethod() { const dep = inject(OtherService); // Throws: inject() only works during construction } }

inject() works in field initializers, constructors, and useFactory functions. Anywhere else it throws at runtime.

Circular dependencies in factories

typescript
{ provide: A, useFactory: () => new A(inject(B)) } { provide: B, useFactory: () => new B(inject(A)) } // A needs B, B needs A

Angular detects cycles in dev mode and throws. In some prod builds it crashes without a clear message. Break the cycle by injecting Injector and resolving lazily, or restructure the dependency graph.

Expecting lazy-module providers to be visible in sibling routes

A lazy-loaded module creates its own child injector. Providers declared there are not visible to other routes. If you need a service shared across lazy routes, register it in the root injector with providedIn: 'root'.

Real-world usage

  • Angular HttpClient uses HTTP_INTERCEPTORS as a multi-provider token out of the box
  • NGRX and NGXS register store plugins via custom multi-provider tokens
  • Nx monorepos use useFactory with PLATFORM_ID to provide env-specific config in SSR apps
  • APP_CONFIG injection tokens are standard in standalone Angular apps for feature flags and API URLs

Follow-up questions

Q: What is the difference between useClass and useFactory?
A: useClass calls new on the class and lets Angular handle constructor injection automatically. useFactory runs a plain function at resolution time, which lets you write conditional logic and call inject() for additional dependencies inside the function.

Q: How does multi: true interact with injector hierarchy?
A: Each injector in the tree collects its own multi-provider entries. Child injectors add from their own registrations first, then from parents. The final array order matches registration order within each level.

Q: When does Angular create a new service instance vs reuse one?
A: Angular creates one instance per injector for any given token. providedIn: 'root' means one instance in the root injector, shared everywhere. A component-level provider means a new instance per component instance, destroyed when that component is destroyed.

Q: Why can't you use a TypeScript interface as a DI token?
A: Interfaces are erased at compile time. At runtime there is no object Angular can use as a key in its provider map. Abstract classes survive compilation because they produce real JavaScript constructor functions.

Q (senior): In a lazy-loaded route where a providedIn: 'root' service is overridden via route-level providers, what does a sibling route get when it injects that service?
A: The root instance. The override only applies to the lazy route's injector subtree. Sibling routes share the parent injector and never see the override.

Examples

Environment-based logger with useFactory

typescript
import { InjectionToken, PLATFORM_ID, inject } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; export const LOGGER = new InjectionToken<Logger>('LOGGER'); // In providers: { provide: LOGGER, useFactory: () => { const platformId = inject(PLATFORM_ID); return isPlatformBrowser(platformId) ? new ConsoleLogger() // Browser: logs to console : new NoopLogger(); // SSR: no output, avoids hydration issues } } // Usage in any service @Injectable() export class ApiService { private logger = inject(LOGGER); fetchData() { this.logger.log('Fetching data'); // Console in browser, silent in SSR } }

The factory runs once per injector at creation time and picks the right implementation based on the runtime environment. No if statements scattered through consumer code.

APP_CONFIG token for feature flags

typescript
export interface AppConfig { apiUrl: string; debug: boolean; } export const APP_CONFIG = new InjectionToken<AppConfig>('app.config'); // main.ts bootstrapApplication(AppComponent, { providers: [ { provide: APP_CONFIG, useValue: { apiUrl: environment.apiUrl, debug: !environment.production, } } ] }); // Any service or component @Injectable({ providedIn: 'root' }) export class ApiService { private config = inject(APP_CONFIG); getUrl(path: string) { if (this.config.debug) console.log(`Calling ${this.config.apiUrl}/${path}`); return `${this.config.apiUrl}/${path}`; } }

InjectionToken with useValue is the standard way to pass static config through the DI system without creating a service class for it.

Hierarchical override in a lazy-loaded feature

typescript
// Root provides a global ApiService @Injectable({ providedIn: 'root' }) export class ApiService { baseUrl = '/api'; } // Feature service extends root behavior @Injectable() export class FeatureApiService extends ApiService { baseUrl = '/api/feature'; } // Lazy route overrides ApiService only within its injector subtree const routes: Routes = [ { path: 'feature', loadComponent: () => import('./feature.component').then(m => m.FeatureComponent), providers: [{ provide: ApiService, useClass: FeatureApiService }] } ]; // FeatureComponent and its children get FeatureApiService // All other routes still get the root ApiService

This is the pattern for multi-tenant dashboards or white-label apps where different sections need different API behavior but share the same consumer code.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?