Services & DI
Dependency injection hierarchy, providers, singleton vs scoped services
Services in Angular are singleton objects that share data and logic across components. Angular's dependency injection (DI) system is hierarchical — injectors at different levels (root, component, module) create isolated or shared instances. Understanding the injector tree is key to avoiding stale state and unintended sharing.
Key Points
- @Injectable({ providedIn: 'root' }): singleton at root injector — one instance for the entire app, tree-shaken if unused
- Injector hierarchy: root (app-level) → environment (feature module / lazy-loaded) → component (component tree) → element
- providedIn: 'root' vs providers: [Service] in component: component-level creates a new instance per component subtree
- useClass, useValue, useFactory, useExisting: alternative DI tokens for flexible bean definition
- InjectionToken<T>: type-safe token for non-class values — strings, numbers, configs
- inject() function (Angular 14+): functional alternative to constructor injection — usable in guards, resolvers, and utility functions
- HTTP Interceptors (functional, Angular 15+): withInterceptors([authInterceptor]) — add auth headers, handle errors globally
- Caching in services: BehaviorSubject or signal to hold and share state across components
- Service communication pattern: smart components subscribe to services; dumb components receive @Input and emit @Output
Angular services: root singleton with signal cache, InjectionToken for config, functional inject(), HTTP interceptor
// Root singleton service with caching
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly http = inject(HttpClient);
private readonly cache = signal<User[]>([]);
readonly users = this.cache.asReadonly();
loadUsers(): Observable<User[]> {
return this.http.get<User[]>('/api/users').pipe(
tap(users => this.cache.set(users))
);
}
addUser(user: User): void {
this.cache.update(current => [...current, user]);
}
}
// InjectionToken — type-safe config value
export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');
// In app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
{ provide: APP_CONFIG, useValue: { apiUrl: '/api', timeout: 5000 } }
]
};
// Inject token with inject()
@Injectable({ providedIn: 'root' })
export class ApiService {
private config = inject(APP_CONFIG);
private baseUrl = this.config.apiUrl;
}
// Functional HTTP interceptor (Angular 15+)
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).getToken();
if (!token) return next(req);
return next(req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`)
})).pipe(
catchError(err => {
if (err.status === 401) inject(Router).navigate(['/login']);
return throwError(() => err);
})
);
};
// Register in app.config.ts
provideHttpClient(withInterceptors([authInterceptor]))Real-World Example
providedIn: 'root' means Angular tree-shakes the service if no component ever injects it — important for keeping bundle size small. Providing a service in a lazy-loaded module creates a new instance when the module loads and destroys it when the module is destroyed — ideal for feature-specific state that should not persist across feature navigation.