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
@Injectablethat 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
// 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:
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
// 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
| Provider | What it does | Typical use |
|---|---|---|
useClass | Swap in a different class | Replace ConsoleLogger with FileLogger |
useValue | Provide a static value | Config constants, feature flags |
useFactory | Build instance via function | Instance needs runtime config or deps |
useExisting | Alias to another token | Backward-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.
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:
@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:
@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:
// 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
HttpClientis itself a DI service, provided byprovideHttpClient()in standalone apps- Angular Material's
MatDialogis a root-level service injected wherever a modal is needed - NgRx
Storeis injected as a service into any component or effect that reads state - AngularFire's
Firestoreservice 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
// 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 messageThe 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()
// 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
// 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 elseThe 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 readyA concise answer to help you respond confidently on this topic during an interview.