EthicsComputerCourses
Join training +91 90532 10052

Advanced Dependency Injection

Angular · Lesson 14 of 41 Intermediate 8 min read Updated

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 the providers in app.config.ts and from every @Service() class. Lazy-loaded routes with their own providers get a child environment injector.
  • Element injectors belong to components and directives. Each one is empty unless the component or directive has providers or viewProviders.
Text
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 here

When 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:

Output
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:

OptionEffect
optional: trueReturn null instead of throwing when nothing is found
self: trueLook only in the current element's injector
skipSelf: trueStart the search at the parent, ignoring the current element
host: trueStop 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:

TypeScript
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:

HTML
<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 />
Output
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.

TypeScript
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:

TypeScript
protected methods = inject(PAYMENT_METHODS);
// this.methods.map((m) => `${m.label}: fee ₹${m.fee(1500)}`)
Output
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:

TypeScript
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.

  1. Create a token for the options the app passes in:
TypeScript
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');
  1. Write the service. It reads the options with inject(), so it does not care where they came from:
TypeScript
@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}`);
    }
  }
}
  1. Bundle everything in a function that returns EnvironmentProviders. makeEnvironmentProviders() marks the result as "app level only", so nobody can put it in a component's providers by mistake. provideAppInitializer() runs a function while the app starts:
TypeScript
export function provideAnalytics(options: AnalyticsOptions): EnvironmentProviders {
  return makeEnvironmentProviders([
    { provide: ANALYTICS_OPTIONS, useValue: options },
    provideAppInitializer(() => {
      inject(Analytics).track('app_start');
    }),
  ]);
}
  1. Use it in app.config.ts with one readable line:
TypeScript
export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes), provideAnalytics({ appName: 'LeaveDesk', enabled: true })],
};
  1. Check the result. After start-up and one track('login') call, events holds:
Output
["[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

  • viewProviders in @Component() works like providers, 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, so inject() works inside it. You get an injector with inject(Injector) or inject(EnvironmentInjector). Use it sparingly; most code can simply inject in a field.

Common mistakes

  • Forgetting optional: true when a parent may not exist, so the top-level component throws NG0201.
  • Forgetting multi: true on one provider. That provider then replaces the whole list instead of adding to it.
  • Putting environment providers in a component. provideHttpClient() and your own provideX() belong in app.config.ts or route providers, 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: true collects many providers for one token into an array, the basis of plug-in style features.
  • An InjectionToken can have a default factory with providedIn: 'root'.
  • Wrap a feature's providers in a provideX() function built with makeEnvironmentProviders(), and run start-up logic with provideAppInitializer().

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
TypeScript
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)?

2. What does inject(FormSection, { skipSelf: true, optional: true }) return in the outermost section?

3. What does multi: true do?

4. Why wrap providers in makeEnvironmentProviders()?

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