Introduction to Angular Services
Services in Angular are a fundamental building block that provide a way to organize and share code across your application. They're typically used for tasks that aren't directly related to the view layer, such as:
- Data fetching from servers
- User authentication
- Logging
- Business logic
- Shared functionality
- State management
Real-world analogy: If components are like the various stations in a factory (the assembly line, quality control, etc.), then services are like the utility providers (electricity, water, maintenance) that support multiple stations. They provide essential functions that many parts of the factory need, but don't belong to any specific station.
Creating and Using Services
Let's explore how to create and use services in Angular:
Creating a Basic Service
We can create a service using the Angular CLI:
ng generate service services/data
# or the shorthand
ng g s services/data
This creates a service with the injectable decorator:
// data.service.ts
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class DataService {
constructor() { }
getData() {
return [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' }
];
}
}
Using a Service in a Component
We can use this service in a component by injecting it:
// item-list.component.ts
import { Component, OnInit } from '@angular/core';
import { DataService } from '../services/data.service';
@Component({
selector: 'app-item-list',
template: `
<h2>Items</h2>
<ul>
<li *ngFor="let item of items">
{{ item.name }}
</li>
</ul>
`
})
export class ItemListComponent implements OnInit {
items: any[] = [];
constructor(private dataService: DataService) { }
ngOnInit(): void {
this.items = this.dataService.getData();
}
}
Key benefits of this approach:
- The component doesn't need to know where the data comes from
- The data fetching logic is encapsulated in the service
- Multiple components can use the same service
- The service can be easily replaced or mocked for testing
Understanding Dependency Injection
Dependency Injection (DI) is a design pattern where a class receives its dependencies from external sources rather than creating them itself.
Real-world analogy: Think of DI like a professional kitchen. When a chef (component) needs a knife (service), they don't make the knife themselves or go to the store to buy one. Instead, the kitchen manager (Angular's DI system) provides the appropriate knife from the kitchen's inventory. This allows chefs to focus on cooking (component logic) rather than tool creation or acquisition.
How Angular's DI Works
service in constructor] --> B[Angular's DI system
checks injector hierarchy] B --> C{Service
exists?} C -->|Yes| D[Return existing
service instance] C -->|No| E[Create new
service instance] E --> F[Register instance
in injector] F --> D D --> G[Service injected
into component] style A fill:#C3002F,color:white style B fill:#DD0031,color:white style C fill:#DD0031,color:white style D fill:#C3002F,color:white style E fill:#C3002F,color:white style F fill:#DD0031,color:white style G fill:#C3002F,color:white
The Injectable Decorator
The @Injectable() decorator marks a class as available to the DI system:
@Injectable({
providedIn: 'root'
})
export class LoggingService {
log(message: string) {
console.log(`LOG: ${message}`);
}
}
The providedIn: 'root' option makes the service available throughout the application as a singleton.
Injection Hierarchy
Angular's DI system uses a hierarchical injector system that follows the component tree:
providedIn: 'root'] --> B[ModuleInjector
NgModule providers] B --> C[ElementInjector
Component providers] C --> D[Child Component
ElementInjector] style A fill:#DD0031,color:white style B fill:#C3002F,color:white style C fill:#C3002F,color:white style D fill:#C3002F,color:white
When a component requests a service:
- Angular first checks the injector of that component
- If not found, it checks the parent component's injector
- It continues up the ancestry chain
- If still not found, it checks module injectors
- Finally, it checks the root injector
Service Registration: Provider Options
There are several ways to register services with Angular's DI system:
1. Root-level Providers (Application-wide Singleton)
@Injectable({
providedIn: 'root'
})
export class DataService { }
// This is equivalent to:
@NgModule({
providers: [DataService]
})
export class AppModule { }
2. Module-level Providers (Module-wide Singleton)
@Injectable({
providedIn: SomeModule
})
export class DataService { }
// Or in the module declaration:
@NgModule({
providers: [DataService]
})
export class SomeModule { }
3. Component-level Providers (Component-specific Instance)
@Component({
selector: 'app-some-component',
templateUrl: './some.component.html',
providers: [DataService]
})
export class SomeComponent { }
Different Provider Syntaxes
// Shorthand (class is both the token and the implementation)
providers: [DataService]
// Expanded (useClass)
providers: [
{ provide: DataService, useClass: DataService }
]
// Using a different implementation
providers: [
{ provide: DataService, useClass: MockDataService }
]
// Using a factory
providers: [
{
provide: DataService,
useFactory: () => {
return environment.production
? new ProductionDataService()
: new MockDataService();
}
}
]
// Using a value
providers: [
{
provide: 'API_URL',
useValue: 'https://api.example.com'
}
]
// Using an existing service
providers: [
{
provide: LogService,
useExisting: ConsoleLogService
}
]
Service Lifecycles Based on Provider Level
Root-level Providers
- Created when the application starts
- Destroyed when the application shuts down
- Same instance shared across all components
- Ideal for: app-wide services like authentication, logging
Module-level Providers
- Created when the module is loaded
- Shared among components within the module
- Destroyed when the module is unloaded (lazy-loaded modules)
- Ideal for: feature-specific services
Component-level Providers
- Created when the component is created
- Destroyed when the component is destroyed
- New instance for each component instance
- Ideal for: component-specific state or behavior
Injection Tokens
Injection tokens provide a way to identify dependencies beyond just class types:
// Defining a string token
import { InjectionToken } from '@angular/core';
export const API_URL = new InjectionToken<string>('api.url');
// Providing a value with the token
@NgModule({
providers: [
{ provide: API_URL, useValue: 'https://api.example.com' }
]
})
export class AppModule { }
// Injecting the value
import { Inject } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class DataService {
constructor(@Inject(API_URL) private apiUrl: string) {
console.log(`Using API URL: ${apiUrl}`);
}
}
When to use Injection Tokens:
- For values that aren't classes (strings, numbers, objects)
- For configuration values
- For providing interfaces (which don't exist at runtime in TypeScript)
- To avoid naming conflicts
Service Design Patterns
Let's explore some common patterns for creating and using services in Angular applications:
Data Service Pattern
Data services encapsulate data access logic, often communicating with APIs:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { User } from '../models/user.model';
@Injectable({
providedIn: 'root'
})
export class UserService {
private apiUrl = 'https://api.example.com/users';
constructor(private http: HttpClient) { }
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl)
.pipe(
catchError(this.handleError)
);
}
getUser(id: number): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`)
.pipe(
catchError(this.handleError)
);
}
createUser(user: User): Observable<User> {
return this.http.post<User>(this.apiUrl, user)
.pipe(
catchError(this.handleError)
);
}
updateUser(user: User): Observable<User> {
return this.http.put<User>(`${this.apiUrl}/${user.id}`, user)
.pipe(
catchError(this.handleError)
);
}
deleteUser(id: number): Observable<any> {
return this.http.delete(`${this.apiUrl}/${id}`)
.pipe(
catchError(this.handleError)
);
}
private handleError(error: any): Observable<never> {
console.error('An error occurred:', error);
return throwError(() => error);
}
}
Singleton State Service Pattern
State services maintain shared state across components:
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { User } from '../models/user.model';
@Injectable({
providedIn: 'root'
})
export class UserStateService {
private currentUserSubject = new BehaviorSubject<User | null>(null);
currentUser$ = this.currentUserSubject.asObservable();
private isLoadingSubject = new BehaviorSubject<boolean>(false);
isLoading$ = this.isLoadingSubject.asObservable();
constructor() { }
setCurrentUser(user: User): void {
this.currentUserSubject.next(user);
}
clearCurrentUser(): void {
this.currentUserSubject.next(null);
}
setLoading(isLoading: boolean): void {
this.isLoadingSubject.next(isLoading);
}
// If we need the current value synchronously
get currentUser(): User | null {
return this.currentUserSubject.value;
}
get isLoading(): boolean {
return this.isLoadingSubject.value;
}
}
Utility/Helper Service Pattern
Utility services provide reusable functionality:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class UtilityService {
constructor() { }
formatDate(date: Date): string {
// Custom date formatting logic
return date.toLocaleDateString();
}
formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency
}).format(amount);
}
debounce(func: Function, wait: number): Function {
let timeout: any;
return function(...args: any[]) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
generateUUID(): string {
// UUID generation logic
return 'xxxxx-xxxxx-xxxxx-xxxxx'.replace(/x/g, () => {
return Math.floor(Math.random() * 16).toString(16);
});
}
}
Facade Service Pattern
Facade services simplify complex interactions by providing a unified API:
import { Injectable } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { UserService } from './user.service';
import { AuthService } from './auth.service';
import { UserStateService } from './user-state.service';
import { User } from '../models/user.model';
@Injectable({
providedIn: 'root'
})
export class UserFacadeService {
// Expose state as Observables
currentUser$ = this.userStateService.currentUser$;
isLoading$ = this.userStateService.isLoading$;
constructor(
private userService: UserService,
private authService: AuthService,
private userStateService: UserStateService
) { }
loadUser(id: number): Observable<User> {
this.userStateService.setLoading(true);
return this.userService.getUser(id).pipe(
tap(user => {
this.userStateService.setCurrentUser(user);
this.userStateService.setLoading(false);
}),
catchError(error => {
this.userStateService.setLoading(false);
return throwError(() => error);
})
);
}
updateUserProfile(userChanges: Partial<User>): Observable<User> {
if (!this.userStateService.currentUser) {
return throwError(() => new Error('No user loaded'));
}
const updatedUser = {
...this.userStateService.currentUser,
...userChanges
};
this.userStateService.setLoading(true);
return this.userService.updateUser(updatedUser).pipe(
tap(user => {
this.userStateService.setCurrentUser(user);
this.userStateService.setLoading(false);
}),
catchError(error => {
this.userStateService.setLoading(false);
return throwError(() => error);
})
);
}
// Complex operation involving multiple services
registerAndLoadUser(newUser: User): Observable<User> {
this.userStateService.setLoading(true);
return this.authService.register(newUser).pipe(
switchMap(response => this.authService.login(newUser.email, newUser.password)),
switchMap(authResponse => this.userService.getUser(authResponse.userId)),
tap(user => {
this.userStateService.setCurrentUser(user);
this.userStateService.setLoading(false);
}),
catchError(error => {
this.userStateService.setLoading(false);
return throwError(() => error);
})
);
}
}
Services vs Components: Separation of Concerns
Understanding when to put logic in services versus components is key to a well-structured Angular application:
Component Responsibilities
- UI presentation logic
- Handling user interactions
- Managing component-specific state
- Coordinating between child components
- Delegating data operations to services
Service Responsibilities
- Data fetching and persistence
- Business logic
- Shared functionality
- Application-wide or feature-wide state
- Communication with external systems
Rule of thumb: If the logic is related to how something looks or handles direct user interaction, it belongs in a component. If it's about what the application does or how it works, it belongs in a service.
Benefits of Proper Separation
- Reusability: Services can be used across multiple components
- Testability: Services are easier to test in isolation
- Maintainability: Components stay focused and smaller
- Scalability: Teams can work on components and services independently
Service Communication with Observables
Angular services often use RxJS Observables for asynchronous operations and communication:
HTTP Requests
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Product } from '../models/product.model';
@Injectable({
providedIn: 'root'
})
export class ProductService {
private apiUrl = 'https://api.example.com/products';
constructor(private http: HttpClient) { }
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>(this.apiUrl);
}
}
State Management with BehaviorSubject
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { CartItem } from '../models/cart-item.model';
@Injectable({
providedIn: 'root'
})
export class CartService {
private cartItemsSubject = new BehaviorSubject<CartItem[]>([]);
cartItems$: Observable<CartItem[]> = this.cartItemsSubject.asObservable();
constructor() { }
addToCart(item: CartItem): void {
const currentItems = this.cartItemsSubject.value;
const existingItemIndex = currentItems.findIndex(i => i.id === item.id);
if (existingItemIndex !== -1) {
// Update quantity if item exists
const updatedItems = [...currentItems];
updatedItems[existingItemIndex] = {
...currentItems[existingItemIndex],
quantity: currentItems[existingItemIndex].quantity + item.quantity
};
this.cartItemsSubject.next(updatedItems);
} else {
// Add new item
this.cartItemsSubject.next([...currentItems, item]);
}
}
removeFromCart(id: number): void {
const currentItems = this.cartItemsSubject.value;
this.cartItemsSubject.next(currentItems.filter(item => item.id !== id));
}
clearCart(): void {
this.cartItemsSubject.next([]);
}
get totalItems(): number {
return this.cartItemsSubject.value.reduce(
(total, item) => total + item.quantity, 0
);
}
get totalPrice(): number {
return this.cartItemsSubject.value.reduce(
(total, item) => total + (item.price * item.quantity), 0
);
}
}
Using Services with Observables in Components
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { CartService } from '../../services/cart.service';
import { CartItem } from '../../models/cart-item.model';
@Component({
selector: 'app-cart',
template: `
<div class="cart">
<h2>Your Cart</h2>
<div *ngIf="cartItems.length === 0" class="empty-cart">
Your cart is empty
</div>
<div *ngIf="cartItems.length > 0">
<div *ngFor="let item of cartItems" class="cart-item">
<span>{{ item.name }}</span>
<span>{{ item.quantity }} × {{ item.price | currency }}</span>
<button (click)="removeItem(item.id)">Remove</button>
</div>
<div class="cart-total">
<strong>Total: {{ totalPrice | currency }}</strong>
</div>
<button (click)="clearCart()">Clear Cart</button>
<button>Checkout</button>
</div>
</div>
`
})
export class CartComponent implements OnInit, OnDestroy {
cartItems: CartItem[] = [];
totalPrice = 0;
private cartSubscription: Subscription;
constructor(private cartService: CartService) { }
ngOnInit(): void {
this.cartSubscription = this.cartService.cartItems$.subscribe(items => {
this.cartItems = items;
this.totalPrice = this.cartService.totalPrice;
});
}
removeItem(id: number): void {
this.cartService.removeFromCart(id);
}
clearCart(): void {
this.cartService.clearCart();
}
ngOnDestroy(): void {
// Always unsubscribe to prevent memory leaks
if (this.cartSubscription) {
this.cartSubscription.unsubscribe();
}
}
}
Async Pipe Alternative
The async pipe automatically subscribes to and unsubscribes from Observables:
import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { CartService } from '../../services/cart.service';
import { CartItem } from '../../models/cart-item.model';
@Component({
selector: 'app-cart',
template: `
<div class="cart">
<h2>Your Cart</h2>
<ng-container *ngIf="cartItems$ | async as cartItems">
<div *ngIf="cartItems.length === 0" class="empty-cart">
Your cart is empty
</div>
<div *ngIf="cartItems.length > 0">
<div *ngFor="let item of cartItems" class="cart-item">
<span>{{ item.name }}</span>
<span>{{ item.quantity }} × {{ item.price | currency }}</span>
<button (click)="removeItem(item.id)">Remove</button>
</div>
<div class="cart-total">
<strong>Total: {{ totalPrice$ | async | currency }}</strong>
</div>
<button (click)="clearCart()">Clear Cart</button>
<button>Checkout</button>
</div>
</ng-container>
</div>
`
})
export class CartComponent {
cartItems$: Observable<CartItem[]>;
totalPrice$: Observable<number>;
constructor(private cartService: CartService) {
this.cartItems$ = this.cartService.cartItems$;
this.totalPrice$ = this.cartService.totalPrice$;
}
removeItem(id: number): void {
this.cartService.removeFromCart(id);
}
clearCart(): void {
this.cartService.clearCart();
}
}
Benefits of the async pipe approach:
- No need to manually subscribe and unsubscribe
- Less boilerplate code
- Automatic memory management
- Better handling of component destruction
Advanced Dependency Injection Techniques
Let's explore some advanced DI techniques that can be useful in larger applications:
Multi Providers
Multi providers allow multiple services to be injected for a single token:
// Define the token and interface
import { InjectionToken } from '@angular/core';
export interface Logger {
log(message: string): void;
}
export const LOGGERS = new InjectionToken<Logger[]>('Loggers');
// Create implementations
@Injectable()
export class ConsoleLogger implements Logger {
log(message: string): void {
console.log(`[Console]: ${message}`);
}
}
@Injectable()
export class DatabaseLogger implements Logger {
constructor(private dbService: DbService) {}
log(message: string): void {
this.dbService.saveLog(message);
}
}
@Injectable()
export class ApiLogger implements Logger {
constructor(private http: HttpClient) {}
log(message: string): void {
this.http.post('api/logs', { message }).subscribe();
}
}
// Register with multi: true
@NgModule({
providers: [
{ provide: LOGGERS, useClass: ConsoleLogger, multi: true },
{ provide: LOGGERS, useClass: DatabaseLogger, multi: true },
{ provide: LOGGERS, useClass: ApiLogger, multi: true }
]
})
export class AppModule { }
// Use in a service
@Injectable({ providedIn: 'root' })
export class LoggingService {
constructor(@Inject(LOGGERS) private loggers: Logger[]) {}
log(message: string): void {
// Log to all registered loggers
this.loggers.forEach(logger => logger.log(message));
}
}
Factory Providers with Dependencies
Factory functions can access other injected services:
@NgModule({
providers: [
{
provide: 'ConfiguredApiService',
useFactory: (configService: ConfigService, http: HttpClient) => {
const apiUrl = configService.getApiUrl();
return new ApiService(apiUrl, http);
},
deps: [ConfigService, HttpClient]
}
]
})
export class AppModule { }
Optional Dependencies
You can mark dependencies as optional to prevent errors if they're not available:
import { Injectable, Optional } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
constructor(
@Optional() private logger: LoggingService
) {
if (this.logger) {
this.logger.log('AnalyticsService initialized');
}
}
trackEvent(event: string): void {
// Track the event
if (this.logger) {
this.logger.log(`Event tracked: ${event}`);
}
}
}
Self, SkipSelf, and Host
These modifiers control where Angular looks for dependencies:
import { Injectable, Self, SkipSelf, Host, Optional } from '@angular/core';
@Component({
selector: 'app-child',
templateUrl: './child.component.html',
providers: [
{ provide: LoggingService, useClass: ChildLoggingService }
]
})
export class ChildComponent {
constructor(
// Only look in this component's injector
@Self() private selfLogger: LoggingService,
// Skip this component's injector, look in parent
@SkipSelf() private parentLogger: LoggingService,
// Only look in this component's host component
@Host() @Optional() private hostLogger: LoggingService
) {
this.selfLogger.log('Using child component logger');
this.parentLogger.log('Using parent logger');
if (this.hostLogger) {
this.hostLogger.log('Using host logger');
}
}
}
Testing Services
Services are typically easier to test than components because they don't involve the DOM. Here are some testing strategies:
Basic Service Testing
// calculator.service.ts
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CalculatorService {
add(a: number, b: number): number {
return a + b;
}
subtract(a: number, b: number): number {
return a - b;
}
}
// calculator.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CalculatorService } from './calculator.service';
describe('CalculatorService', () => {
let service: CalculatorService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CalculatorService]
});
service = TestBed.inject(CalculatorService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should add two numbers', () => {
expect(service.add(2, 3)).toBe(5);
expect(service.add(-1, 1)).toBe(0);
});
it('should subtract two numbers', () => {
expect(service.subtract(5, 2)).toBe(3);
expect(service.subtract(1, 2)).toBe(-1);
});
});
Testing Services with Dependencies
// data.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Product } from '../models/product.model';
@Injectable({ providedIn: 'root' })
export class ProductService {
private apiUrl = 'https://api.example.com/products';
constructor(private http: HttpClient) { }
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>(this.apiUrl);
}
}
// data.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ProductService } from './product.service';
import { Product } from '../models/product.model';
describe('ProductService', () => {
let service: ProductService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [ProductService]
});
service = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
// Verify no outstanding requests
httpMock.verify();
});
it('should retrieve products', () => {
const mockProducts: Product[] = [
{ id: 1, name: 'Product 1', price: 10 },
{ id: 2, name: 'Product 2', price: 20 }
];
service.getProducts().subscribe(products => {
expect(products.length).toBe(2);
expect(products).toEqual(mockProducts);
});
// Expect a request to the specified URL
const req = httpMock.expectOne('https://api.example.com/products');
expect(req.request.method).toBe('GET');
// Respond with mock data
req.flush(mockProducts);
});
});
Testing with Mock Services
// component using a service
@Component({
selector: 'app-product-list',
template: `
<div *ngIf="products.length">
<div *ngFor="let product of products">{{ product.name }}</div>
</div>
`
})
export class ProductListComponent implements OnInit {
products: Product[] = [];
constructor(private productService: ProductService) { }
ngOnInit(): void {
this.productService.getProducts()
.subscribe(products => this.products = products);
}
}
// component test with mock service
describe('ProductListComponent', () => {
let component: ProductListComponent;
let fixture: ComponentFixture<ProductListComponent>;
let mockProductService: jasmine.SpyObj<ProductService>;
beforeEach(async () => {
// Create a mock service
mockProductService = jasmine.createSpyObj('ProductService', ['getProducts']);
await TestBed.configureTestingModule({
declarations: [ProductListComponent],
providers: [
{ provide: ProductService, useValue: mockProductService }
]
}).compileComponents();
// Set up the mock to return test data
mockProductService.getProducts.and.returnValue(of([
{ id: 1, name: 'Test Product 1', price: 10 },
{ id: 2, name: 'Test Product 2', price: 20 }
]));
fixture = TestBed.createComponent(ProductListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load products on init', () => {
expect(mockProductService.getProducts).toHaveBeenCalled();
expect(component.products.length).toBe(2);
expect(component.products[0].name).toBe('Test Product 1');
});
});
Practical Example: Building a Complete Service Architecture
Let's bring everything together with a practical example of a service architecture for a product management system:
1. API Configuration Service
// config.service.ts
import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class ConfigService {
private apiUrl = environment.apiUrl;
getApiUrl(): string {
return this.apiUrl;
}
getProductsEndpoint(): string {
return `${this.apiUrl}/products`;
}
getProductEndpoint(id: number): string {
return `${this.apiUrl}/products/${id}`;
}
}
2. HTTP Error Handling Service
// error-handler.service.ts
import { Injectable } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class ErrorHandlerService {
handleError(error: HttpErrorResponse): Observable<never> {
let errorMessage = 'An unknown error occurred!';
if (error.error instanceof ErrorEvent) {
// Client-side error
errorMessage = `Error: ${error.error.message}`;
} else {
// Server-side error
errorMessage = `Error Code: ${error.status}\nMessage: ${error.message}`;
}
console.error(errorMessage);
return throwError(() => new Error(errorMessage));
}
}
3. Product Data Service
// product-data.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ConfigService } from './config.service';
import { ErrorHandlerService } from './error-handler.service';
import { Product } from '../models/product.model';
@Injectable({
providedIn: 'root'
})
export class ProductDataService {
constructor(
private http: HttpClient,
private configService: ConfigService,
private errorHandler: ErrorHandlerService
) { }
getProducts(category?: string): Observable<Product[]> {
let params = new HttpParams();
if (category) {
params = params.set('category', category);
}
return this.http.get<Product[]>(
this.configService.getProductsEndpoint(),
{ params }
).pipe(
catchError(error => this.errorHandler.handleError(error))
);
}
getProduct(id: number): Observable<Product> {
return this.http.get<Product>(
this.configService.getProductEndpoint(id)
).pipe(
catchError(error => this.errorHandler.handleError(error))
);
}
createProduct(product: Product): Observable<Product> {
return this.http.post<Product>(
this.configService.getProductsEndpoint(),
product
).pipe(
catchError(error => this.errorHandler.handleError(error))
);
}
updateProduct(product: Product): Observable<Product> {
return this.http.put<Product>(
this.configService.getProductEndpoint(product.id),
product
).pipe(
catchError(error => this.errorHandler.handleError(error))
);
}
deleteProduct(id: number): Observable<any> {
return this.http.delete(
this.configService.getProductEndpoint(id)
).pipe(
catchError(error => this.errorHandler.handleError(error))
);
}
}
4. Product State Service
// product-state.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { Product } from '../models/product.model';
@Injectable({
providedIn: 'root'
})
export class ProductStateService {
private productsSubject = new BehaviorSubject<Product[]>([]);
products$ = this.productsSubject.asObservable();
private selectedProductSubject = new BehaviorSubject<Product | null>(null);
selectedProduct$ = this.selectedProductSubject.asObservable();
private loadingSubject = new BehaviorSubject<boolean>(false);
loading$ = this.loadingSubject.asObservable();
private errorSubject = new BehaviorSubject<string | null>(null);
error$ = this.errorSubject.asObservable();
setProducts(products: Product[]): void {
this.productsSubject.next(products);
}
setSelectedProduct(product: Product | null): void {
this.selectedProductSubject.next(product);
}
setLoading(loading: boolean): void {
this.loadingSubject.next(loading);
}
setError(error: string | null): void {
this.errorSubject.next(error);
}
addProduct(product: Product): void {
const currentProducts = this.productsSubject.value;
this.productsSubject.next([...currentProducts, product]);
}
updateProduct(updatedProduct: Product): void {
const currentProducts = this.productsSubject.value;
this.productsSubject.next(
currentProducts.map(p =>
p.id === updatedProduct.id ? updatedProduct : p
)
);
// Update selected product if it's the same one
if (this.selectedProductSubject.value?.id === updatedProduct.id) {
this.selectedProductSubject.next(updatedProduct);
}
}
removeProduct(id: number): void {
const currentProducts = this.productsSubject.value;
this.productsSubject.next(
currentProducts.filter(p => p.id !== id)
);
// Clear selected product if it's the same one
if (this.selectedProductSubject.value?.id === id) {
this.selectedProductSubject.next(null);
}
}
// Helper methods to get current values synchronously
get products(): Product[] {
return this.productsSubject.value;
}
get selectedProduct(): Product | null {
return this.selectedProductSubject.value;
}
}
5. Product Facade Service
// product-facade.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { tap, finalize, catchError, map } from 'rxjs/operators';
import { ProductDataService } from './product-data.service';
import { ProductStateService } from './product-state.service';
import { Product } from '../models/product.model';
@Injectable({
providedIn: 'root'
})
export class ProductFacadeService {
// Expose the state as observables for components
products$ = this.productState.products$;
selectedProduct$ = this.productState.selectedProduct$;
loading$ = this.productState.loading$;
error$ = this.productState.error$;
constructor(
private productData: ProductDataService,
private productState: ProductStateService
) { }
loadProducts(category?: string): void {
this.productState.setLoading(true);
this.productState.setError(null);
this.productData.getProducts(category).pipe(
tap(products => {
this.productState.setProducts(products);
}),
catchError(error => {
this.productState.setError('Failed to load products.');
throw error;
}),
finalize(() => {
this.productState.setLoading(false);
})
).subscribe();
}
loadProduct(id: number): void {
this.productState.setLoading(true);
this.productState.setError(null);
this.productData.getProduct(id).pipe(
tap(product => {
this.productState.setSelectedProduct(product);
}),
catchError(error => {
this.productState.setError(`Failed to load product #${id}.`);
throw error;
}),
finalize(() => {
this.productState.setLoading(false);
})
).subscribe();
}
createProduct(product: Product): Observable<Product> {
this.productState.setLoading(true);
this.productState.setError(null);
return this.productData.createProduct(product).pipe(
tap(newProduct => {
this.productState.addProduct(newProduct);
}),
catchError(error => {
this.productState.setError('Failed to create product.');
throw error;
}),
finalize(() => {
this.productState.setLoading(false);
})
);
}
updateProduct(product: Product): Observable<Product> {
this.productState.setLoading(true);
this.productState.setError(null);
return this.productData.updateProduct(product).pipe(
tap(updatedProduct => {
this.productState.updateProduct(updatedProduct);
}),
catchError(error => {
this.productState.setError(`Failed to update product #${product.id}.`);
throw error;
}),
finalize(() => {
this.productState.setLoading(false);
})
);
}
deleteProduct(id: number): Observable<boolean> {
this.productState.setLoading(true);
this.productState.setError(null);
return this.productData.deleteProduct(id).pipe(
map(() => true),
tap(() => {
this.productState.removeProduct(id);
}),
catchError(error => {
this.productState.setError(`Failed to delete product #${id}.`);
throw error;
}),
finalize(() => {
this.productState.setLoading(false);
})
);
}
selectProduct(product: Product | null): void {
this.productState.setSelectedProduct(product);
}
clearSelectedProduct(): void {
this.productState.setSelectedProduct(null);
}
}
6. Using the Services in Components
// product-list.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { ProductFacadeService } from '../../services/product-facade.service';
import { Product } from '../../models/product.model';
@Component({
selector: 'app-product-list',
template: `
<div class="product-list-container">
<h2>Products</h2>
<div class="filters">
<select (change)="filterByCategory($event)">
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
</select>
<button (click)="refresh()">Refresh</button>
<button routerLink="/products/new">Add New</button>
</div>
<div *ngIf="loading$ | async" class="loading">
Loading products...
</div>
<div *ngIf="error$ | async as error" class="error">
{{ error }}
</div>
<div *ngIf="(products$ | async)?.length === 0 && !(loading$ | async)" class="empty">
No products found.
</div>
<div class="product-grid">
<div *ngFor="let product of products$ | async" class="product-card">
<h3>{{ product.name }}</h3>
<p class="price">{{ product.price | currency }}</p>
<div class="actions">
<button (click)="viewProduct(product)">View</button>
<button (click)="editProduct(product)">Edit</button>
<button (click)="deleteProduct(product.id)">Delete</button>
</div>
</div>
</div>
</div>
`
})
export class ProductListComponent implements OnInit {
products$: Observable<Product[]>;
loading$: Observable<boolean>;
error$: Observable<string | null>;
private currentCategory = '';
constructor(private productFacade: ProductFacadeService) {
this.products$ = this.productFacade.products$;
this.loading$ = this.productFacade.loading$;
this.error$ = this.productFacade.error$;
}
ngOnInit(): void {
this.loadProducts();
}
loadProducts(): void {
this.productFacade.loadProducts(this.currentCategory || undefined);
}
filterByCategory(event: Event): void {
this.currentCategory = (event.target as HTMLSelectElement).value;
this.loadProducts();
}
refresh(): void {
this.loadProducts();
}
viewProduct(product: Product): void {
this.productFacade.selectProduct(product);
// Navigate to product detail page
}
editProduct(product: Product): void {
this.productFacade.selectProduct(product);
// Navigate to product edit page
}
deleteProduct(id: number): void {
if (confirm('Are you sure you want to delete this product?')) {
this.productFacade.deleteProduct(id).subscribe({
next: () => {
// Show success message
},
error: (err) => {
// Error is already handled by the facade
console.error('Delete operation failed', err);
}
});
}
}
}
This architecture demonstrates:
- Separation of concerns between data access, state management, and business logic
- Reactive programming with RxJS Observables
- Error handling and loading state management
- Facade pattern to simplify component interaction
- Dependency injection to connect the layers
Activities for Practice
Exercise 1: Create a Complete Service Layer
Create a set of services for a task management application:
- Create a
TaskDataServicefor API communication - Create a
TaskStateServicefor storing application state - Create a
TaskFacadeServiceto coordinate between the two - Implement CRUD operations for tasks
- Add loading and error handling
- Create a simple component that uses these services
Exercise 2: Implement Advanced Dependency Injection
Create a logging system using advanced DI techniques:
- Create a
Loggerinterface - Create multiple implementations (console logger, storage logger, etc.)
- Use multi providers to register all loggers
- Create a
LoggingServicethat uses all registered loggers - Add a factory provider for a configurable logger
- Use the logging system in a component
Exercise 3: Build a Service with Reactive State
Create a shopping cart service with reactive state management:
- Define cart item models
- Create a
CartServicewith methods to add, remove, and update items - Use
BehaviorSubjectto track cart state - Add computed values like total price and item count
- Implement persistence using
localStorage - Create a simple cart component that uses the
asyncpipe to display the cart
Additional Resources
- Angular Documentation: Services
- Angular Documentation: Dependency Injection
- Angular Documentation: Hierarchical Injectors
- Angular Documentation: Singleton Services
- Angular Documentation: Testing Services
- NgRx: Reactive State for Angular
- Angular University: Dependency Injection
- In-Depth: Hierarchical Dependency Injectors
- The Ultimate Guide to Set Up Your Angular Service Layer