Skip to main content

Services and Dependency Injection (DI)

Dependency Injection (DI) is a core Angular concept that doesn't exist in React/Next.js, representing a design philosophy unique to Angular.

1. What Are Services?

A service is a class that encapsulates business logic and data access concerns unrelated to the UI. Components focus on rendering, while HTTP communication, state management, and validation are delegated to services.

// user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface User {
id: number;
name: string;
email: string;
}

@Injectable({
providedIn: 'root',
})
export class UserService {
private readonly apiUrl = 'https://api.example.com/users';

constructor(private http: HttpClient) {}

getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl);
}

getUserById(id: number): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`);
}
}

Comparison with React

In React, business logic is separated through Custom Hooks or utility functions.

AngularReact / Next.js
@Injectable serviceCustom Hook (useUsers) / utility function
DI container resolves dependenciesDirect import
Singleton by defaultModule-scoped singleton
Replace via DI in testsJest mocking (jest.mock)

2. @Injectable Decorator

Adding the @Injectable() decorator marks a class as a DI target. Specifying providedIn: 'root' registers it as a singleton shared across the entire application.

import { Injectable } from '@angular/core';

@Injectable({
providedIn: 'root', // Registers in the application's root injector
})
export class LoggerService {
log(message: string): void {
console.log(`[App] ${message}`);
}
}

Generating a Service with the Angular CLI

ng generate service services/user
# or shorthand
ng g s services/user

Generated files:

src/app/services/
user.service.ts ← Service implementation
user.service.spec.ts ← Test file

3. How DI Works

Injector Hierarchy

Angular maintains multiple Injectors in a hierarchy. Dependency resolution searches from the lowest level upward.

Constructor Injection (Traditional Approach)

@Component({ selector: 'app-user-list', ... })
export class UserListComponent {
constructor(private userService: UserService) {}
}
import { Component, inject, OnInit } from '@angular/core';
import { UserService, User } from './user.service';

@Component({
selector: 'app-user-list',
template: `
@for (user of users; track user.id) {
<p>{{ user.name }}</p>
}
`,
})
export class UserListComponent implements OnInit {
private userService = inject(UserService);
users: User[] = [];

ngOnInit() {
this.userService.getUsers().subscribe((data) => (this.users = data));
}
}

The inject() function can be used outside the constructor, enabling factory function patterns similar to custom hooks.

Comparison with React

// React: Custom Hook
function useUsers() {
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
fetch('/api/users').then(r => r.json()).then(setUsers);
}, []);
return users;
}

// Angular: inject() function
function injectUsers(): Signal<User[]> {
const userService = inject(UserService);
const users = signal<User[]>([]);
userService.getUsers().subscribe(data => users.set(data));
return users.asReadonly();
}

4. Service Scopes

The lifecycle of a service is controlled by the providedIn option or the providers array.

A singleton instance shared across the entire application. Tree-shaking is supported, so unused services are not included in the bundle.

@Injectable({ providedIn: 'root' })
export class CartService { ... }

Component-Level Providers

Registering a service in the providers array creates an instance exclusive to that component and its descendants. The service is destroyed when the component is destroyed.

@Component({
selector: 'app-checkout',
providers: [CheckoutStateService], // Instance exclusive to this component
template: `...`,
})
export class CheckoutComponent { }

providedIn: 'platform'

Used when sharing a single instance across multiple Angular apps (e.g., micro-frontends).

@Injectable({ providedIn: 'platform' })
export class SharedAuthService { ... }
ScopeInstance CountUse Case
'root' (default)1 (singleton)Most services
Component providersOne per componentWizard state, etc.
'platform'One per platformMicro-frontend sharing

5. InjectionToken

Use InjectionToken when providing non-class values (strings, numbers, objects) via DI.

// tokens.ts
import { InjectionToken } from '@angular/core';

export interface AppConfig {
apiUrl: string;
maxRetries: number;
}

export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');
// app.config.ts (Root configuration for standalone apps)
import { ApplicationConfig } from '@angular/core';
import { APP_CONFIG } from './tokens';

export const appConfig: ApplicationConfig = {
providers: [
{
provide: APP_CONFIG,
useValue: {
apiUrl: 'https://api.example.com',
maxRetries: 3,
},
},
],
};
// Usage in a component or service
import { inject } from '@angular/core';
import { APP_CONFIG } from './tokens';

@Injectable({ providedIn: 'root' })
export class ApiService {
private config = inject(APP_CONFIG);

getBaseUrl() {
return this.config.apiUrl;
}
}

Comparison with React

// React: Context + Provider pattern
const AppConfigContext = createContext<AppConfig | null>(null);

export function AppConfigProvider({ children }: { children: ReactNode }) {
return (
<AppConfigContext.Provider value={{ apiUrl: '...', maxRetries: 3 }}>
{children}
</AppConfigContext.Provider>
);
}

export function useAppConfig() {
const ctx = useContext(AppConfigContext);
if (!ctx) throw new Error('AppConfigProvider required');
return ctx;
}

6. Practical Patterns

Creating an API Communication Service

// product.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface Product {
id: number;
name: string;
price: number;
}

@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private apiUrl = '/api/products';

getAll(category?: string): Observable<Product[]> {
let params = new HttpParams();
if (category) params = params.set('category', category);
return this.http.get<Product[]>(this.apiUrl, { params });
}

create(product: Omit<Product, 'id'>): Observable<Product> {
return this.http.post<Product>(this.apiUrl, product);
}

update(id: number, changes: Partial<Product>): Observable<Product> {
return this.http.patch<Product>(`${this.apiUrl}/${id}`, changes);
}

delete(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}

State Management Service (Using Signals)

// cart.service.ts
import { Injectable, signal, computed } from '@angular/core';

export interface CartItem {
productId: number;
name: string;
price: number;
quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
private _items = signal<CartItem[]>([]);

// Read-only public signal
readonly items = this._items.asReadonly();

readonly total = computed(() =>
this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);

readonly itemCount = computed(() =>
this._items().reduce((count, item) => count + item.quantity, 0)
);

addItem(item: CartItem): void {
this._items.update((items) => {
const existing = items.find((i) => i.productId === item.productId);
if (existing) {
return items.map((i) =>
i.productId === item.productId
? { ...i, quantity: i.quantity + item.quantity }
: i
);
}
return [...items, item];
});
}

removeItem(productId: number): void {
this._items.update((items) =>
items.filter((i) => i.productId !== productId)
);
}

clear(): void {
this._items.set([]);
}
}

Service-to-Service Dependencies

// order.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { CartService } from './cart.service';
import { AuthService } from './auth.service';
import { tap } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class OrderService {
private http = inject(HttpClient);
private cartService = inject(CartService);
private authService = inject(AuthService);

placeOrder() {
const userId = this.authService.currentUser()?.id;
const items = this.cartService.items();

return this.http
.post('/api/orders', { userId, items })
.pipe(tap(() => this.cartService.clear())); // Clear cart after order
}
}

Mock Injection for Testing

Angular's DI makes it easy to swap real services for mocks in tests.

// user-list.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { UserListComponent } from './user-list.component';
import { UserService } from './user.service';
import { of } from 'rxjs';

describe('UserListComponent', () => {
let mockUserService: jasmine.SpyObj<UserService>;

beforeEach(() => {
mockUserService = jasmine.createSpyObj('UserService', ['getUsers']);
mockUserService.getUsers.and.returnValue(
of([{ id: 1, name: 'Alice', email: 'alice@example.com' }])
);

TestBed.configureTestingModule({
imports: [UserListComponent],
providers: [
{ provide: UserService, useValue: mockUserService }, // Inject mock
],
});
});

it('should display users', () => {
const fixture = TestBed.createComponent(UserListComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('p')?.textContent).toContain('Alice');
});
});