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.