NullInjectorError No Provider: Angular DI Fix
Add providedIn root or provideHttpClient() to fix it.
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
- ✓Basic Angular components and services
- ✓Familiarity with TypeScript decorators
- ✓A runnable Angular CLI project
- Angular's NullInjectorError means no injector in the hierarchy registered the token you're injecting, so DI has no recipe to build it
- Default every service to @Injectable({ providedIn: 'root' }) so the root injector owns exactly one shared instance
- In standalone apps add provideHttpClient() to app.config.ts providers; in NgModule apps import HttpClientModule once in AppModule
- TestBed builds an isolated injector, so specs need HttpClientTestingModule or provideHttpClientTesting() to mirror the app
Think of Angular's dependency injection like a company kitchen. When a cook (your component) needs a tool, they don't forge it themselves; they ask the kitchen manager (the injector), who checks the equipment list (providers). NullInjectorError means the tool was never put on any equipment list, so the manager can't hand it over. The fix isn't yelling louder; it's registering the tool once on the right list. Write providedIn: 'root' and every kitchen in the building shares one copy.
You've just wired up a service, injected it into a component, hit save, and the app dies with NullInjectorError: No provider for UserService. Nothing looks wrong: the class exists, the import path resolves, the constructor signature is clean. Yet Angular insists it has no idea how to build the thing you just wrote.
This error is dependency injection telling you the truth. Angular never constructs injected classes with new directly. It consults a hierarchy of injectors, each holding provider recipes, and walks upward until it finds one that knows your token. When every level shrugs, you get this error with the infamous R3InjectorError bracket chain naming each dependent that failed.
The standalone-component era added a fresh twist. Apps bootstrapped with bootstrapApplication have no AppModule, so providers live in app.config.ts or the bootstrap providers array. The classic HttpClientModule import becomes provideHttpClient(), and TestBed specs need provideHttpClientTesting(). Half the NullInjectorError reports you'll see today are migration fallout from this shift.
This guide covers the full picture: how the injector hierarchy resolves tokens, when to use providedIn root versus providers arrays, how standalone providers differ from NgModule wiring, why HttpClient is the most common victim, and how to configure TestBed so specs pass without touching working app code.
How Angular's Injector Hierarchy Actually Resolves Tokens
Angular never calls new on your services directly. When a component declares constructor(private svc: UserService), the framework asks the component's element injector for the UserService token. If that injector has no recipe, the request bubbles to the parent injector, then the module or standalone context injector, then the root injector, then the platform injector. The first recipe found wins, and only when every level misses do you see NullInjectorError.
This bubbling design is what makes providedIn: 'root' so powerful. The decorator registers the recipe with the root injector at build time, so every component in the app resolves the same shared instance without any module editing. Tree-shaking also benefits: if nothing injects the service, the compiler drops it from the bundle. You get a singleton and dead-code elimination from one line.
The bracket chain in R3InjectorError mirrors this walk. Standalone[CheckoutComponent] names the injector where resolution started, and each arrow names a dependent that needed the next token. Reading right-to-left shows the exact missing recipe. Most developers read left-to-right and blame the component, but the component is just the messenger. The fix always targets the rightmost token: register it where the failing subtree can see it, and everything left of the arrow resolves itself.
providedIn root vs providers Arrays: Picking One Owner
New Angular developers often register services in two places at once: providedIn: 'root' on the class plus an entry in some module's providers array. For eager modules this duplication is harmless because both point at the root injector. For lazy modules it silently forks the service: the lazy child injector builds its own instance, and state written through one route is invisible through another. Login works, then the header says logged out.
The rule is one owner per service. Shared state like auth, cart, and API clients belongs to the root injector via providedIn: 'root', with zero providers entries anywhere. Deliberately scoped state, like a wizard's draft data, belongs in the providers array of the component or lazy route that owns it. Document the scoping choice with a comment, because the next developer will otherwise hoist it to root during cleanup and reintroduce the bug in reverse.
Choosing wrong also affects bundle size and testability. Root-provided services tree-shake when unused and inject cleanly into any TestBed. Component-scoped services can't be injected outside their subtree, which makes cross-component tests awkward. When a NullInjectorError names your own service token, check for double registration before adding yet another providers entry. Removing the extra registration beats adding a third.
Standalone Apps: provideHttpClient and importProvidersFrom
Standalone apps have no AppModule, so there is no imports array at the root that can carry provider registrations. Instead, bootstrapApplication takes a providers array, conventionally factored into app.config.ts. Functional providers like provideRouter() and provideHttpClient() register the same recipes that NgModules used to contribute, but they're tree-shakable: unused features never enter the bundle.
The migration trap is assuming old module imports still feed the root injector. A shared UI module that imports HttpClientModule registers that recipe in its own branch, not in the standalone bootstrap context. Your product page may work because it resolves through that branch, while checkout, served from a different branch, throws NullInjectorError for HttpClient. Both observations are true simultaneously, which is why the bug survives code review.
The bridge for half-migrated apps is importProvidersFrom(), which pulls an NgModule's providers into the standalone bootstrap context. Use it for third-party modules that haven't shipped functional providers yet, and prefer provideHttpClient() for first-party HTTP. Audit every module import you delete during migration: if the module registered providers, its deletion removes recipes, and each one needs a functional replacement in app.config.ts.
The Missing HttpClient Provider: DI's Most Common Victim
HttpClient is the most common NullInjectorError victim because its recipe never comes from your code. No decorator on your service can conjure it; only HttpClientModule in an NgModule imports array or provideHttpClient() in a standalone providers array registers it. Developers write a perfect root-provided service, inject HttpClient, and blame their own decorator when the real gap is the missing HTTP registration two files away.
The error chain gives it away: YourService -> HttpClient -> HttpClient ends in the framework token, not your class. That trailing repetition means Angular resolved your service fine and failed one level deeper. The fix never touches your service file. It touches app.module.ts or app.config.ts, which is exactly why the bug feels misplaced.
Prevention is a startup smoke test. A spec that injects HttpClient through your real appConfig fails the moment someone drops the registration, long before checkout breaks in production. Also centralize HTTP behind one or two root-provided API services instead of injecting HttpClient in twenty components. Fewer injection sites mean fewer chains to read, and interceptors attach in one place. When every request flows through a single service, a missing HttpClient provider breaks one obvious test instead of twenty mysterious components.
TestBed Injectors: Why Tests Fail When the App Works
TestBed builds a brand-new injector that inherits nothing from your app. A spec with an empty configureTestingModule has no HttpClient recipe, no router, and none of your root services unless you list them. The app can run perfectly while its specs fail with NullInjectorError, and the correct response is fixing the spec, not rewiring working app code.
For HTTP graphs the modern pair is provideHttpClient() plus provideHttpClientTesting(), which swaps the real backend for HttpTestingController. You assert requests with expectOne and flush fake responses, so specs never touch the network. The legacy equivalent is importing HttpClientTestingModule. Both approaches keep the testing injector shaped like the app injector without duplicating app.config.ts by hand.
For component specs, prefer NO_ERRORS_SCHEMA-free setups that declare the real dependency graph with mocks only at the boundary. Mock the service your component injects, not HttpClient three levels down: providers: [{ provide: CheckoutService, useValue: fakeService }]. This keeps specs fast and focused, and a NullInjectorError in the spec then points at the component's direct dependency instead of a framework token. Run the single spec file in isolation before editing anything; a green isolated run proves the failure was injector setup, not logic.
Reading the Bracket Chain: R3InjectorError Token Paths
The R3InjectorError format looks hostile but parses mechanically. R3InjectorError(Standalone[CheckoutComponent]) names the injector where resolution started. Each bracketed segment TypeA -> TypeB means TypeA's constructor needed TypeB. The final NullInjectorError: No provider for HttpClient names the token nobody registered. Fix that token and the whole chain collaps to resolved.
Three chain shapes cover nearly everything. A chain ending in your own service means the service lacks providedIn or a providers entry. A chain ending in HttpClient, ActivatedRoute, or another framework token means a module import or functional provider is missing at the owning level. A chain naming a component token means you're injecting across element-injector branches, usually a sibling, and need to restructure with @Input() or a shared root service.
Build a team habit around chains: paste the full chain into the bug report, name the rightmost token explicitly, and state which injector level should own it. Reports written this way get fixed in one pass because the diagnosis is already half done. Reports that say only checkout is broken invite a week of guessing. The chain is the framework handing you the answer; reading it right-to-left is the whole skill.
The Standalone Migration That Broke Checkout for 38 Minutes
- Standalone migration isn't mechanical: every NgModule import that registered providers needs a functional equivalent like provideHttpClient() in the bootstrap providers, or its recipe silently vanishes.
- A shared module's imports don't feed the root injector of a bootstrapped standalone app, so 'it's imported somewhere' is never proof the token resolves on the failing route.
- Smoke-test the real bootstrap path in CI: a five-line test that injects HttpClient through appConfig would have failed the migration PR before it ever reached production.
| File | Command / Code | Purpose |
|---|---|---|
| src | @Injectable({ providedIn: 'root' }) | How Angular's Injector Hierarchy Actually Resolves Tokens |
| src | @Injectable({ providedIn: 'root' }) | providedIn root vs providers Arrays |
| src | export const appConfig: ApplicationConfig = { | Standalone Apps |
| src | export interface Order { | The Missing HttpClient Provider |
| src | provideHttpClient, | TestBed Injectors |
Key takeaways
Common mistakes to avoid
5 patternsRegistering the same service in providedIn root and a lazy module's providers
Putting HttpClientModule in providers instead of imports
Importing HttpClientModule in a lazy feature module instead of the root
Testing an HttpClient service with an empty TestBed
Injecting a component-scoped token from a sibling subtree
Input(). Read the bracket chain in the error to find which injector boundary you crossed.Interview Questions on This Topic
What does NullInjectorError: No provider for X mean?
Frequently Asked Questions
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
That's Angular. Mark it forged?
5 min read · try the examples if you haven't