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.