1. Providers & providedIn Strategies
@Injectable({ providedIn: 'root' }) is the default you've been using all
along — it registers a service with the application's root injector, and Angular is
smart enough to tree-shake it entirely out of your production bundle if
nothing ever actually injects it.
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class NotificationService {
notify(message: string) {
console.log('[notify]', message);
}
}
You can also provide a service to a single component's subtree instead of the whole app — every instance of that component (and its children) gets its own instance of the service, isolated from every other instance:
import { Component } from '@angular/core';
import { TabStateService } from './tab-state.service';
@Component({
selector: 'app-tab-group',
templateUrl: './tab-group.html',
providers: [TabStateService], // a fresh instance per <app-tab-group>
})
export class TabGroup {}
This is the right call whenever a service holds state that belongs to one specific
instance of a component, not the whole app — two <app-tab-group>
elements on the same page shouldn't share which tab is selected.
2. inject() vs. Constructor Injection
Constructor injection still works exactly as you'd expect, and remains completely valid:
export class OrderSummary {
constructor(private notifications: NotificationService) {}
}
inject() gets the same dependency without a constructor parameter — and,
critically, works in places a constructor can't: field initializers, functional route
guards, functional interceptors, and anywhere else that runs inside Angular's
injection context.
import { inject } from '@angular/core';
export class OrderSummary {
private notifications = inject(NotificationService);
}
A route guard written as a plain function has no constructor at all — inject() is the only way for it to get a service. This is exactly why Angular's router, interceptors, and resolvers all moved to a function-based API: inject() made dependency access possible without requiring a class.
3. Injection Tokens & Multi-Providers
Classes aren't the only thing you can inject. An InjectionToken lets you
register and inject plain values — configuration objects, primitives, anything — with
the same type safety as a class dependency.
import { InjectionToken } from '@angular/core';
export interface ApiConfig {
baseUrl: string;
timeoutMs: number;
}
export const API_CONFIG = new InjectionToken<ApiConfig>('API_CONFIG');
import { API_CONFIG } from './api-config';
export const appConfig: ApplicationConfig = {
providers: [
{ provide: API_CONFIG, useValue: { baseUrl: '/api', timeoutMs: 5000 } },
],
};
multi: true takes this further — instead of one provider "winning," Angular
collects every provider registered against the same token into an array. This
is exactly how you'll register a set of pluggable validators or transports (this week's
exercise) without a giant hardcoded if/else:
export const NOTIFICATION_TRANSPORT = new InjectionToken<NotificationTransport>('NOTIFICATION_TRANSPORT');
// In app.config.ts:
providers: [
{ provide: NOTIFICATION_TRANSPORT, useClass: ToastTransport, multi: true },
{ provide: NOTIFICATION_TRANSPORT, useClass: ConsoleTransport, multi: true },
]
// Injecting it anywhere gives you the full array:
private transports = inject(NOTIFICATION_TRANSPORT); // NotificationTransport[]
4. Hierarchical Injectors & Component-Level Providers
Angular's injectors form a tree that mirrors your component tree. When a component asks for a dependency, Angular looks for a provider starting at that component, then walks up through its ancestors until it finds one — the root injector is the final fallback.
Root injector (providedIn: 'root' services)
└── DashboardComponent injector (its own `providers: [...]`, if any)
└── WidgetComponent injector
└── WidgetComponent asks for a service --
Angular checks here first, then Widget's parent,
then Dashboard, then root -- first match wins.
This is why re-providing a service on a component (Section 1's TabGroup
example) works: every descendant of that component resolves to the local
instance, not the app-wide singleton, because the local injector is checked first.
import { Optional, Self } from '@angular/core';
export class WidgetComponent {
constructor(
@Optional() private analytics?: AnalyticsService, // won't throw if nothing provides it
@Self() private tabState?: TabStateService, // must be provided on THIS component exactly
) {}
}
You'll rarely need these two decorators, but they're worth recognizing: @Optional()
makes a dependency non-fatal if missing, and @Self() restricts resolution to
the component's own injector, refusing to walk up the tree at all.
5. Environment-Based Configuration & Feature Flags
Combine what you learned in Week 2 (environment files) with this week's
InjectionToken pattern, and you get a clean, testable way to configure an
app per environment:
import { environment } from '../environments/environment';
import { API_CONFIG } from './api-config';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: API_CONFIG,
useValue: { baseUrl: environment.apiUrl, timeoutMs: 5000 },
},
],
};
Feature flags follow the same shape — a small injectable service backed by whatever
source makes sense (a config file, a remote flag service, localStorage for
local overrides during development):
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class FeatureFlags {
private flags = signal(new Set(['new-checkout']));
isEnabled(flag: string): boolean {
return this.flags().has(flag);
}
}
Because it's a normal injectable service, swapping the real implementation for a fake one in tests (Section 6) is exactly as easy as swapping any other dependency.
6. Testing Services in Isolation
A service with constructor-injected dependencies can often be tested with zero Angular testing machinery at all — just instantiate the class directly with fakes:
import { NotificationService } from './notification.service';
describe('NotificationService', () => {
it('logs a message', () => {
const logSpy = jasmine.createSpy('log');
const service = new NotificationService({ log: logSpy } as any);
service.notify('Order placed');
expect(logSpy).toHaveBeenCalledWith('Order placed');
});
});
When a service relies on inject() internally, it needs an injection
context to run at all — that's what TestBed provides, letting you override
specific dependencies with fakes while leaving the rest wired normally:
import { TestBed } from '@angular/core/testing';
import { NotificationService } from './notification.service';
import { NOTIFICATION_TRANSPORT } from './notification-transport.token';
describe('NotificationService', () => {
it('sends to every registered transport', () => {
const fakeTransport = { send: jasmine.createSpy('send') };
TestBed.configureTestingModule({
providers: [
NotificationService,
{ provide: NOTIFICATION_TRANSPORT, useValue: [fakeTransport], multi: false },
],
});
const service = TestBed.inject(NotificationService);
service.notify('Order placed');
expect(fakeTransport.send).toHaveBeenCalledWith('Order placed');
});
});
You'll build on this exact pattern in Week 18, when unit testing gets a full lesson — for now, the goal is simply recognizing that a well-designed service, with its dependencies injected rather than hardcoded, is naturally easy to test.
7. Hands-on Exercise
Build a notification service with a swappable transport
Put Sections 3 and 4 together into a working, pluggable notification system.
Requirements:
- Define a
NotificationTransportinterface with one method:send(message: string): void. - Create a
NOTIFICATION_TRANSPORTinjection token, and two implementations:ToastTransport(render a temporary on-screen element) andConsoleTransport(log to the console). - Register both as
multi: trueproviders for the token inapp.config.ts. - Build
NotificationService, injecting the token (so it receives the full array) and looping through every registered transport in itsnotify()method. - Add an environment-based
API_CONFIG-style token that controls whetherToastTransportis included at all — e.g. disabled in a "quiet mode" configuration. - Write at least one isolated test (Section 6's pattern) proving
notify()callssend()on every registered transport.
If injecting NOTIFICATION_TRANSPORT gives you a single instance instead of an array, double-check every provider registered against that token includes multi: true — forgetting it on even one provider changes the injected shape.
8. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What does "tree-shakable" mean for a providedIn: 'root' service, concretely?
What does "tree-shakable" mean for a providedIn: 'root' service, concretely?
If nothing in your app ever injects that service, the build tool can detect it's unreachable and drop it from the production bundle entirely — you pay zero bytes for a service you registered but never actually used. This works because providedIn: 'root' ties the provider registration directly to the class itself, rather than to a separate providers list a bundler would have to assume is fully used.
Q2
Why can a functional route guard use inject() but not constructor injection?
Why can a functional route guard use inject() but not constructor injection?
A functional guard is a plain function, not a class — there's no constructor to inject into. inject() works because Angular runs the guard inside an active injection context, and inject() reads from whatever injection context is currently active, regardless of whether that context is a class constructor or not.
Q3
If three providers are registered against the same multi: true token, in what order does the injected array contain them?
If three providers are registered against the same multi: true token, in what order does the injected array contain them?
In registration order — the order the providers appear in the providers array(s) Angular resolves, from higher up the injector tree to lower. It's worth keeping in mind if execution order matters for what you're building (interceptors are the clearest example, later in Week 14) — don't assume a particular transport or handler runs first without checking where it's actually registered.
Q4
Component A provides TabStateService. Its child, component B, injects it. Its sibling, component C (not inside A), also injects it. Same instance, or different?
Component A provides TabStateService. Its child, component B, injects it. Its sibling, component C (not inside A), also injects it. Same instance, or different?
Different — assuming this is the only place the service is provided below root. B, being inside A's subtree, resolves to the instance A provided. C, being outside that subtree, keeps walking up past where A would be and either finds a different provider higher up or falls back to the root singleton — it never sees A's local instance at all, because injector resolution only walks up an element's own ancestor chain, never sideways.