โ† Back to blog
Angular Published on 2025-12-24 ยท 15 min read

Angular Dependency Injection: Advanced Guide

Master Angular's DI system with injector hierarchy, resolution modifiers, and production patterns

Angular Dependency Injection: Advanced Guide

Angular's dependency injection system is far more than a simple service container. It's a sophisticated hierarchical system that controls instance lifecycle, scope, and resolution. This guide explores the internal mechanisms and advanced patterns you'll encounter in production applications.

Injector Hierarchy: Understanding Resolution

Angular maintains an injector tree parallel to the component tree. When you request a dependency, Angular walks up this tree until it finds a provider. This hierarchy is key to understanding where your services are instantiated and how many instances exist.

typescript
// Injector hierarchy visualization
//
// Null Injector (throws NullInjectorError)
//       โ†‘
// Platform Injector (platformBrowserDynamic)
//       โ†‘
// Root Injector (providedIn: 'root' services live here)
//       โ†‘
// Route Injector (lazy-loaded modules create their own)
//       โ†‘
// Element Injector (component providers: [...])
//       โ†‘
// Child Element Injector (child components)

// Example: Understanding where instances are created
@Injectable({ providedIn: 'root' })
export class AuthService {
  // Single instance in Root Injector
  // Shared across entire application
}

@Component({
  providers: [FormStateService]  // Element Injector
})
export class UserFormComponent {
  // Each UserFormComponent gets its own FormStateService
  private formState = inject(FormStateService);
}

The lookup order is: Element Injector โ†’ Parent Element Injectors โ†’ Environment Injector (Module/Route) โ†’ Root โ†’ Platform โ†’ Null Injector. If the Null Injector is reached, Angular throws an error (unless the dependency is optional).

providedIn: Beyond 'root'

providedIn: 'root' is the default choice, but other options provide finer control over tree-shaking and scope:

typescript
// 'root' - Application-wide singleton (most common)
@Injectable({ providedIn: 'root' })
export class AuthService {}

// 'platform' - Shared across multiple Angular apps on same page
@Injectable({ providedIn: 'platform' })
export class SharedAnalyticsService {}

// 'any' - One instance per lazy-loaded module boundary
@Injectable({ providedIn: 'any' })
export class ModuleScopedCache {}

// Factory with dependencies - tree-shakeable defaults
export const API_CONFIG = new InjectionToken<ApiConfig>('API_CONFIG', {
  providedIn: 'root',
  factory: () => ({
    baseUrl: inject(ENVIRONMENT).apiUrl,
    timeout: 30000,
    retries: 3
  })
});

// Route-level providers (best for lazy-loaded features)
export const FEATURE_ROUTES: Routes = [{
  path: 'admin',
  loadComponent: () => import('./admin.component'),
  providers: [
    AdminService,  // Only loaded when route is accessed
    { provide: FEATURE_FLAG, useValue: 'admin-v2' }
  ]
}];

Why it matters: With providedIn: 'root', the service is included in the main bundle even if only a lazy-loaded route uses it. Using route-level providers allows isolating code into appropriate chunks.

Resolution Modifiers

Angular provides several options to control how and where dependencies are resolved. Understanding these modifiers is essential for complex architectures.

Optional Dependencies

By default, a missing dependency causes an error. The optional: true option returns null if the provider doesn't exist:

typescript
@Component({...})
export class FeatureComponent {
  // Returns null if not provided, instead of throwing
  private analytics = inject(AnalyticsService, { optional: true });

  trackEvent(name: string) {
    this.analytics?.track(name);
  }
}

// Common pattern: feature flags with defaults
@Component({...})
export class DashboardComponent {
  private featureFlags = inject(FEATURE_FLAGS, { optional: true }) ?? {};

  get showNewChart(): boolean {
    return this.featureFlags['dashboard-v2'] ?? false;
  }
}

self and skipSelf: Controlling Lookup Scope

These modifiers limit where Angular looks for the provider:

typescript
// self: true - Only look in current element's injector
@Directive({ selector: '[validateOnBlur]' })
export class ValidateOnBlurDirective {
  // Must be provided on this exact element, won't search parents
  private ngControl = inject(NgControl, { self: true });
}

// skipSelf: true - Skip current injector, start from parent
@Component({
  selector: 'nested-menu',
  providers: [MenuService]  // New instance for this subtree
})
export class NestedMenuComponent {
  // Gets parent's MenuService, not our own
  private parentMenu = inject(MenuService, { skipSelf: true, optional: true });
  // Gets our own MenuService
  private selfMenu = inject(MenuService);

  constructor() {
    this.parentMenu?.registerChild(this.selfMenu);
  }
}

Typical use case: A form component that wants to register with a parent form while having its own FormGroupDirective instance.

host: Stop at Host Component Boundary

host: true limits the lookup to the host component's injector. Useful for directives communicating with their container component:

typescript
@Directive({ selector: '[tooltip]' })
export class TooltipDirective {
  // Only finds TooltipConfig if provided by host component
  private config = inject(TooltipConfig, { host: true, optional: true });
}

@Component({
  selector: 'data-table',
  providers: [{ provide: TABLE_CONFIG, useValue: { striped: true } }],
  template: `<ng-content></ng-content>`
})
export class DataTableComponent {}

@Component({ selector: 'table-row', template: `...` })
export class TableRowComponent {
  // Gets config from DataTable host
  private config = inject(TABLE_CONFIG, { host: true });
}

InjectionToken: Injecting Non-Class Values

Classes serve as their own injection token. For primitive values or configuration objects, use InjectionToken:

typescript
export const API_URL = new InjectionToken<string>('API_URL');

export interface AppConfig {
  apiUrl: string;
  features: Record<string, boolean>;
  environment: 'dev' | 'staging' | 'prod';
}

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

// Token with tree-shakeable factory default
export const LOGGER = new InjectionToken<Logger>('LOGGER', {
  providedIn: 'root',
  factory: () => {
    const config = inject(APP_CONFIG, { optional: true });
    return config?.environment === 'prod'
      ? new ProductionLogger()
      : new ConsoleLogger();
  }
});

// Providing tokens
export const appConfig: ApplicationConfig = {
  providers: [
    { provide: APP_CONFIG, useValue: environment },
    { provide: API_URL, useFactory: () => inject(APP_CONFIG).apiUrl }
  ]
};

Multi-Providers: Collecting Multiple Implementations

The multi: true flag allows multiple providers to contribute to the same token:

typescript
export const CUSTOM_VALIDATORS = new InjectionToken<Validator[]>('CUSTOM_VALIDATORS');

export const appConfig: ApplicationConfig = {
  providers: [
    { provide: CUSTOM_VALIDATORS, useClass: RequiredFieldsValidator, multi: true },
    { provide: CUSTOM_VALIDATORS, useClass: EmailFormatValidator, multi: true },
  ]
};

@Injectable({ providedIn: 'root' })
export class ValidationService {
  private validators = inject(CUSTOM_VALIDATORS);  // Validator[]

  validate(form: FormGroup): ValidationError[] {
    return this.validators.flatMap(v => v.validate(form));
  }
}

Component Provider Strategies

Declaring providers at the component level creates a new instance per component:

typescript
@Component({
  selector: 'product-editor',
  providers: [ProductEditorState],  // New instance per component
  template: `
    <h2>{{ state.product()?.name }}</h2>
    <product-form />
    <product-preview />
  `
})
export class ProductEditorComponent {
  state = inject(ProductEditorState);
}

// Child components share parent's instance
@Component({ selector: 'product-form', template: `<form>...</form>` })
export class ProductFormComponent {
  private state = inject(ProductEditorState);  // Same instance as parent
}

// viewProviders: Only visible to view, not content children
@Component({
  selector: 'modal',
  viewProviders: [ModalContext],  // Not visible to <ng-content>
  template: `<div class="modal"><ng-content /></div>`
})
export class ModalComponent {}

inject() vs Constructor Injection

The inject() function (Angular 14+) offers significant advantages:

typescript
// Constructor injection - verbose
@Component({...})
export class OldStyleComponent {
  constructor(
    private userService: UserService,
    @Optional() private analytics: AnalyticsService | null,
    @Inject(APP_CONFIG) private config: AppConfig
  ) {}
}

// inject() - cleaner, more flexible
@Component({...})
export class ModernComponent {
  private userService = inject(UserService);
  private analytics = inject(AnalyticsService, { optional: true });
  private config = inject(APP_CONFIG);
  private destroyRef = inject(DestroyRef);

  constructor() {
    this.destroyRef.onDestroy(() => this.cleanup());
  }
}

// Extracting reusable injection logic
function injectRouteParam(name: string): Signal<string | null> {
  const route = inject(ActivatedRoute);
  return toSignal(route.paramMap.pipe(map(p => p.get(name))));
}

@Component({...})
export class ProductComponent {
  private productId = injectRouteParam('id');
}

Testing Patterns

Dependency injection greatly facilitates testing:

typescript
describe('UserComponent', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: UserService,
          useValue: { getUser: vi.fn().mockReturnValue(of({ name: 'Test' })) }
        }
      ]
    });
  });

  it('should display user name', () => {
    const fixture = TestBed.createComponent(UserComponent);
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('Test');
  });
});

describe('AuthService Integration', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()]
    });
  });

  it('should authenticate user', () => {
    const service = TestBed.inject(AuthService);
    const httpTesting = TestBed.inject(HttpTestingController);

    service.login('user', 'pass').subscribe(r => expect(r.token).toBeDefined());
    httpTesting.expectOne('/api/auth/login').flush({ token: 'abc123' });
  });
});

Best Practices

  • Use inject() over constructor injection
  • Prefer providedIn: 'root' for stateless shared services
  • Use component providers for instance-specific state
  • InjectionToken with factory enables optimal tree-shaking
  • Avoid circular dependencies โ€” forwardRef() is a workaround
angulardependency-injectionservicesarchitecture

Made with Angular & PrimeNG

ยฉ 2025 Java Angular Blog