Dependency injection is how Angular hands your classes the things they need, instead of letting them construct those things themselves. You ask for a token, an injector finds a provider for it, and you get back an instance. The interesting interview questions are all about the “finds” part: which injector answers, how long the instance lives, and how the same service ends up being created twice. This note uses the modern standalone APIs (inject(), ApplicationConfig, route providers) and mentions the classic decorator equivalents along the way.
The two injector hierarchies
Angular resolves dependencies through two trees:
- Element injectors follow the component tree. A component or directive that declares
providers(orviewProviders) gets an injector on its host element. - Environment injectors hold application-wide services. The root environment injector is configured by
providedIn: 'root'and by the providers you pass tobootstrapApplication. Routes with their ownproviders, and lazily loaded NgModules, create child environment injectors. A lazily loaded array of standalone routes doesn’t create one by itself. Above root sit the platform injector and finally theNullInjector.
When a component asks for a token, Angular searches in this order:
- The requesting element’s injector, then each ancestor element’s injector up the component tree.
- If nothing matched, the environment injector for that part of the app (for example, a route’s), then its parents up to root and the platform injector.
- If still nothing matched, the
NullInjectorthrows, unless the dependency was marked optional. Recent versions report it as NG0201, “No provider found forX”; older ones saidNullInjectorError: No provider for X!.
The first provider found wins, and the injector that owns it also owns the instance. Keep that sentence in mind; it explains every scoping question below.
providedIn: ‘root’ and app providers
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly http = inject(HttpClient);
readonly items = signal<CartItem[]>([]);
}providedIn: 'root' registers the service with the root environment injector. You get one instance for the whole app, created lazily the first time something injects it. It’s also tree-shakable: the provider lives on the class rather than in a providers array, so if nothing injects the service, the bundler drops it. A class listed in a providers array is always referenced and always bundled.
Services that need configuration, and framework features, go in the application config:
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
{ provide: API_URL, useValue: 'https://api.example.com' },
],
};
bootstrapApplication(App, appConfig);Note
Angular v22 added
@Service()as a shorthand for@Injectable({ providedIn: 'root' }). It supports onlyinject()(no constructor parameters) and can opt out of auto-providing with{ autoProvided: false }. Stick with@Injectable()when you need constructor injection or its other provider options.
Component and route providers
@Component({
selector: 'app-editor',
providers: [EditorState], // a fresh EditorState for every <app-editor>
template: `<app-toolbar /><app-canvas />`,
})
export class Editor {
private readonly state = inject(EditorState);
}Providers on a component create a new instance for each component instance. The toolbar, the canvas and any projected content all share that editor’s EditorState, and it’s destroyed along with the component, so its ngOnDestroy runs. That’s ideal for state that should be scoped: an editor, a wizard, one row of a complex form. viewProviders works the same way but hides the service from content projected through <ng-content>.
Route providers scope a service to a section of the app:
export const routes: Routes = [
{
path: 'admin',
providers: [AdminReportsService],
loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
},
];providers on a route create an environment injector for that route and its children. Everything under /admin shares one AdminReportsService, and the rest of the app can’t see it.
Provider recipes and InjectionToken
A provider maps a token to a recipe for producing the value:
providers: [
// Swap an implementation: inject(Logger) returns a ConsoleLogger
{ provide: Logger, useClass: ConsoleLogger },
// A fixed value, usually behind an InjectionToken
{ provide: API_URL, useValue: 'https://api.example.com' },
// Computed at creation time; inject() works inside the factory
{
provide: STORAGE,
useFactory: () => (isPlatformBrowser(inject(PLATFORM_ID)) ? localStorage : new MemoryStorage()),
},
// An alias: both tokens resolve to the SAME Logger instance
{ provide: AuditLogger, useExisting: Logger },
]Classic code passes factory dependencies through a deps: [...] array; calling inject() inside the factory is the modern equivalent. Watch the difference between the last two recipes: { provide: AuditLogger, useClass: Logger } creates a second, independent Logger, while useExisting returns the one that already exists. If you list two plain providers for the same token, the last one wins.
Tokens don’t have to be classes. TypeScript interfaces vanish at runtime, so a config object or a string needs an InjectionToken:
export interface AppConfig {
apiUrl: string;
retries: number;
}
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
// A token can carry a tree-shakable default, just like providedIn: 'root'
export const API_URL = new InjectionToken<string>('API_URL', {
providedIn: 'root',
factory: () => '/api',
});The string is only a debugging label; Angular identifies the token by object reference.
inject() vs constructor injection
// Modern
export class OrderPage {
private readonly orders = inject(OrderService);
private readonly route = inject(ActivatedRoute);
}
// Classic, still fully supported
export class OrderPage {
constructor(private orders: OrderService, private route: ActivatedRoute) {}
}Both resolve through the same injectors, so the difference is ergonomics. The reasons to prefer inject():
- It works in any injection context, not just constructors: field initializers, provider and
InjectionTokenfactories, functional guards, resolvers and interceptors, and code wrapped inrunInInjectionContext(). Calling it anywhere else, such as in a click handler or asetTimeout, throws NG0203. - Inheritance is simpler. A subclass doesn’t have to accept its parent’s dependencies just to forward them to
super(...). - Options are typed.
inject(Logger, { optional: true })has the typeLogger | null. - It composes. You can write reusable helper functions that call
inject()internally.
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
return auth.isLoggedIn() || inject(Router).createUrlTree(['/login']);
};Resolution modifiers and multi providers
Modifiers change where the search starts, where it stops, and what happens on a miss:
inject() option |
Decorator | Effect |
|---|---|---|
optional: true |
@Optional() |
Return null instead of throwing |
self: true |
@Self() |
Look only in the current injector |
skipSelf: true |
@SkipSelf() |
Start the search at the parent injector |
host: true |
@Host() |
Stop the search at the host component’s boundary |
self can’t be combined with skipSelf or host. A classic use of skipSelf is a recursive component that needs its parent’s instance of a service it also provides:
@Component({
selector: 'app-tree-node',
providers: [TreeNodeState],
template: `<ng-content />`,
})
export class TreeNode {
readonly state = inject(TreeNodeState); // this node's own instance
readonly parent = inject(TreeNodeState, { skipSelf: true, optional: true }); // null at the root
}Multi providers let several providers contribute to one token, and injecting it returns an array:
export const SEARCH_SOURCES = new InjectionToken<SearchSource[]>('SEARCH_SOURCES');
providers: [
{ provide: SEARCH_SOURCES, useClass: ProductSearch, multi: true },
{ provide: SEARCH_SOURCES, useClass: DocsSearch, multi: true },
]
const sources = inject(SEARCH_SOURCES); // [ProductSearch, DocsSearch]You’ll meet this pattern in the framework itself: NG_VALIDATORS, NG_VALUE_ACCESSOR, and class-based interceptors registered with HTTP_INTERCEPTORS (which need withInterceptorsFromDi()). Every entry needs multi: true; mixing multi and regular providers for one token throws an error.
Why a singleton gets created twice
A “singleton” is only single per injector. These are the usual ways a second instance sneaks in:
- A root service is also listed in a component’s
providers. That component and its subtree get their own copy, so state written in one place never shows up in the other. - It’s listed in a route’s
providers(or, in NgModule apps, provided by a lazily loaded module). The child environment injector creates a separate instance for that part of the app. The oldforRoot()/forChild()convention existed to prevent exactly this. - An alias uses
useClassinstead ofuseExisting, which builds a second instance instead of pointing at the first. - A component with
providersis rendered in a loop. Every@foritem gets its own instance. Sometimes that’s the goal; make sure it’s deliberate.
Interview tip
When someone says “my service state keeps resetting”, ask which injector created the instance they’re looking at. Then search the codebase for the class name inside every
providersarray. The answer is almost always a stray provider.
The interview answer
“Angular resolves a token by walking injectors: first the element injectors up the component tree, then the environment injectors (route injectors, then root, then platform). The first provider found wins, and if there’s none it throws a no-provider error unless the dependency is optional. providedIn: 'root' gives an app-wide, lazily created, tree-shakable singleton. Component providers create an instance per component, and route providers share one instance across that route’s subtree.
A provider can supply a class, a value, a factory or an alias, and non-class dependencies use InjectionToken. I prefer inject() because it works in any injection context, including functional guards and interceptors, and it keeps inheritance simple. The classic gotcha is that a singleton is only single per injector, so listing a root service again in a component or route quietly creates a second instance.”