Skip to main content

Services and dependency injection in Angular

Angular services and dependency injection work together: services are @Injectable classes that hold shared logic, and DI is the mechanism that delivers them to components through constructor parameters or the inject() function, without any manual new calls.

Theory

TL;DR

  • A service is a plain class with @Injectable that holds logic shared across components (API calls, state, validation)
  • DI delivers instances automatically: no new ServiceName() in your components
  • @Injectable({ providedIn: 'root' }) creates one app-wide singleton, tree-shakable by default
  • Injectors form a hierarchy: root, module, component; child injectors inherit from parent
  • Decision rule: logic needed in 2+ components goes into a service; component-only state stays in the component

Quick example

typescript
// user.service.ts import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) // one instance for the whole app export class UserService { constructor(private http: HttpClient) {} // Angular injects HttpClient getUsers(): Observable<User[]> { return this.http.get<User[]>('/api/users'); } } // user-list.component.ts @Component({ selector: 'app-user-list', template: '...' }) export class UserListComponent implements OnInit { constructor(private userService: UserService) {} // Angular resolves this ngOnInit() { this.userService.getUsers().subscribe(users => this.users = users); } }

UserService never calls new HttpClient(). The component never calls new UserService(). Angular's injector reads constructor parameter types at runtime and passes in the right instances. That is the entire idea.

What DI solves

Without DI, a component that needs HTTP calls would hardcode the setup directly, or create its own UserService instance. Swapping that in a test means rewriting the component. With DI, you change one line in TestBed.configureTestingModule:

typescript
providers: [{ provide: UserService, useValue: mockUserService }]

The component stays untouched. DI exists for testability and flexibility, not for magic.

How the injector resolves dependencies

Angular's injector is a tree. At the top sits the root injector, created at app bootstrap. Below it: module injectors, then component injectors. When a component requests a service, Angular walks up the tree until it finds a matching provider.

@Injectable({ providedIn: 'root' }) registers at the root. One instance, shared everywhere. Lazy-loaded modules get their own child injector branch; services declared in a lazy module's providers: [] exist only in that branch. Component-level providers (providers: [SomeService] inside @Component) create a fresh instance for that component and its children, destroyed when the component is destroyed.

TypeScript's emitDecoratorMetadata flag makes all of this work. At compile time, decorators write parameter type metadata into the class. At runtime, the injector reads design:paramtypes and uses the class constructor itself as the injection token. No string identifiers needed.

Providing services: three patterns

typescript
// 1. Root singleton - most common, tree-shakable @Injectable({ providedIn: 'root' }) export class AuthService {} // 2. Component-scoped - fresh instance per component subtree @Component({ selector: 'app-checkout', providers: [CartService] // isolated, destroyed with the component }) export class CheckoutComponent {} // 3. Injection token for non-class values export const API_URL = new InjectionToken<string>('API_URL'); // register providers: [{ provide: API_URL, useValue: 'https://api.example.com' }] // consume private apiUrl = inject(API_URL);

Provider types

ProviderWhat it doesTypical use
useClassSwap in a different classReplace ConsoleLogger with FileLogger
useValueProvide a static valueConfig constants, feature flags
useFactoryBuild instance via functionInstance needs runtime config or deps
useExistingAlias to another tokenBackward-compatible service rename

Modern inject() function

Angular 14 introduced inject() as an alternative to constructor injection. It works in field initializers and is the preferred style in standalone components.

typescript
import { inject } from '@angular/core'; @Component({ selector: 'app-dashboard', template: '...' }) export class DashboardComponent { private userService = inject(UserService); private apiUrl = inject(API_URL); // no constructor block needed }

Constructor injection still works and is common in older codebases. Both are valid. Most teams pick one style per project and stay consistent.

Common mistakes

In practice, the scoping issue trips up more teams than anything else: services that should live at component level end up in root, and state bleeds between views that should be isolated.

Missing provider registration:

typescript
@Injectable() // no providedIn - not registered anywhere export class DataService {} // NullInjectorError: No provider for DataService!

Fix: @Injectable({ providedIn: 'root' }) for a singleton, or add to the relevant providers: [].

Bypassing DI with new:

typescript
@Component({...}) export class MyComponent { private service = new DataService(/* what goes here? */); }

DataService needs HttpClient. You would have to instantiate that too and set up the whole HTTP stack. Untestable. Use constructor injection or inject() instead.

Circular dependency:

typescript
// user.service.ts constructor(private auth: AuthService) {} // auth.service.ts constructor(private user: UserService) {} // A needs B, B needs A // Cannot instantiate cyclic dependency!

Fix: extract the shared logic into a third service. Or inject lazily using inject() inside a method instead of at class initialization.

Accidental provider override: if two providers register for the same token in the same injector, the last one wins silently. Useful for intentional mocking; surprising when it happens by accident during a module merge.

Real-world usage

  • HttpClient is itself a DI service, provided by provideHttpClient() in standalone apps
  • Angular Material's MatDialog is a root-level service injected wherever a modal is needed
  • NgRx Store is injected as a service into any component or effect that reads state
  • AngularFire's Firestore service follows the same pattern for database access
  • In NX monorepos, the generator scaffolds services with providedIn: 'root' out of the box

Follow-up questions

Q: What is the difference between providedIn: 'root' and module-level providers: []?
A: providedIn: 'root' creates a single app-wide instance and is tree-shakable: if nothing injects the service, it does not end up in the bundle. Module-level providers: [] scopes to that module's injector; lazy-loaded modules create their own instance if they declare the provider themselves.

Q: How does Angular match constructor parameters to providers without string identifiers?
A: TypeScript with emitDecoratorMetadata: true writes parameter type info as design:paramtypes on the class. The injector reads this at runtime and uses the class constructor itself as the injection token.

Q: How do hierarchical injectors work when a lazy module loads?
A: The lazy module gets a child injector that inherits from the root. Services declared in the lazy module's providers: [] are isolated to that branch. A providedIn: 'root' service is always the same shared instance regardless of where it is injected.

Q: How do you mock a service in unit tests?
A: Pass a mock with providers: [{ provide: UserService, useValue: { getUsers: () => of([]) } }] in TestBed.configureTestingModule. The component gets the mock, not the real service. Access the same instance in the test body with TestBed.inject(UserService).

Q: (Senior) When would you choose useFactory with deps over useClass?
A: When the service instance needs values unavailable at class definition time. A logging service that sets a per-feature prefix, or an interceptor that reads a runtime config object injected from the parent. The deps array tells Angular which providers to resolve and pass as arguments to the factory function.

Examples

Basic: constructor injection with error handling

typescript
// user.service.ts import { Injectable } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class UserService { private usersUrl = 'https://jsonplaceholder.typicode.com/users'; constructor(private http: HttpClient) {} getUsers(): Observable<User[]> { return this.http.get<User[]>(this.usersUrl).pipe( catchError((error: HttpErrorResponse) => throwError(() => new Error(`Failed to load users: ${error.message}`)) ) ); } } // user-list.component.ts @Component({ selector: 'app-user-list', template: ` <ul *ngIf="users.length"> <li *ngFor="let user of users">{{ user.name }}</li> </ul> <p *ngIf="error">{{ error }}</p> ` }) export class UserListComponent implements OnInit { users: User[] = []; error = ''; constructor(private userService: UserService) {} ngOnInit() { this.userService.getUsers().subscribe({ next: (users) => this.users = users, error: (err) => this.error = err.message }); } } // Loads from JSONPlaceholder, shows names or an error message

The service handles HTTP errors in one place. Every component that calls getUsers() gets consistent error handling without repeating the catchError logic.

Intermediate: component-scoped service with inject()

typescript
// form-state.service.ts - scoped to the checkout component subtree @Injectable() // no providedIn - provided at component level below export class FormStateService { private formData: Partial<Order> = {}; update(data: Partial<Order>) { this.formData = { ...this.formData, ...data }; } get(): Partial<Order> { return this.formData; } } // checkout.component.ts @Component({ selector: 'app-checkout', template: '<app-address-form /><app-payment-form />', providers: [FormStateService] // new instance, destroyed with this component }) export class CheckoutComponent { private state = inject(FormStateService); } // address-form.component.ts - child inherits the same instance @Component({ selector: 'app-address-form', template: '...' }) export class AddressFormComponent { private state = inject(FormStateService); // same instance as CheckoutComponent }

FormStateService shares state between CheckoutComponent and its child AddressFormComponent, but a different checkout session elsewhere in the app gets a completely separate instance. This is the component-scoped injector pattern in practice.

Advanced: factory provider with injection token

typescript
// logger.service.ts @Injectable({ providedIn: 'root' }) export class LoggerService { prefix = ''; log(msg: string) { console.log(`${this.prefix}${msg}`); } } // feature.module.ts import { InjectionToken, NgModule } from '@angular/core'; import { LoggerService } from './logger.service'; export const FEATURE_LOGGER = new InjectionToken<LoggerService>('FeatureLogger'); @NgModule({ providers: [{ provide: FEATURE_LOGGER, useFactory: (rootLogger: LoggerService) => { const logger = new LoggerService(); logger.prefix = '[Feature] '; return logger; // separate instance, different prefix }, deps: [LoggerService] // pulls root LoggerService as factory argument }] }) export class FeatureModule {} // feature.component.ts @Component({ template: '<p>Check console</p>' }) export class FeatureComponent { constructor(@Inject(FEATURE_LOGGER) private logger: LoggerService) { this.logger.log('Loaded'); // logs "[Feature] Loaded" } } // Root LoggerService logs without prefix everywhere else

The factory creates a dedicated logger for the feature module. It pulls LoggerService from the parent injector via deps, then customizes its own copy. The root logger stays unchanged. This is how Angular Material and other libraries customize behavior per lazy module.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?