Advanced Angular Dependency Injection in 2026: Providers, Tokens and Interview Questions

Master Angular dependency injection with InjectionToken, hierarchical injectors, resolution modifiers, and multi-providers. Includes interview questions and production patterns.

Angular dependency injection providers tokens hierarchical injector tree visualization

Angular's dependency injection system provides fine-grained control over service instantiation through providers, tokens, and hierarchical injectors. Mastering these concepts separates experienced Angular developers from juniors in technical interviews and production codebases.

Key Concept

Angular maintains two parallel injector trees: the ModuleInjector tree for services provided at module level, and the ElementInjector tree for component-scoped dependencies. Resolution starts at the element level and bubbles up to the root.

Provider Configuration Strategies in Angular DI

Angular offers multiple provider configuration strategies, each suited to different use cases. The most common configurations use useClass, useValue, useFactory, and useExisting.

app.config.tstypescript
import { ApplicationConfig, InjectionToken } from '@angular/core';
import { LoggerService } from './services/logger.service';
import { DebugLoggerService } from './services/debug-logger.service';
import { API_CONFIG, ApiConfig } from './config/api.config';

export const appConfig: ApplicationConfig = {
  providers: [
    // useClass: provide a different implementation
    { provide: LoggerService, useClass: DebugLoggerService },
    
    // useValue: provide a static configuration object
    { 
      provide: API_CONFIG, 
      useValue: { baseUrl: 'https://api.example.com', timeout: 5000 } 
    },
    
    // useFactory: create dependency with runtime logic
    {
      provide: 'FEATURE_FLAGS',
      useFactory: () => {
        const env = import.meta.env.MODE;
        return { debugMode: env === 'development', analytics: env === 'production' };
      }
    },
    
    // useExisting: create an alias to another provider
    { provide: 'Logger', useExisting: LoggerService }
  ]
};

The useClass strategy swaps implementations without changing consumer code. The useValue strategy provides static objects like configuration. The useFactory strategy handles runtime decisions, and useExisting creates aliases for polymorphic access.

InjectionToken for Type-Safe Non-Class Dependencies

While @Injectable works for class-based services, non-class values like configuration objects, primitives, or functions require InjectionToken. This token acts as a unique key in Angular's DI registry.

tokens/config.tokens.tstypescript
import { InjectionToken } from '@angular/core';

export interface ApiConfig {
  baseUrl: string;
  timeout: number;
  retryAttempts: number;
}

// Generic parameter ensures type safety at injection point
export const API_CONFIG = new InjectionToken<ApiConfig>('api.config', {
  providedIn: 'root',
  factory: () => ({
    baseUrl: 'https://api.sharpskill.dev',
    timeout: 30000,
    retryAttempts: 3
  })
});

// Token for primitive values
export const MAX_UPLOAD_SIZE = new InjectionToken<number>('max.upload.size', {
  providedIn: 'root',
  factory: () => 10 * 1024 * 1024 // 10MB
});

The generic type parameter on InjectionToken<ApiConfig> propagates to the inject() call. TypeScript knows the injected value matches the declared type, catching misuse at compile time rather than runtime.

The inject() Function vs Constructor Injection

Angular 14 introduced the inject() function as an alternative to constructor-based injection. In Angular 20+, inject() has become the preferred approach, especially in standalone components and functional contexts.

user.service.tstypescript
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { API_CONFIG } from '../tokens/config.tokens';

@Injectable({ providedIn: 'root' })
export class UserService {
  // Modern approach: inject() at field level
  private readonly http = inject(HttpClient);
  private readonly config = inject(API_CONFIG);

  getUser(id: string) {
    return this.http.get(`${this.config.baseUrl}/users/${id}`);
  }
}

// Alternative: constructor injection (still valid)
@Injectable({ providedIn: 'root' })
export class UserServiceLegacy {
  constructor(
    private readonly http: HttpClient,
    @Inject(API_CONFIG) private readonly config: ApiConfig
  ) {}
}

The inject() function eliminates decorator boilerplate for tokens and enables dependency injection in non-class contexts like functional guards, resolvers, and interceptors.

auth.guard.tstypescript
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';

// Functional guard using inject()
export const authGuard: CanActivateFn = () => {
  const authService = inject(AuthService);
  const router = inject(Router);

  if (authService.isAuthenticated()) {
    return true;
  }
  return router.createUrlTree(['/login']);
};

The inject() function only works within an injection context: during class construction, in factory functions, or in functional Angular constructs. Calling it outside these contexts throws a runtime error.

Ready to ace your Angular interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Hierarchical Injectors: ElementInjector vs EnvironmentInjector

Angular maintains two parallel injector hierarchies that determine service scope and resolution order. Understanding this architecture is essential for controlling service lifetime and visibility.

data.service.tstypescript
import { Injectable } from '@angular/core';

// Root-level singleton: single instance across entire app
@Injectable({ providedIn: 'root' })
export class GlobalDataService {
  private data = new Map<string, unknown>();
  
  set(key: string, value: unknown) { this.data.set(key, value); }
  get(key: string) { return this.data.get(key); }
}

// Component-scoped: new instance per component
@Injectable()
export class ComponentDataService {
  private data = new Map<string, unknown>();
  
  set(key: string, value: unknown) { this.data.set(key, value); }
  get(key: string) { return this.data.get(key); }
}
dashboard.component.tstypescript
import { Component } from '@angular/core';
import { ComponentDataService } from './services/component-data.service';

@Component({
  selector: 'app-dashboard',
  standalone: true,
  // This component and all children get the same instance
  providers: [ComponentDataService],
  template: `
    <app-widget />
    <app-stats />
  `
})
export class DashboardComponent {}

When a component declares a provider, Angular creates a new instance scoped to that component's ElementInjector. Child components inherit access to the parent's providers unless they declare their own.

The resolution algorithm follows this path:

  1. Check the requesting component's ElementInjector
  2. Walk up the ElementInjector tree to ancestors
  3. Check the EnvironmentInjector (module or standalone providers)
  4. Walk up to the root EnvironmentInjector
  5. Reach NullInjector and throw an error if @Optional() was not used

Resolution Modifiers: @Self, @SkipSelf, @Optional, @Host

Resolution modifiers alter how Angular searches the injector hierarchy. These decorators work with both constructor injection and the inject() function.

panel.component.tstypescript
import { Component, Optional, SkipSelf, Self, inject } from '@angular/core';
import { PanelService } from './panel.service';

@Component({
  selector: 'app-panel',
  standalone: true,
  providers: [PanelService],
  template: `<ng-content />`
})
export class PanelComponent {
  // @Self: only look in this component's injector, fail otherwise
  private readonly localService = inject(PanelService, { self: true });
  
  // @SkipSelf: skip this component, start search from parent
  private readonly parentService = inject(PanelService, { 
    skipSelf: true, 
    optional: true 
  });
  
  // @Optional: return null instead of throwing if not found
  private readonly optionalService = inject(PanelService, { optional: true });

  constructor() {
    // localService is always the component's own instance
    // parentService is the parent's instance or null
    console.log('Local:', this.localService);
    console.log('Parent:', this.parentService);
  }
}

The @Host() modifier restricts resolution to the host element's injector and stops at component boundaries. This is useful when a directive needs to access a service provided by its host component but should not reach higher in the tree.

highlight.directive.tstypescript
import { Directive, inject, Host, Optional } from '@angular/core';
import { HighlightConfig } from './highlight.config';

@Directive({
  selector: '[appHighlight]',
  standalone: true
})
export class HighlightDirective {
  // Only look at the host component's providers
  private readonly config = inject(HighlightConfig, { 
    host: true, 
    optional: true 
  });

  constructor() {
    // config is null if host component didn't provide HighlightConfig
    const color = this.config?.color ?? 'yellow';
    // Apply highlighting...
  }
}

Multi-Providers for Extensible Systems

Multi-providers allow multiple values to be registered under a single token. Angular returns all registered values as an array, enabling plugin architectures and extensibility patterns.

validators.tokens.tstypescript
import { InjectionToken } from '@angular/core';

export interface Validator {
  validate(value: string): string | null;
}

export const VALIDATORS = new InjectionToken<Validator[]>('validators');
app.config.tstypescript
import { ApplicationConfig } from '@angular/core';
import { VALIDATORS } from './validators.tokens';

const requiredValidator = {
  validate: (value: string) => value ? null : 'Field is required'
};

const minLengthValidator = {
  validate: (value: string) => value.length >= 3 ? null : 'Minimum 3 characters'
};

const emailValidator = {
  validate: (value: string) => 
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : 'Invalid email format'
};

export const appConfig: ApplicationConfig = {
  providers: [
    { provide: VALIDATORS, useValue: requiredValidator, multi: true },
    { provide: VALIDATORS, useValue: minLengthValidator, multi: true },
    { provide: VALIDATORS, useValue: emailValidator, multi: true }
  ]
};
validation.service.tstypescript
import { Injectable, inject } from '@angular/core';
import { VALIDATORS, Validator } from './validators.tokens';

@Injectable({ providedIn: 'root' })
export class ValidationService {
  private readonly validators = inject(VALIDATORS);

  validate(value: string): string[] {
    // validators is an array of all registered validators
    return this.validators
      .map(v => v.validate(value))
      .filter((error): error is string => error !== null);
  }
}

The multi: true flag tells Angular to collect all providers for this token into an array. Without it, later providers would override earlier ones.

Interview Questions on Angular Dependency Injection

Technical interviews frequently probe understanding of Angular's DI system. Here are questions that distinguish candidates with production experience.

Q: What happens when you provide the same service at both module and component level?

The component-level provider creates a separate instance scoped to that component's subtree. Services injected in that subtree receive the component's instance, not the module-level singleton. This enables state isolation, for example when each tab needs its own form state.

Q: Why use InjectionToken instead of a string literal?

String tokens risk collisions across libraries or different parts of an application. InjectionToken creates a unique runtime reference that cannot conflict. The generic type parameter also provides compile-time type safety that string tokens lack.

Q: When does inject() throw vs return undefined?

By default, inject() throws when the dependency is not found. Passing { optional: true } changes the return type to T | null and returns null instead of throwing. This matches the behavior of the @Optional() decorator.

Q: Explain the difference between providedIn: 'root' and providing in a module's providers array.

Both create singletons, but providedIn: 'root' enables tree-shaking. The service is only included in the bundle if actually injected somewhere. Module-level providers are always included regardless of usage.

Q: How does lazy loading affect service scope?

Lazy-loaded modules get their own child EnvironmentInjector. Services provided in a lazy module are scoped to that module and its children. A service with providedIn: 'root' remains a true singleton across all modules, lazy or not.

For practice questions on Angular services and DI patterns, check Angular interview questions on services and dependency injection.

Practical Patterns: Configuration and Feature Flags

Real applications combine these DI concepts for configuration management. This pattern uses InjectionToken, factory providers, and environment awareness.

feature-flags.config.tstypescript
import { InjectionToken, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

export interface FeatureFlags {
  newCheckout: boolean;
  darkMode: boolean;
  betaFeatures: boolean;
}

export const FEATURE_FLAGS = new InjectionToken<FeatureFlags>('feature.flags', {
  providedIn: 'root',
  factory: () => {
    const platformId = inject(PLATFORM_ID);
    
    if (!isPlatformBrowser(platformId)) {
      // SSR: return safe defaults
      return { newCheckout: false, darkMode: false, betaFeatures: false };
    }

    // Browser: check localStorage or remote config
    const stored = localStorage.getItem('featureFlags');
    if (stored) {
      return JSON.parse(stored);
    }
    
    return { newCheckout: true, darkMode: false, betaFeatures: false };
  }
});
feature-flag.directive.tstypescript
import { Directive, Input, TemplateRef, ViewContainerRef, inject } from '@angular/core';
import { FEATURE_FLAGS } from './feature-flags.config';

@Directive({
  selector: '[appFeatureFlag]',
  standalone: true
})
export class FeatureFlagDirective {
  private readonly flags = inject(FEATURE_FLAGS);
  private readonly templateRef = inject(TemplateRef<unknown>);
  private readonly viewContainer = inject(ViewContainerRef);

  @Input() set appFeatureFlag(flag: keyof typeof this.flags) {
    if (this.flags[flag]) {
      this.viewContainer.createEmbeddedView(this.templateRef);
    } else {
      this.viewContainer.clear();
    }
  }
}

This directive conditionally renders content based on feature flags, with the flag configuration centralized in one injectable token.

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Production-Ready Angular DI Practices

  • Use providedIn: 'root' for application-wide singletons that benefit from tree-shaking
  • Prefer inject() over constructor injection in Angular 20+ for cleaner syntax and functional compatibility
  • Scope stateful services to components when isolation is needed, not at module level
  • Create InjectionToken for non-class dependencies to ensure type safety and avoid collisions
  • Apply @Optional() when a dependency might not exist, especially for plugins or optional features
  • Test components with overridden providers using TestBed.overrideComponent() for isolation
  • Use multi-providers for extensibility patterns like validators, interceptors, and handlers
Daily challenge

Can you spot the bug in Angular?

One real snippet, one hidden bug, one attempt a day. No account needed to try.

Anthony Fillion-Maillet

Written by

Anthony Fillion-Maillet

Founder of SharpSkill

Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.

Updated on September 13, 2026

Tags

#angular
#dependency-injection
#typescript
#interview

Share

Related articles