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.
| Angular | React / Next.js |
|---|---|
@Injectable service | Custom Hook (useUsers) / utility function |
| DI container resolves dependencies | Direct import |
| Singleton by default | Module-scoped singleton |
| Replace via DI in tests | Jest 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) {}
}
inject() Function (Angular 14+, Recommended)
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.
providedIn: 'root' (Default, Recommended)
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 { ... }
| Scope | Instance Count | Use Case |
|---|---|---|
'root' (default) | 1 (singleton) | Most services |
Component providers | One per component | Wizard state, etc. |
'platform' | One per platform | Micro-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');
});
});