Dependency injection (DI) means a class doesn't build the things it needs — it asks for them, and something else decides what it gets. In Angular that "something else" is an injector: an object that knows how to create values for keys, creates each one at most once, and hands the same instance to everyone who asks it.
The mental model is a hotel front desk. A guest (your component) doesn't go into the kitchen and cook; they ring the desk and say "I need breakfast". The desk (the injector) knows which kitchen is on duty (the provider), sends the order there the first time, and keeps track of what's already been made. The guest never knows — or cares — whether breakfast came from the main kitchen or, in a test, from a vending machine. The general pattern is covered on the JavaScript site under Dependency injection; this chapter is about Angular's machinery for it.
OrderServiceHardWired calls new ConsoleLogger() itself. The choice of logger is baked into the class: to log somewhere else — or to check in a test what was logged — you have to edit OrderServiceHardWired.
OrderService instead calls inject(Logger). That's a request: "give me whatever is registered under the key Logger". The class still names a type, but it no longer controls which object arrives or how it was built.
@Service() registers OrderService itself with the application's root injector, so anything can inject(OrderService) and they all share one instance (next topic).
The payoff is substitution. In a test you register a different value under the same key, and OrderService uses it without a single change — the spec below proves it.
AdvancedSpec: swapping in a fake Logger
idea.spec.ts
it('lets a test swap the dependency without touching OrderService',()=>{const fakeLogger ={ log: vi.fn()};
TestBed.configureTestingModule({ providers:[{ provide: Logger, useValue: fakeLogger }]});
TestBed.inject(OrderService).place('A-17');expect(fakeLogger.log).toHaveBeenCalledWith('order A-17 placed');});
What’s happening
providers: [{ provide: Logger, useValue: fakeLogger }] tells the test's root injector: "when someone asks for Logger, give them this object". It overrides the real Logger for this test only.
TestBed.inject(OrderService) asks the injector for an OrderService. It doesn't have one yet, so it creates one — and while that constructor runs, the field initializer inject(Logger) asks the same injector for Logger and gets fakeLogger.
place('A-17') calls fakeLogger.log('order A-17 placed'), which vi.fn() records. The assertion passes.
Nothing in OrderService knew a test was running. That's the whole point of DI: the class declares what it needs; the configuration decides what it gets.
AdvancedWhere DI shows up in an app
Where DI shows up in an Angular app:
Services — shared state, API clients, loggers, auth: one instance injected into many components.
Framework objects — HttpClient, Router, ActivatedRoute, DestroyRef, ElementRef are all handed to you by DI.
A service is just a class whose instance is meant to be shared: it holds state or logic that several components need, so it lives outside any one of them. In Angular 22 the standard way to write one is the new @Service() decorator plus the inject() function for its own dependencies. ng generate service in CLI 22.2 now emits @Service() by default (the generator has an --injectable flag that produces the old @Injectable({ providedIn: 'root' }) instead — I checked the schematic's template).
What @Service() does, exactly — from the ServiceDecorator declaration in @angular/core 22.2 and the compiler source:
@Service() "marks a class as a service and makes it automatically available in the dependency injection system". At runtime it compiles to a provider definition with providedIn: 'root' — I logged CartService.ɵprov.providedIn and got root. So @Service() is the same scope as @Injectable({ providedIn: 'root' }): one lazily-created, tree-shakable singleton for the whole app.
@Service({ autoProvided: false }) — "the service won't be exposed to the dependency injection system automatically. It is up to the user to expose it in a providers list." (see Opting out of root providing).
@Service({ factory: () => value }) — "creates a service that is automatically provided and uses the value returned from the factory function". The class becomes a typed token and the factory supplies what you get.
The compiler is stricter than with @Injectable: a @Service class cannot use constructor injection (error NG2028), cannot carry another Angular decorator (NG1006), must be called (@Service, without parentheses, is "@Service must be called"), and autoProvided must be a literal true/false.
Nothing is created when the app starts. @Service() only attaches a recipe (ɵprov, with providedIn: 'root' and a factory) to each class. Services are lazy: the first inject() creates them.
Angular creates AddToCart. Its field initializer inject(CartService) asks the component's injector, which doesn't have it, so the request climbs to the root injector. The root injector sees CartService's recipe, runs it, and caches the instance.
Creating CartService runs its field initializers: inject(Logger) makes the root injector create and cache a Logger too. Dependencies are resolved recursively, in the order the fields are declared.
Angular creates CartBadge. Its inject(CartService) reaches the root injector again, which now returns the cached instance — the same object AddToCart holds.
Clicking the button calls cart.add('book'): items goes [] → ['book'], so count goes 0 → 1, and because CartBadge's template reads cart.count(), the badge re-renders with "1 items". Two clicks give "2 items" and Logger.lines() is ['added book', 'added book'] — my spec asserts exactly that, plus TestBed.inject(CartService) === TestBed.inject(CartService).
The components never talk to each other. They share state because DI handed them one service instance — this is the normal way to share state between components that aren't parent and child (see Sharing state with signal-based services).
Advanced@Service factory form for browser APIs
The factory form turns the class into a typed key whose value comes from a function. That's handy for wrapping something you don't own, like a browser API:
cart.ts
// The factory form: the class is only a typed token; the factory supplies the value.@Service({factory:()=>inject(DOCUMENT).defaultView!.localStorage })exportabstractclassBrowserStorage{abstractgetItem(key:string):string|null;abstractsetItem(key:string, value:string):void;}
What’s happening
BrowserStorage is abstract — nobody will ever new it. It exists to be a token (a lookup key) and to describe the shape callers can rely on: getItem and setItem.
The first inject(BrowserStorage) runs the factory in the root injector. The factory is an injection context, so it can call inject(DOCUMENT) itself, then returns window.localStorage.
In my spec, TestBed.inject(BrowserStorage) is window.localStorage itself (Object.prototype.toString gives [object Storage]), and storage instanceof BrowserStorage is false — the value is whatever the factory returned, not an instance of the class.
The factory's return type is checked against the class. Returning 42 instead fails to compile with "TS1270: Decorator function return type 'abstract new (...args: any[]) => number' is not assignable to type 'void | typeof Wrong'… Type 'number' is not assignable to type 'Wrong'." Storage passes because it structurally has getItem and setItem.
Tests (or a server-rendering setup without localStorage) can still replace it: { provide: BrowserStorage, useValue: fakeStorage }. The factory is only the default.
AdvancedPitfall: NG2028 with constructor parameters
?Same instance?
AddToCart and CartBadge both inject(CartService). A third component is declared with providers: [CartService] and also injects it. How many CartService instances exist once all three are on screen?
Show answer
Two.
What’s happening
@Service() makes CartService available from the root injector, which creates one instance on the first request and caches it. AddToCart and CartBadge have no providers of their own, so their requests climb to root and share that instance.
The third component lists CartService in its own providers. That gives its element injector a separate recipe for the same token, and lookups stop at the first injector that has one — so it gets a second, private instance.
My spec does the same with Logger: a component with providers: [Logger] gets an object that is not.toBe(TestBed.inject(Logger)). More in Component-level providers.
@Injectable is the decorator Angular has used for services since Angular 2, and it isn't deprecated in 22. @Service() covers the common case — root singleton, inject() style — but @Injectable still does several things @Service deliberately doesn't:
Constructor injection. Older code (and NgModule-era apps like techStatus on Angular 16) wires dependencies through constructor parameters. @Injectable is what lets the compiler generate a factory that reads those parameter types.
Not providing itself.@Injectable() with no providedIn only makes the class injectable; someone must still list it in providers. (@Service({ autoProvided: false }) now covers this too.)
Other scopes.providedIn: 'platform' (shared by every app on the page) and the deprecated providedIn: SomeNgModule / 'any'.
Inline provider options.@Injectable({ providedIn: 'root', useFactory: … }), useClass, useExisting and useValue in the decorator itself.
Everything already written. Angular's own classes and every library you depend on use it.
cart.ts
// The older equivalent of @Service(): still valid, and still needed for constructor injection.@Injectable({ providedIn:'root'})exportclassLegacyUserApi{constructor(private http: HttpClient,private logger: Logger,){}load(id:number){this.logger.log(`loading ${id}`);returnthis.http.get(`/api/users/${id}`);}}
What’s happening
providedIn: 'root' registers the class with the root injector — the same scope @Service() gives you. It's lazy and tree-shakable: if nothing ever injects LegacyUserApi, the bundler can drop it.
The constructor declares two parameters. With @Injectable, the Angular compiler reads their types (HttpClient, Logger) and generates a factory roughly like () => new LegacyUserApi(inject(HttpClient), inject(Logger)).
Those types work as tokens only because they are classes, which exist at runtime. A parameter typed string or an interface has nothing to look up (see the next topic).
private http: HttpClient is TypeScript's parameter-property shorthand: it declares a field and assigns the argument to it. My spec checks TestBed.inject(LegacyUserApi) is an instance of LegacyUserApi, with HttpClient resolved from root (in Angular 22 HttpClient is provided in root by default; see Setting up HttpClient).
Advanced@Injectable() without providedIn
tokens.ts
@Injectable()exportclassNewLogger{readonly id =++created;log(msg:string){return`[new #${this.id}] ${msg}`;}}
What’s happening
@Injectable() without providedIn only gives NewLogger a factory. It isn't registered anywhere.
TestBed.inject(NewLogger) therefore throws "NG0201: No provider found for _NewLogger." (the leading underscore is how the test build renames decorated classes; your app's message shows the class name).
After TestBed.configureTestingModule({ providers: [NewLogger] }) the same call succeeds. Listing the class in providers is what registers it — at the app level here, or in a component (see Component-level providers).
There are two ways for a class to receive a dependency. The old way is constructor parameters: Angular reads each parameter's type and uses it as the token. The modern way is the inject() function, called in a field initializer or the constructor body, which takes the token as an ordinary argument.
The @Inject(token) parameter decorator exists because of a gap in the old way. TypeScript types disappear at runtime, so a parameter typed string gives Angular nothing to look up — and lots of useful dependencies (an API URL, a config object) aren't classes. @Inject(API_URL) says "ignore this parameter's type; use this token". inject() doesn't have the gap at all, because you always pass the token explicitly.
tokens.ts
exportconstAPI_URL=newInjectionToken<string>('API_URL');// Old style: constructor parameters + @Inject for non-class tokens.@Injectable({ providedIn:'root'})exportclassOldStyleClient{constructor(@Inject(API_URL)readonly baseUrl:string,private logger: Logger,// a class type is its own token — no @Inject needed){}}// Modern style: inject() takes the token directly.@Service()exportclassNewStyleClient{readonly baseUrl =inject(API_URL);privatereadonly logger =inject(Logger);}
What’s happening
API_URL is an InjectionToken<string> — a unique runtime object that serves as a key, with the <string> telling TypeScript what the value's type will be (see InjectionToken).
In OldStyleClient, the compiler builds a factory from the parameter list. For baseUrl, the type string is useless as a key, so @Inject(API_URL) supplies the key instead. For logger, the type Logger is a class, so the type itself is the key.
In NewStyleClient there's no parameter list to analyse. inject(API_URL) returns a string (the token's type parameter flows through), and inject(Logger) returns a Logger.
My spec provides { provide: API_URL, useValue: 'https://api.test' } and both classes end up with baseUrl === 'https://api.test' — the two styles resolve the exact same tokens from the exact same injector.
If you leave out @Inject on the string parameter, the build fails: "NG2003: No suitable injection token for parameter 'baseUrl' of class 'NoToken'. Consider using the @Inject decorator to specify an injection token."
AdvancedWhy inject() won over @Inject
Why inject() won:
No decorator metadata. It works without emitDecoratorMetadata and isn't affected by how TypeScript compiles parameter properties.
Inheritance is painless. A subclass doesn't have to repeat every parent constructor parameter and call super(a, b, c).
Reusable helpers. A plain function can call inject() (e.g. injectLogger('checkout')), as long as it's called from an injection context (see The injection context).
Options are explicit.inject(X, { optional: true, self: true }) instead of stacking @Optional() @Self() @Inject(X).
Types flow.inject(API_URL) is typed string from the token; @Inject(API_URL) url: number would compile and lie.
Four words get used loosely, and interviews love to check you can separate them:
Token — the key you ask for. A class (CartService), an InjectionToken (API_URL), or rarely a string. It identifies what you want, not how to make it.
Provider — the recipe registered under a token: "for Logger, use this class / value / factory / other token". @Service() and providedIn: 'root' are providers declared on the class itself; providers: [...] arrays declare them from outside.
Injector — the container that holds providers, runs a recipe the first time a token is requested, and caches the result. Injectors form a tree.
Instance — the value the injector produced and cached. One per provider per injector.
The analogy that sticks: a token is the name on a coat-check ticket, a provider is the instruction "if nobody's collected it yet, fetch it from rack B", an injector is the coat-check desk, and the instance is the coat. Two people holding tickets with the same name at the same desk get the same coat; a different desk (a component's own injector) can hold a different coat under the same name.
tokens.spec.ts
it('without multi, the last provider wins',()=>{
TestBed.configureTestingModule({
providers:[{ provide:API_URL, useValue:'first'},{ provide:API_URL, useValue:'second'},],});expect(TestBed.inject(API_URL)).toBe('second');});
What’s happening
{ provide: API_URL, useValue: 'first' } is a provider object: provide is the token, useValue: 'first' is the recipe.
The second entry registers another recipe for the same token in the same injector. An injector keeps one recipe per token, so the later entry replaces the earlier one.
TestBed.inject(API_URL) asks the test's root injector, which runs the surviving recipe and returns the instance'second'.
This "last one wins" rule is how overrides work: a test or a feature can re-provide a token that a library already provided. (To collect several values instead, add multi: true — see InjectionToken.)
A provider object always has provide (the token) plus one recipe. There are four recipes:
Strategy
Recipe
Typical use
useClass
new this class (resolving its own dependencies)
Swap an implementation: { provide: Logger, useClass: RemoteLogger }
useValue
Return this exact value
Config objects, constants, simple fakes in tests
useFactory
Call this function and use its return value
Decide at runtime, or build something that needs arguments
useExisting
Return whatever another token resolves to
An alias: two tokens, one instance
The shorthand providers: [NewLogger] means { provide: NewLogger, useClass: NewLogger }.
The one interviewers probe is useClass vs useExisting, because they look alike and behave very differently. My spec proves it with a counter that numbers every NewLogger the injector creates:
tokens.ts
let created =0;@Injectable()exportclassNewLogger{readonly id =++created;log(msg:string){return`[new #${this.id}] ${msg}`;}}// The class the rest of the app already injects everywhere.@Injectable()exportclassOldLogger{log(msg:string){return`[old] ${msg}`;}}
Both tests register NewLogger under its own token, and something under the OldLogger token. Think of the injector as a table with two rows, OldLogger → ? and NewLogger → new NewLogger().
useExisting: the OldLogger row says "go and look up NewLogger". inject(OldLogger) follows the alias to the NewLogger row, which creates the instance (id 1) and caches it. inject(NewLogger) hits the same row and gets the cached object. Same object, toBe passes, and only one NewLogger was ever constructed.
useClass: the OldLogger row says "construct a NewLoggerfor this row". inject(OldLogger) runs that and caches instance id 1 under OldLogger. inject(NewLogger) has its own row and its own cache, so it constructs instance id 2. Two objects of the same class.
Why it matters: if NewLogger holds state (a buffer, a signal, a WebSocket), useClass silently splits it in two — half the app writes to one copy, half to the other. Use useExisting when the goal is "old token, same service" (migrations, exposing a service under an abstract token); use useClass when you really want a separate implementation or instance.
AdvanceduseFactory with inject() and deps
useFactory runs a function in an injection context, so the function can inject() what it needs. The older form lists the dependencies in a deps array that's passed in as arguments:
tokens.spec.ts
it('useFactory picks an implementation at runtime, once per injector',()=>{let calls =0;
TestBed.configureTestingModule({
providers:[{ provide:LOCALE, useValue:'fr'},{
provide: Greeter,useFactory:()=>{
calls++;returninject(LOCALE)==='fr'?newFrenchGreeter():newEnglishGreeter();},},],});expect(TestBed.inject(Greeter).greet('Rohit')).toBe('Bonjour, Rohit');
TestBed.inject(Greeter);expect(calls).toBe(1);});it('useFactory with the older deps array',()=>{
TestBed.configureTestingModule({
providers:[{ provide:LOCALE, useValue:'en'},{
provide: Greeter,useFactory:(locale:string)=>(locale ==='fr'?newFrenchGreeter():newEnglishGreeter()),
deps:[LOCALE],},],});expect(TestBed.inject(Greeter).greet('Rohit')).toBe('Hello, Rohit');});
What’s happening
Greeter is an abstract class used as the token; EnglishGreeter and FrenchGreeter extend it. LOCALE is an InjectionToken<string> provided with useValue.
In the first test, the first TestBed.inject(Greeter) runs the factory: calls goes 0 → 1, inject(LOCALE) returns 'fr', and a FrenchGreeter is returned and cached. greet('Rohit') gives 'Bonjour, Rohit'.
The second TestBed.inject(Greeter) returns the cached instance without calling the factory — calls is still 1. A factory provider is a once-per-injector recipe, not a "call me every time" function.
In the second test, deps: [LOCALE] tells Angular to resolve LOCALE and pass it as the factory's first argument. 'en' arrives as locale, so an EnglishGreeter is built and greet gives 'Hello, Rohit'.
Both forms are valid in Angular 22. The inject() form is easier to read and type-check; deps is what you'll see in older code and libraries.
AdvancedPitfall: shared instances in useValue
?Predict the ids
TypeScript
exportconstLEGACY=newInjectionToken<OldLogger>('LEGACY');
providers:[
NewLogger,{ provide: OldLogger, useClass: NewLogger },{ provide:LEGACY, useExisting: OldLogger },]// then, in this order (the counter starts at 0):inject(NewLogger).id;inject(LEGACY).id;inject(OldLogger).id;
Show answer
1, 2, 2 — my spec asserts exactly [1, 2, 2].
What’s happening
inject(NewLogger) hits the NewLogger row first: the counter goes 0 → 1, so its id is 1.
inject(LEGACY) follows the useExisting alias to OldLogger. The OldLogger row is useClass: NewLogger and hasn't run yet, so it constructs a new NewLogger: counter 1 → 2, id2, cached under OldLogger.
inject(OldLogger) finds its row already has a cached instance and returns it — id2. The alias added no instance of its own; useClass did.
A token has to exist at runtime, because the injector looks providers up by object identity. Classes exist at runtime, so they work as their own tokens. But many things you want to inject aren't classes: a URL string, a config object described by an interface, a function, a Window. An interface vanishes when TypeScript compiles, so there's nothing to look up:
TypeScript
exportinterfaceAppConfig{ apiUrl:string}@Service()exportclassUsesInterface{
config =inject(AppConfig);// ✘ TS2693: 'AppConfig' only refers to a type, but is being used as a value here.}
AdvancedDefining and providing InjectionTokens
InjectionToken<T> fixes that: it's a small, unique object that stands in for the value, and its type parameter T carries the static type so inject() returns a T. Its description string ('APP_CONFIG') is only for error messages — two tokens with the same description are still two different keys.
tokens.ts
exportinterfaceAppConfig{
apiUrl:string;
retries:number;}// An interface has no runtime value, so it needs a token to stand in for it.exportconstAPP_CONFIG=newInjectionToken<AppConfig>('APP_CONFIG');// A token can carry its own default — no provider needed anywhere.exportconstWINDOW=newInjectionToken<Window>('WINDOW',{
providedIn:'root',factory:()=>inject(DOCUMENT).defaultView!,});// providedIn left out: does it still provide itself?exportconstNOW=newInjectionToken<()=>number>('NOW',{factory:()=>()=> Date.now(),});
What’s happening
APP_CONFIG has no default. Until something provides it, TestBed.inject(APP_CONFIG) throws "NG0201: No provider found for InjectionToken APP_CONFIG." With { provide: APP_CONFIG, useValue: { apiUrl: '/api', retries: 2 } } in the providers, it returns that object, typed AppConfig.
WINDOW is the "token that provides itself" from my notes: providedIn: 'root' plus a factory means the root injector can create it on demand with no providers entry. The factory runs in an injection context, so inject(DOCUMENT) works inside it. My spec gets window back.
NOW leaves providedIn out. I logged NOW.ɵprov.providedIn and got root — in Angular 22 a token with a factory defaults to root. (The .d.ts marks providedIn: NgModule and 'any' as deprecated.)
A self-providing token is still overridable: { provide: NOW, useValue: () => 0 } makes TestBed.inject(NOW)() return 0. That's the same pattern as the @Service factory form — a sensible default plus an escape hatch for tests.
Compare with a @Service({ factory }) abstract class: both give you a typed key with a default. Use a token for non-class values (strings, functions, config); use the abstract-class form when you want a class name to depend on, with abstract members documenting the contract.
AdvancedMulti providers with multi: true
Multi providers are the other reason tokens exist. With multi: true, each provider adds to an array instead of replacing the previous one. Angular uses this itself — HTTP interceptors registered the old way (HTTP_INTERCEPTORS) and app initializers are multi tokens.
inject() is not magic global lookup. It works only while Angular is in the middle of creating something through DI — that's the injection context. Angular sets a "current injector" before it runs a constructor or factory and clears it afterwards; inject() simply reads that current injector. Call it at any other moment and there's no injector to read.
Places that are an injection context:
A class's field initializers and constructor body, when DI creates the class (components, directives, pipes, services).
Factory functions of providers (useFactory, @Service({ factory }), InjectionToken factories).
Functional guards, resolvers and HTTP interceptors — the router and HttpClient run them in one.
Any function passed to runInInjectionContext(injector, fn).
Places that are not: event handlers, lifecycle hooks such as ngOnInit, setTimeout/subscribe callbacks, async code after an await, and module top level.
scopes.ts
@Component({
selector:'app-profile',
template:`<button (click)="onSave()">Save</button>`,})exportclassProfile{privatereadonly logger =inject(Logger);// ✅ field initializerprivatereadonly injector =inject(Injector);// ✅ capture the injector for laterconstructor(){inject(Logger).log('constructor ok');// ✅ constructor}onSave(){inject(Logger).log('save');// ❌ NG0203 — event handlers run outside the injection context}onSaveFixed(){runInInjectionContext(this.injector,()=>inject(Logger).log('save via runInInjectionContext'));}}
What’s happening
TestBed.createComponent(Profile) makes Angular set the current injector to the component's element injector, then run field initializers and the constructor. Both inject(Logger) calls and inject(Injector) succeed; Logger.lines() becomes ['constructor ok'].
When construction finishes, Angular restores the previous (empty) context. From now on, code in this class runs "outside".
Calling onSave() later — exactly what a click does — runs inject(Logger) with no current injector, and it throws: "NG0203: The _Logger token injection failed. inject() function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with runInInjectionContext."
onSaveFixed() uses the Injector captured during construction. runInInjectionContext(this.injector, fn) sets it as the current injector, runs fn, and restores the old one, so inject(Logger) works. My spec ends with ['constructor ok', 'save via runInInjectionContext'].
The usual fix is simpler than runInInjectionContext: inject in a field (like this.logger) and use the field in the handler. Reach for runInInjectionContext only when you must call an API that itself needs a context (toSignal, effect, httpResource) after construction.
AdvancedHelper functions that call inject()
The same rule applies to every API built on inject(). toSignal() checks for a context up front and throws its own NG0203 — "toSignal() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with runInInjectionContext." — unless you pass { injector } (see Where toSignal() can run). effect() behaves the same way (see effect() and when not to use it).
Helper functions that call inject() are fine, because the context belongs to the call, not to where the function is written:
scopes.ts
// Helper functions that call inject() are fine — as long as THEY are called in an injection context.exportfunctioninjectLogger(prefix:string){const logger =inject(Logger);return(msg:string)=> logger.log(`${prefix}: ${msg}`);}@Component({ selector:'app-uses-helper', template:``})exportclassUsesHelper{readonly log =injectLogger('helper');// ✅ field initializer}
What’s happening
injectLogger is an ordinary function. It isn't a context on its own — it borrows whichever context it's called in.
UsesHelper's field initializer calls it during construction, so inject(Logger) inside it succeeds and the returned closure captures logger (see the JS site's closures).
Later, fixture.componentInstance.log('hi') runs the closure — no inject() happens at that point, it just uses the captured logger. My spec sees ['helper: hi'].
This "inject now, use later" shape is how injectXxx() helper functions in libraries work, and why their docs say "call it in a constructor or field initializer".
AdvancedPitfall: injecting after an async boundary
A provider listed in a component's providers array lives in that component's element injector. Every instance of the component gets its own injector, so every instance gets its own instance of the service, created with the component and destroyed with it. The component's children (its template, and their templates) see that instance, because lookups climb from child to parent and stop at the first match. Siblings and the rest of the app don't.
This is how you give each widget its own state — each counter, each wizard, each row editor — instead of one global singleton. It's also how you override a root service for one subtree.
scopes.ts
@Service({ autoProvided:false})exportclassCounterStoreimplementsOnDestroy{readonly id =++storeCount;readonly count =signal(0);static destroyed:number[]=[];increment(){this.count.update((c)=> c +1);}ngOnDestroy(){
CounterStore.destroyed.push(this.id);}}@Component({
selector:'app-counter-label',
template:`<i>store #{{ store.id }} = {{ store.count() }}</i>`,})exportclassCounterLabel{protectedreadonly store =inject(CounterStore);// resolved from the nearest ancestor that provides it}@Component({
selector:'app-counter',
imports:[CounterLabel],
providers:[CounterStore],// each <app-counter> gets its own CounterStore
template:`<button (click)="store.increment()">+1</button> <app-counter-label />`,})exportclassCounter{protectedreadonly store =inject(CounterStore);}@Component({
selector:'app-counters',
imports:[Counter],
template:`
<app-counter />
@if (showSecond()) {
<app-counter />
}
`,})exportclassCounters{readonly showSecond =signal(true);}
What’s happening
Counters renders two <app-counter> elements. For each one, Angular creates an element injector holding the CounterStore provider from providers: [CounterStore].
The first Counter runs inject(CounterStore): its own element injector has the provider, so it creates store #1. The second Counter does the same in its own injector and gets store #2.
Each Counter renders an <app-counter-label>. The label has no providers, so its inject(CounterStore) climbs to the nearest ancestor that has one — its own Counter — and gets that counter's store. Label 1 sees #1, label 2 sees #2.
Clicking the first button twice takes store #1 from 0 → 1 → 2. My spec reads both labels and gets ['store #1 = 2', 'store #2 = 0']: the second counter is untouched.
Setting showSecond to false removes the second Counter, which destroys its element injector, which calls ngOnDestroy on store #2. CounterStore.destroyed becomes [2]. A component-scoped service lives and dies with its component — a good place for per-widget subscriptions.
autoProvided: false makes sure nobody accidentally gets a root-level CounterStore: without a Counter ancestor, inject(CounterStore) throws NG0201 instead of silently sharing one global store (next topic).
AdvancedOverriding a root service for a subtree
Overriding a root service for one subtree works the same way:
scopes.ts
// Overriding a root service for one subtree.@Component({
selector:'app-isolated',
providers:[Logger],// a SECOND Logger, separate from the root one
template:``,})exportclassIsolated{readonly logger =inject(Logger);}
What’s happening
Logger is a @Service(), so the root injector already has one.
providers: [Logger] puts another recipe for the same token on Isolated's element injector. inject(Logger) checks that injector first, finds it, and creates a new instance there.
My spec asserts fixture.componentInstance.logger is notTestBed.inject(Logger). Everything inside <app-isolated> logs to the private copy; the rest of the app keeps using the root one.
This is the classic interview trap: "I added CartService to a component's providers and now the cart is empty in that part of the page." The component got a fresh instance. Remove the entry to go back to the shared one.
@Service() provides itself in root. That's what you want for app-wide singletons, but wrong for state that should only exist inside a scope — a per-component store, a per-route wizard, a per-feature cache. If such a class were root-provided, forgetting to add it to providers wouldn't fail: some component would silently get the global instance and share state with every other one.
@Service({ autoProvided: false }) is Angular 22's way to say "this is a service, but I'll decide where it's provided". It compiles to providedIn: null — it's injectable, but no injector has it until you list it. It's the @Service equivalent of @Injectable() without providedIn.
scopes.spec.ts
it('autoProvided:false is not available from the root injector',()=>{let message ='';try{
TestBed.inject(CounterStore);}catch(e){
message =(e as Error).message;}expect(message).toContain('NG0201');});it('autoProvided:false can be provided at the application level too',()=>{
TestBed.configureTestingModule({ providers:[CounterStore]});expect(TestBed.inject(CounterStore)).toBe(TestBed.inject(CounterStore));});
What’s happening
CounterStore (from Component-level providers) is declared with @Service({ autoProvided: false }). Its definition has no providedIn, so the root injector has no recipe for it.
TestBed.inject(CounterStore) asks the root injector, which can't find a provider, and there is nothing above it to ask. It throws "NG0201: No provider found for _CounterStore." That loud failure is the point: you can't share it by accident.
In the second test, providers: [CounterStore] registers it in the test's environment injector. Now it behaves like a singleton for that injector: two inject calls return the same object.
So autoProvided: false doesn't mean "component-only" — it means "only where I list it": a component's providers, a route's providers, or bootstrapApplication's providers.
AdvancedWhere to provide an opted-out service
Where you'd list it:
A component — providers: [CounterStore], one per component instance (most common).
A route — { path: 'checkout', providers: [CheckoutState], loadComponent: … }, one per route's environment injector, shared by everything under that route.
Sometimes a dependency is nice-to-have: an analytics client that only exists in production, a parent form a field can work without, a plugin token a library lets you fill in. By default, a missing provider is an error (NG0201). inject(token, { optional: true }) turns "missing" into null, and its return type becomes T | null so TypeScript makes you handle it. The old equivalent is the @Optional() parameter decorator.
ANALYTICS is a token with no default factory, so it only exists if someone provides it.
In the first spec nobody does. inject(ANALYTICS, { optional: true }) walks the whole injector chain, finds nothing, and returns null instead of throwing. track('signup') takes the else branch: sent becomes ['(dropped) signup'].
In the second spec the test provides { provide: ANALYTICS, useValue: { send: (e) => sent.push(e) } }. Now analytics is that object and track('signup') forwards to it: ['signup'].
The type Analytics | null is what keeps this safe — calling this.analytics.send(...) without the if would be a compile error under strict mode.
The dependency inversion principle says high-level code should depend on an abstraction, not on a concrete low-level class. In Angular terms: Checkout should ask for "a payment gateway", not for "the HTTP-calling Stripe gateway". DI then plugs in the real one in the app and a fake one in tests, and Checkout never knows the difference.
The neat Angular 22 version uses an abstract class as the token and the @Service factory form to make the real implementation the default:
payments.ts
exportinterfaceChargeResult{
ok:boolean;
id:string;}// The abstraction. The factory makes the real gateway the default,// so the app needs no extra provider — but tests can swap it.@Service({factory:()=>inject(HttpPaymentGateway)})exportabstractclassPaymentGateway{abstractcharge(cents:number):Promise<ChargeResult>;}@Service()exportclassHttpPaymentGatewayextendsPaymentGateway{privatereadonly http =inject(HttpClient);charge(cents:number){returnfirstValueFrom(this.http.post<ChargeResult>('/api/charges',{ cents }));}}// High-level code depends only on the abstraction.@Service()exportclassCheckout{privatereadonly gateway =inject(PaymentGateway);readonly status =signal<'idle'|'paid'|'declined'>('idle');asyncpay(cents:number){if(cents <=0)thrownewError('amount must be positive');const result =awaitthis.gateway.charge(cents);this.status.set(result.ok ?'paid':'declined');return result.id;}}
What’s happening
PaymentGateway is the abstraction: an abstract class (so it exists at runtime and can be a token) with one abstract method. See the TypeScript site on abstract classes.
Its @Service({ factory: () => inject(HttpPaymentGateway) }) says "by default, PaymentGateway resolves to the root HttpPaymentGateway". The factory is lazy, so referring to a class declared further down the file is fine.
Checkout only ever mentions PaymentGateway. In the app, inject(PaymentGateway) runs the factory, which creates the HTTP gateway. My spec checks TestBed.inject(PaymentGateway)isTestBed.inject(HttpPaymentGateway) — one instance, two tokens, just like useExisting.
With provideHttpClientTesting(), calling pay(1999) produces a POST /api/charges with body { cents: 1999 }; flushing { ok: true, id: 'ch_real' } resolves pay to 'ch_real'.
The business logic (validation, mapping ok to a status) is the part worth testing, and it doesn't need HTTP at all — which the next spec shows.
FakeGateway implements the same contract with an in-memory script: it records each amount in calls and returns whatever next holds.
{ provide: PaymentGateway, useValue: new FakeGateway() } replaces the PaymentGateway token for this test. The @Service factory never runs, so HttpPaymentGateway and HttpClient are never created.
TestBed.inject(Checkout) creates Checkout, whose inject(PaymentGateway) gets the fake. pay(1999) awaits the fake's promise, sets status to 'paid', returns 'ch_fake', and calls is [1999].
Changing fake.next to { ok: false, … } drives the other branch: status becomes 'declined'. You can test every branch of Checkout in milliseconds, with no network stubs.
This is the practical meaning of "dependency inversion for testing": the test controls the abstraction, so it controls every input the logic sees.
AdvancedTesting pitfall: useClass returns the real service
Angular doesn't have one injector; it has a tree of them, in two layers:
Environment injectors — the root injector (from bootstrapApplication providers and everything providedIn: 'root'), plus child environment injectors created for lazy-loaded routes with providers. Above root sits the platform injector, and above that a null injector that throws NG0201.
Element injectors — one per element that has a component or directive with providers/viewProviders. They mirror the template tree: a component's element injector is the parent of the element injectors inside its template.
When something calls inject(token), Angular walks up the element injectors first — from the requesting element, to its parent element, through each component's host, up to the root component — and only if none of them has a provider does it switch to the environment injectors: the nearest environment injector, then its parents, root, platform, null. The first injector that has a provider wins, creates (or reuses) the instance, and the walk stops.
The first injector on the path that has a provider for the token creates (or reuses) the instance, and the walk stops.
inject() takes options that change where the walk starts and stops:
Option
Meaning
self: true
Only look at the requesting element's own injector
skipSelf: true
Start at the parent, skipping the requesting element's own injector
host: true
Stop at the host component of the template this code is declared in
optional: true
Return null instead of throwing when the walk finds nothing
hierarchy.ts
exportconstTHEME=newInjectionToken<string>('THEME',{factory:()=>'root-default'});@Component({
selector:'app-theme-probe',
template:`{{ theme }} | parent={{ fromParent ?? 'null' }} | self={{ own ?? 'null' }}`,})exportclassThemeProbe{readonly theme =inject(THEME);// normal walk: element injectors, then environment injectorsreadonly fromParent =inject(THEME,{ skipSelf:true, optional:true});// start one level upreadonly own =inject(THEME,{ self:true, optional:true});// only this element}@Component({
selector:'app-panel',
imports:[ThemeProbe, HostTheme],
providers:[{ provide:THEME, useValue:'panel'}],
template:`<p class="inside" appHostTheme><app-theme-probe /></p>`,})exportclassPanel{}@Component({
selector:'app-page',
imports:[Panel, ViewPanel, ThemeProbe, HostTheme],
template:`
<div class="outside" appHostTheme><app-theme-probe /></div>
<app-panel />
<app-view-panel />
`,})exportclassPage{}
What’s happening
THEME has a root default, 'root-default'. Panel overrides it for its subtree with providers: [{ provide: THEME, useValue: 'panel' }].
The probe outside the panel (.outside): no element on its path provides THEME, so the walk falls through to the environment and the token's factory gives 'root-default'. skipSelf also ends up at root. self checks only the probe's own element, which has no provider, so it's null. Rendered: root-default | parent=root-default | self=null.
The probe inside the panel (.inside): the walk reaches <app-panel>'s element injector and stops there: 'panel'. skipSelf starts one level up from the probe — still finds the panel. self is still null. Rendered: panel | parent=panel | self=null.
In the second spec, the test's environment provides { provide: THEME, useValue: 'app' }. The outside probe now gets 'app' (an explicit provider beats the token's built-in factory), but the inside probe still gets 'panel' — element injectors are checked before any environment injector.
This is what "nearest provider wins" means in practice: the same inject(THEME) line returns different values depending on where in the tree the component is.
Advancedself and skipSelf in a nested probe
self and skipSelf matter most when a component provides a token and wants its parent's version of it — e.g. a nested menu that registers itself and also needs its parent menu:
hierarchy.ts
// A probe that provides THEME on its own element.@Component({
selector:'app-self-probe',
providers:[{ provide:THEME, useValue:'self'}],
template:`{{ theme }} | parent={{ fromParent }} | self={{ own }}`,})exportclassSelfProbe{readonly theme =inject(THEME);readonly fromParent =inject(THEME,{ skipSelf:true});readonly own =inject(THEME,{ self:true});}@Component({
selector:'app-self-page',
imports:[SelfProbe],
providers:[{ provide:THEME, useValue:'page'}],
template:`<app-self-probe />`,})exportclassSelfPage{}
What’s happening
SelfProbe provides THEME = 'self' on its own element, and sits inside SelfPage, which provides THEME = 'page'.
Plain inject(THEME) starts at the probe's own element injector, which has a provider: 'self'.
skipSelf: true skips that injector and starts at the parent, <app-self-page>: 'page'. This is how a child gets "the version my parent sees" even though it shadows the token itself.
self: true only checks the probe's own injector: 'self'. Rendered: self | parent=page | self=self — exactly what my spec asserts.
Advancedhost: true and where the lookup stops
host: true is the subtle one. It stops the walk at the host element of the component whose template the requesting code is written in, and it never falls back to environment injectors. I tested it with a directive:
hierarchy.ts
// host: true — search stops at the host element of the component whose template this directive is in.@Directive({ selector:'[appHostTheme]', host:{'[attr.data-host-theme]':'theme ?? "null"'}})exportclassHostTheme{readonly theme =inject(THEME,{ host:true, optional:true});}@Component({
selector:'app-view-panel',
imports:[HostTheme],
viewProviders:[{ provide:THEME, useValue:'view-panel'}],
template:`<p class="view-inside" appHostTheme></p>`,})exportclassViewPanel{}
What’s happening
appHostTheme is used three times: in Page's template (.outside), in Panel's template (.inside) and in ViewPanel's template (.view-inside). It writes what it resolved into a data-host-theme attribute.
.outside is in Page's template, so the boundary is <app-page>. Nothing up to there provides THEME, and host doesn't continue to the environment — so even though the token has a root default, the result is null.
.inside is in Panel's template. I expected 'panel', but got null: a host component's providers are not visible to a host: true lookup from inside its own view.
.view-inside is in ViewPanel's template, and ViewPanel uses viewProviders. Now the lookup finds it: 'view-panel'. My spec asserts ['null', 'null', 'view-panel'].
Takeaway: host is a tool for directives that must only cooperate with the component they're used in (for example a form control looking for its form). If you rely on it, provide with viewProviders. In ordinary app code you rarely need it.
?Which value?
THEME has a root factory returning 'root-default'. appConfig provides { provide: THEME, useValue: 'app' }. A <app-card> component with providers: [{ provide: THEME, useValue: 'card' }] contains <app-badge>, which calls inject(THEME, { skipSelf: true }). What does the badge get?
Show answer
'card'.
What’s happening
skipSelf skips the badge's own element injector — the badge has no providers anyway, so that changes nothing here.
The walk continues to the parent element, <app-card>, whose element injector provides 'card'. It stops there.
The environment's 'app' and the token default 'root-default' are never consulted, because element injectors are always checked first and one of them matched.
If <app-badge> itself provided THEME, skipSelf is what would still let it read 'card'. (My spec renders this exact setup and the badge shows card.)