Advanced Dependency Injection
In Services & Dependency Injection you injected root services, gave a component its own instance and provided a simple value with an InjectionToken. That covers most small apps. Large company codebases go further: libraries ship provideSomething() functions, plug-ins register themselves through multi providers, and components look up their parent through the injector tree.
In this lesson you will learn how Angular searches for a dependency, how to change that search, and how to package your own feature as a clean provideX() function, the same way provideRouter() and provideHttpClient() work.
The injector tree
An injector is an object that creates and stores dependencies. An Angular app has two kinds, arranged in a tree:
- Environment injectors hold app-wide services. The root environment injector is created by
bootstrapApplication()from theprovidersinapp.config.tsand from every@Service()class. Lazy-loaded routes with their ownprovidersget a child environment injector. - Element injectors belong to components and directives. Each one is empty unless the component or directive has
providersorviewProviders.
NullInjector (always throws "No provider found")
Platform injector
Root environment injector (app.config.ts providers, @Service() classes)
AppShell element injector
LeaveList element injector
LeaveRow element injector <- inject() starts hereWhen a component calls inject(Something), Angular first walks up the element injectors, from the component towards the root component. If none of them has a provider, it continues in the environment injectors. The first provider it finds wins. If it reaches the top without success, you get the familiar error:
NG0201: No provider found for `InjectionToken ANALYTICS_OPTIONS`. Find more at https://v22.angular.dev/errors/NG0201
Knowing this order explains many confusing bugs. For example, if a component lists a root service in its providers, everything below that component gets a new instance, because the search stops at the first match.
Changing the search with resolution modifiers
inject() takes an options object with four resolution modifiers:
| Option | Effect |
|---|---|
optional: true | Return null instead of throwing when nothing is found |
self: true | Look only in the current element's injector |
skipSelf: true | Start the search at the parent, ignoring the current element |
host: true | Stop the search at the host component |
Components and directives are themselves available through the element injector, so a child can find its parent component. Here a form section shows its full path, such as "Address > Billing", by asking for the parent section:
import { Component, Signal, computed, inject, input } from '@angular/core';
@Component({
selector: 'app-form-section',
template: `<fieldset><legend>{{ path() }}</legend><ng-content /></fieldset>`,
})
export class FormSection {
readonly title = input.required<string>();
private parent = inject(FormSection, { skipSelf: true, optional: true });
readonly path: Signal<string> = computed(() =>
this.parent ? `${this.parent.path()} > ${this.title()}` : this.title(),
);
}
@Component({
selector: 'app-help-text',
template: `<small>{{ section ? 'Inside ' + section.path() : 'Not inside a section' }}</small>`,
})
export class HelpText {
protected section = inject(FormSection, { optional: true });
}Without skipSelf, a section would find itself. Without optional, the outermost section would throw because it has no parent. Used in a template like this:
<app-form-section title="Address">
<app-help-text />
<app-form-section title="Billing">
<app-help-text />
</app-form-section>
</app-form-section>
<app-help-text />Legends: Address | Address > Billing Help texts: Inside Address | Inside Address > Billing | Not inside a section
This parent lookup pattern is how Angular's own form directives, tab groups and accordions talk to their children without inputs. self and skipSelf cannot be combined, and neither can host and self.
Multi providers: many values for one token
Normally a second provider for the same token replaces the first. With multi: true, Angular collects all of them into an array. This is how plug-in systems work: each feature adds its own item, and the code that uses the token never needs a list of features.
import { InjectionToken } from '@angular/core';
export interface PaymentMethod {
id: string;
label: string;
fee(amount: number): number;
}
export const PAYMENT_METHODS = new InjectionToken<PaymentMethod[]>('PAYMENT_METHODS');
export const upi: PaymentMethod = { id: 'upi', label: 'UPI', fee: () => 0 };
export const card: PaymentMethod = { id: 'card', label: 'Debit/Credit card', fee: (a) => Math.round(a * 0.02) };
export const netBanking: PaymentMethod = { id: 'nb', label: 'Net banking', fee: () => 10 };
// app.config.ts
providers: [
{ provide: PAYMENT_METHODS, useValue: upi, multi: true },
{ provide: PAYMENT_METHODS, useValue: card, multi: true },
{ provide: PAYMENT_METHODS, useValue: netBanking, multi: true },
],A checkout component injects the whole list and shows the fee for a ₹1,500 order:
protected methods = inject(PAYMENT_METHODS);
// this.methods.map((m) => `${m.label}: fee ₹${m.fee(1500)}`)UPI: fee ₹0 | Debit/Credit card: fee ₹30 | Net banking: fee ₹10
Removing net banking later means deleting one provider line; the checkout code does not change. Angular uses the same idea internally for interceptors and app initialisers.
Tokens with a default value
An InjectionToken can carry its own factory, so it works even when nobody provides it. Apps or tests can still override it:
export interface AppSettings {
apiUrl: string;
currency: string;
}
export const APP_SETTINGS = new InjectionToken<AppSettings>('APP_SETTINGS', {
providedIn: 'root',
factory: () => ({ apiUrl: '/api', currency: 'INR' }),
});
// Override in one app or test:
{ provide: APP_SETTINGS, useValue: { apiUrl: 'https://api.example.in', currency: 'INR' } }Without any provider, inject(APP_SETTINGS) returns { apiUrl: '/api', currency: 'INR' }; with the override, apiUrl is https://api.example.in. Factories may call inject() themselves, so a token can be built from other services: factory: () => inject(AppSettings).apiUrl + '/v2'. The same works in a provider with useFactory.
Step by step: write your own provideX() function
Libraries in a workspace should not ask every app to copy five provider lines. They export one function. Follow these steps to build provideAnalytics() for an HR app called LeaveDesk.
- Create a token for the options the app passes in:
import { EnvironmentProviders, InjectionToken, Service, inject, makeEnvironmentProviders, provideAppInitializer } from '@angular/core';
export interface AnalyticsOptions {
appName: string;
enabled: boolean;
}
export const ANALYTICS_OPTIONS = new InjectionToken<AnalyticsOptions>('ANALYTICS_OPTIONS');- Write the service. It reads the options with
inject(), so it does not care where they came from:
@Service()
export class Analytics {
private options = inject(ANALYTICS_OPTIONS);
readonly events: string[] = [];
track(name: string) {
if (this.options.enabled) {
this.events.push(`[${this.options.appName}] ${name}`);
}
}
}- Bundle everything in a function that returns
EnvironmentProviders.makeEnvironmentProviders()marks the result as "app level only", so nobody can put it in a component'sprovidersby mistake.provideAppInitializer()runs a function while the app starts:
export function provideAnalytics(options: AnalyticsOptions): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: ANALYTICS_OPTIONS, useValue: options },
provideAppInitializer(() => {
inject(Analytics).track('app_start');
}),
]);
}- Use it in
app.config.tswith one readable line:
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes), provideAnalytics({ appName: 'LeaveDesk', enabled: true })],
};- Check the result. After start-up and one
track('login')call,eventsholds:
["[LeaveDesk] app_start","[LeaveDesk] login"]
If an initializer returns a promise or observable, Angular waits for it before showing the app. Keep initializers fast: a slow one delays the first screen for every user.
Two more tools you will meet
viewProvidersin@Component()works likeproviders, but the service is visible only to the component's own template, not to content projected into it with<ng-content>. Library authors use it to keep internal helpers private.runInInjectionContext(injector, fn)runs a function as if it were in a constructor, soinject()works inside it. You get an injector withinject(Injector)orinject(EnvironmentInjector). Use it sparingly; most code can simply inject in a field.
Common mistakes
- Forgetting
optional: truewhen a parent may not exist, so the top-level component throws NG0201. - Forgetting
multi: trueon one provider. That provider then replaces the whole list instead of adding to it. - Putting environment providers in a component.
provideHttpClient()and your ownprovideX()belong inapp.config.tsor routeproviders, not in@Component({ providers }). - Doing heavy work in an app initializer, which blocks the first render.
Key points
- Angular searches element injectors first (component tree), then environment injectors, and uses the first provider it finds.
inject(Token, { optional, self, skipSelf, host })changes where and how that search happens.multi: truecollects many providers for one token into an array, the basis of plug-in style features.- An
InjectionTokencan have a defaultfactorywithprovidedIn: 'root'. - Wrap a feature's providers in a
provideX()function built withmakeEnvironmentProviders(), and run start-up logic withprovideAppInitializer().
Exercise
A company portal shows widgets on its home page, and each team adds its own. Create a DASHBOARD_WIDGETS multi token whose items have a title and an order number. Register three widgets ("Leave balance" order 2, "Payslips" order 3, "Announcements" order 1) and write a Dashboard component that lists the titles sorted by order. Then write a provideDashboardWidget(title, order) helper that returns the provider object, so teams do not write multi: true themselves.
Show solution
import { Component, InjectionToken, Provider, computed, inject } from '@angular/core';
export interface DashboardWidget {
title: string;
order: number;
}
export const DASHBOARD_WIDGETS = new InjectionToken<DashboardWidget[]>('DASHBOARD_WIDGETS');
export function provideDashboardWidget(title: string, order: number): Provider {
return { provide: DASHBOARD_WIDGETS, useValue: { title, order }, multi: true };
}
@Component({
selector: 'app-dashboard',
template: `
<ol>
@for (w of widgets(); track w.title) {
<li>{{ w.title }}</li>
}
</ol>
`,
})
export class Dashboard {
private all = inject(DASHBOARD_WIDGETS);
protected widgets = computed(() => [...this.all].sort((a, b) => a.order - b.order));
}
// app.config.ts
providers: [
provideDashboardWidget('Leave balance', 2),
provideDashboardWidget('Payslips', 3),
provideDashboardWidget('Announcements', 1),
],The list renders "Announcements", "Leave balance", "Payslips". The helper hides multi: true, so no team can accidentally replace the other widgets.
Quick Quiz
1. Where does Angular look first when a component calls inject(SomeService)?
Element injectors are searched first; environment injectors are the fallback.
2. What does inject(FormSection, { skipSelf: true, optional: true }) return in the outermost section?
skipSelf ignores the current element and optional turns "not found" into null.
3. What does multi: true do?
Multi providers add to a list instead of replacing the previous provider.
4. Why wrap providers in makeEnvironmentProviders()?
EnvironmentProviders are accepted only by environment injectors, such as app.config.ts or route providers.
Learn Angular with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
