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.
// 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'
// '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'
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: truenull
@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:
// 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
host: Stop at Host Component Boundary
host: true
@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
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
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:
@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()
// 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:
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
over constructor injectioninject() - Prefer
for stateless shared servicesprovidedIn: 'root' - Use component providers for instance-specific state
with factory enables optimal tree-shakingInjectionToken- Avoid circular dependencies โ
is a workaroundforwardRef()