Angular Services and Dependency Injection: inject(), Providers, and Scopes
Components render; they should not know things. How to call the API, what’s in the cart, whether the user may delete — that knowledge belongs in services: plain TypeScript classes holding logic and state, handed to whoever needs them by Angular’s dependency injection system.
DI is simultaneously Angular’s most enterprise-flavored feature and its most misunderstood — newcomers treat it as ceremony for importing things. It’s not. It’s the machinery that makes a 500-component app testable, swappable, and consistent — the program-to-an-interface discipline with a framework enforcing it. This guide builds it from zero: services, inject(), provider scopes, tokens, and the testing payoff that justifies everything.
Services: Where Non-UI Logic Lives
A service is a class with a decorator:
import { Injectable, inject, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' })export class CartStore { private items = signal<CartItem[]>([]);
readonly count = computed(() => this.items().length); readonly totalPaise = computed(() => this.items().reduce((s, i) => s + i.paisePerUnit * i.qty, 0));
add(item: CartItem) { this.items.update((xs) => [...xs, item]); } clear() { this.items.set([]); }}And consumers ask for it rather than constructing it:
export class HeaderBadge { cart = inject(CartStore); // ← DI hands over the shared instance // template: {{ cart.count() }}}Why not just new CartStore() in each component? Three reasons that compound:
- Shared instances.
providedIn: 'root'means one CartStore for the whole app — every injector of it sees the same items.newwould give each component a private, useless copy. Singletons-by-default is exactly right for stores, API clients, and auth state. - Dependency graphs assemble themselves.
CartStoremight injectHttpClient, which needs interceptors, which need config… DI resolves the whole chain;newwould make every component reconstruct it. - Substitutability — the testing/flexibility payoff with its own section below.
The division of labor this creates is modern Angular’s architecture in one sentence: components render state and forward intents; services own state and logic. A component with business rules in it is a refactor waiting to happen; a service is where that logic becomes reusable across pages and trivially unit-testable (no DOM required).
inject(): The Modern Injection Idiom
Two syntaxes request dependencies; you’ll read both, write the first:
export class CheckoutPage { private cart = inject(CartStore); // modern: inject() in field initializers private router = inject(Router);
// legacy equivalent: // constructor(private cart: CartStore, private router: Router) {}}inject() won for practical reasons: it works in field initializers (composing with signal/computed setups), in functions (route guards, interceptors — which have no constructors at all), and it survives inheritance without constructor-parameter gymnastics. One rule governs it: inject() only works in an injection context — field initializers, constructors, and provider/guard factories. Calling it later (in a click handler) throws; inject at construction, use forever.
The injection request is by type — inject(CartStore) — and the type is also the default token DI resolves. Which raises the question the next section answers: resolved from where?
Providers and Scopes: Who Gets Which Instance
DI is a hierarchy of injectors: application-level (from app.config.ts and providedIn: 'root'), route-level, and element-level (component providers). A request walks up the tree from the requester until a provider is found — closest wins:
element injector (component providers) ← checked first ↑route injector (lazy route providers) ↑root injector (providedIn: 'root', appConfig)The scopes in practice:
providedIn: 'root' — the default and the workhorse: one app-wide instance, lazily created on first injection, tree-shaken away entirely if nothing injects it. Stores, API clients, auth: here.
Component providers — a fresh instance per component, shared with its children, destroyed with it:
@Component({ selector: 'app-wizard', providers: [WizardState], // each wizard gets its own state ...})This is the underused gem: two wizards on screen get independent WizardStates, children of each wizard inject their wizard’s instance, and cleanup is automatic (lifecycle-bound). Per-widget state machines, form coordinators, and anything “one per instance of this UI” belongs here — developers who miss this scope end up hand-building instance maps inside root services.
Route providers — scoped to a lazy-loaded route subtree: feature-wide but not global.
The lookup-walks-up rule is also the mechanism behind directives injecting their host component and component families (tabs finding their tab-group) — element DI is proximity-aware by design.
Beyond Classes: InjectionToken and Configurable Providers
Not everything injectable is a class. Configuration objects, primitives, and interface-shaped dependencies use tokens:
export interface ApiConfig { baseUrl: string; timeoutMs: number; }export const API_CONFIG = new InjectionToken<ApiConfig>('api.config');
providers: [ { provide: API_CONFIG, useValue: { baseUrl: 'https://api.example.com', timeoutMs: 8000 } },]
// consumerexport class OrderApi { private cfg = inject(API_CONFIG);}The provider recipes complete the toolkit: useValue (a ready object), useClass (substitute an implementation — { provide: PaymentGateway, useClass: RazorpayGateway }, the interface-swapping pattern verbatim), useFactory (compute it, injecting other deps), and useExisting (alias). You’ll configure far fewer providers than you inject — but reading these recipes is required literacy for app.config.ts files and library documentation, where provideRouter(routes) and friends are exactly these recipes behind a function.
The Payoff: Testing and Substitution
Here’s the section that converts DI skeptics. Because components ask for dependencies by token, tests can answer with fakes:
TestBed.configureTestingModule({ providers: [ { provide: OrderApi, useValue: fakeOrderApi }, // no HTTP in unit tests { provide: AuthStore, useValue: { user: signal(adminUser) } }, ],});The component under test runs against controlled collaborators — no network, no login flow, deterministic state — without changing a line of component code. Compare the alternative universe where components new their dependencies or import singletons directly: testing requires module-mocking gymnastics, and swapping a payment provider means editing every consumer. DI moves substitution from “invasive surgery” to “one provider line” — the same win constructor injection buys in any language, enforced by the framework.
The design habit that maximizes it: keep services narrow and purposeful (OrderApi, CartStore, PricingRules — not AppService), inject what you use, and let component tests fake at the service seam. Codebases that do this stay testable at scale by default; the seams were drawn from day one.
Layering Services: A Shape That Scales
One service class is easy; the skill is the layering when a feature grows. The structure that recurs in healthy large Angular codebases:
components (render + intents) ↓ injectstores (state + orchestration) CartStore, OrdersStore ↓ injectAPI services (HTTP boundary) OrderApi, ProductApi ↓ injectHttpClient (+ interceptors)Each layer knows only the one below: components never touch HttpClient directly (they’d bypass store state and duplicate loading logic); stores never format for display (that’s pipes and computeds at the component layer); API services never hold state (they’re stateless translators between endpoints and typed methods). The payoffs are concrete: swapping a backend touches only API services; testing a store fakes only its API service; testing a component fakes only its store. When you see a component injecting six services, or an API service caching results, the layer boundaries have blurred — the refactor is almost always “move this knowledge one layer toward where it belongs.”
A related judgment developers meet early: utility functions vs services. Pure, stateless helpers (formatting, math, validation functions) should be plain exported functions — modules already share them, no DI needed, trivially importable and testable. A service earns its decorator when it has state, dependencies, or needs substitution in tests. DateUtils as an injectable with no dependencies and no state is DI cosplay; CartStore as a plain module singleton is untestable state. Match the tool to what the code actually is.
Frequently Asked Questions
When is component-scoped state a service vs just signals in the component? Signals in the component until children need the state too — then a component-provided service gives the family shared state without input-drilling. Root services are for state that outlives or spans pages.
Can services inject services? Constantly — CartStore injecting OrderApi injecting HttpClient is the normal shape. The graph is resolved automatically; circular service dependencies throw at runtime and signal a design problem — extract the shared piece, as with module cycles.
What’s { optional: true }? inject(Analytics, { optional: true }) returns null instead of throwing when unprovided — right for genuinely optional integrations. Other modifiers (self, skipSelf, host) fine-tune the hierarchy walk; know they exist, reach for them rarely.
Why did my lazy route get a second instance of a “singleton”? It provided the service in its own providers array — creating a route-scoped instance shadowing root. The classic accidental-double-instance bug: provide once, at the intended scope.
How does DI behave during testing beyond provider substitution? TestBed is a configurable injector — which means scope rules apply in tests too: a component-provided service under test gets a fresh instance per component fixture (matching production), while root-provided fakes are shared across the test file unless you reset. Knowing the test injector mirrors the real hierarchy is what makes test behavior predictable rather than mysterious.
Is providedIn: 'root' with an unused service dead weight? No — tree-shakable providers mean unimported, uninjected services vanish from the bundle. This is why providedIn: 'root' beats registering everything in app.config.ts providers arrays.
Reading Legacy DI
Older codebases carry DI idioms worth decoding on sight. Constructor injection (constructor(private cart: CartStore) {}) — the dominant historical form; identical semantics to inject(), mechanical to migrate, no urgency to. @Injectable() without providedIn plus registration in an NgModule’s providers array — the pre-tree-shaking pattern; such services ship in the bundle whether used or not, and consolidating them to providedIn: 'root' is a safe modernization. @Inject(TOKEN) parameter decorators — the constructor-era syntax for token injection, now inject(TOKEN). forRoot()/forChild() static methods on modules — the module-era convention for “configure once at root, reference elsewhere,” whose job standalone-era provideX(config) functions took over; when a library’s docs still say SomeModule.forRoot(...), it’s the same pattern in older clothes. And injection hierarchies through NgModules — module-provided services scoped to lazy modules — map directly onto today’s route-level providers. None of these are wrong in place; the skill is recognizing each as an era’s answer to the same four questions this guide covered: what’s injectable, at what scope, configured how, substituted where.
One more habit that compounds: inject interfaces-in-spirit even without TypeScript interfaces. DI tokens are classes in Angular (erasure means interfaces don’t survive to runtime), so the pattern is an abstract class as the token — abstract class PaymentGateway { abstract charge(...): ... } — with implementations provided via useClass. Consumers depend on the abstraction; swaps and fakes stay one provider line. It’s the program-to-an-interface discipline, adapted to what the platform can express.
Self-Check
- Two components inject
CartStore(providedIn: 'root'). How many instances exist, and what does each component see? - A
StepperStateservice should be independent per<app-stepper>on the page. Where’s it provided? - Why does
inject(Router)inside a button’s click handler throw, and what’s the fix? - Write the provider line that substitutes
FakePaymentGatewayforPaymentGatewayapp-wide. - A component news up its API client:
private api = new OrderApi(new HttpService()). Name two concrete costs versus injection.
(Answers: one; both see the same signals and items — that’s the sharing mechanism. In the stepper component’s providers array. Click handlers aren’t injection contexts — inject in a field initializer and store the reference. { provide: PaymentGateway, useClass: FakePaymentGateway }. Tests can’t substitute the client (mocking requires module surgery), and the hand-built dependency chain bypasses interceptors/config the DI graph provides — plus every consumer duplicates construction knowledge.)
Next: Routing — URLs as application state: route configuration, params as component inputs, guards, and the navigation lifecycle.