Dependency injection
`inject()` in a field initialiser instead of a constructor parameter, `@Service()` instead of the `providedIn` incantation, and providers you can compose as plain data.
inject() instead of constructor parameters
The old form still compiles, but the new one is shorter and — more importantly — works in more places:
2022 — constructor injection
2026 — inject(), and @Service()
inject() is not just syntax. Because it is a function call rather than a constructor signature, it works in field initialisers, in computed factories, in functional guards and resolvers, in an InjectionToken factory, and in provideAppInitializer — none of which can take constructor parameters. That is the real reason the framework moved.
inject() only works in an injection context: a field initialiser, a constructor, a factory, or a function the framework calls during creation. Calling it inside a setTimeout, a click handler, or an await continuation throws "inject() must be called from an injection context". The fix is usually runInInjectionContext or — better — capture the dependency in a field and use it later.
@Service() and scope
@Injectable({ providedIn: 'root' }) said two things at once: this class can be injected, and one instance lives at the root. v22 splits the intent:
@Service() // root-provided, one instance app-wide
export class Theme {
readonly mode = signal<'light' | 'dark'>('light');
}
@Injectable({ providedIn: 'root' }) // still valid, still common
export class Legacy {}@Service() sets autoProvided: true by default, which is exactly the old providedIn: 'root'. For anything with a narrower life — a component-level store, a per-route cache — declare it in the providers of the component or the route instead:
@Component({
selector: 'app-basket-panel',
providers: [BasketStore], // one instance per component instance
template: `...`,
})
export class BasketPanel {}The rule of thumb has not changed since 2022: root for stateless or genuinely global, component or route for anything with a lifetime shorter than the app.
InjectionToken
A token is how you inject something that is not a class — a string, a config object, a function:
export const API_URL = new InjectionToken<string>('API_URL');
// provided once, in app.config.ts
export const appConfig: ApplicationConfig = {
providers: [{ provide: API_URL, useValue: '/api' }],
};
// injected anywhere
private readonly apiUrl = inject(API_URL);useValue for a literal, useFactory for a computed value, useExisting to alias another token, useClass to pick an implementation. That last one is the seam you want for tests and for swapping a real backend for a fake:
TestBed.configureTestingModule({
providers: [{ provide: WeatherApi, useClass: FakeWeatherApi }],
});InjectionToken + inject() scopes
The host provides a signal under a token, one child injects it, and a second child overrides it for its own subtree. Writing from the host updates the first child and leaves the override alone.
the host reads the token: provided by the demo host
a child injects it:
a child that provides its own:
Providers as data
The provider array is a value, so it composes. This is the pattern that replaced wide, deeply nested modules:
export const WEATHER_PROVIDERS: Provider[] = [
{ provide: API_URL, useValue: '/api/weather' },
WeatherStore,
];
// app.config.ts
providers: [
provideHttpClient(withFetch()),
...WEATHER_PROVIDERS,
]Startup work: provideAppInitializer
APP_INITIALIZER as a multi-provider token is gone; the modern form is a plain function that runs before the app renders, and it may return a promise or an observable:
export const appConfig: ApplicationConfig = {
providers: [
provideAppInitializer(() => {
const config = inject(ConfigService);
return config.load(); // the app waits for this
}),
],
};It is the right place for "the app cannot render without this" — a feature flag payload, a locale bundle, an auth handshake. It is the wrong place for data that a page can show a skeleton for; that belongs in a resource().
For a dependency that arrives asynchronously and is only needed sometimes, v22 adds injectAsync():
readonly heavy = injectAsync(() => import('./heavy-analyzer').then((m) => new m.Analyzer()));The awaited factory runs inside an injection context, so anything it injects is resolved normally — useful for lazily loaded WASM, a large parser, or a config file fetched at first use.
Hierarchical injectors, briefly
Injectors form a tree: root, then the route that declared providers, then the component. A request walks up from the requesting injector until it finds a provider. Two consequences worth keeping in mind:
- A route-level provider is a new instance per route activation, which is often what you want for a page-scoped store.
- The same token can be provided at several levels, and the nearest one wins — that is the mechanism behind "provide a fake in this test, the real thing everywhere else".
What to take away
inject() made dependencies a value you read rather than a constructor you are stuck with; @Service() made the common case one line; providers as data made composition possible. Next, the two places this shows up most: Routing, where providers and guards became functions, and HTTP & async data, where the client is provided the same way.