Blog Highlights

Explore my latest thoughts, insights, and innovations in technology, AI, development, and security. Dive into comprehensive guides and analyses.

Back to all posts

SOLID Principles in Angular

June 5, 2023 by Rakesh Udutha Read Time: 7 min readAngular

SOLID is a set of five design principles that aim to improve the architecture, maintainability, and testability of software applications. These principles provide guidelines for writing clean and modular code, making it easier to understand, extend, and refactor. When applied to Angular development, SOLID principles can significantly enhance the quality and sustainability of your applications.

  1. Single Responsibility Principle (SRP)
  2. Open-Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)

Single Responsibility Principle (SRP)

Suppose we have a UserService that is responsible for managing user-related operations, such as fetching user data from an API, updating user details, and handling authentication.

// user.service.ts

@Injectable()
export class UserService {
  constructor(private http: HttpClient) {}

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

  updateUser(user: User): Observable<User> {
    return this.http.put<User>(`/api/users/${user.id}`, user);
  }

  login(credentials: LoginCredentials): Observable<AuthToken> {
    return this.http.post<AuthToken>('/api/login', credentials);
  }
}

In this example, the UserService is focused on user-related operations. It provides methods for fetching user data, updating user details, and handling authentication. This aligns with the Single Responsibility Principle, as the service has a clear and specific responsibility related to user management.

By adhering to the SRP, the UserService remains focused on user operations and doesn’t include unrelated functionality like handling other entities or performing unrelated tasks. This separation of concerns allows for better maintainability, testability, and code organization in Angular applications.

Open-Closed Principle (OCP)

Suppose we have an OrderService that handles order-related operations, such as placing an order, calculating the total cost, and generating an order confirmation.

// order.service.ts

@Injectable()
export class OrderService {
  constructor(private productService: ProductService) {}

  placeOrder(order: Order): void {
    // Logic for placing the order
    this.productService.updateInventory(order); // Update inventory

    const totalCost = this.calculateTotalCost(order); // Calculate total cost
    this.sendOrderConfirmation(order, totalCost); // Send order confirmation
  }

  calculateTotalCost(order: Order): number {
    let total = 0;
    for (const item of order.items) {
      const product = this.productService.getProductById(item.productId);
      total += product.price * item.quantity;
    }
    return total;
  }

  sendOrderConfirmation(order: Order, totalCost: number): void {
    // Logic for sending order confirmation to the customer
  }
}

In this example, the OrderService follows the Open-Closed Principle by being open for extension but closed for modification. The placeOrder method handles the core order processing logic. However, if there is a need to introduce new features or requirements, such as sending notifications to the warehouse when an order is placed, we can extend the service without modifying the existing code.

// order.service.ts

@Injectable()
export class OrderService {
  constructor(
    private productService: ProductService,
    private notificationService: NotificationService
  ) {}

  placeOrder(order: Order): void {
    // Logic for placing the order
    this.productService.updateInventory(order); // Update inventory

    const totalCost = this.calculateTotalCost(order); // Calculate total cost
    this.sendOrderConfirmation(order, totalCost); // Send order confirmation

    this.notificationService.notifyWarehouse(order); // Send notification to warehouse
  }

  // Existing code...
}

By introducing the NotificationService and extending the placeOrder method, we adhere to the Open-Closed Principle. The OrderService remains closed for modification, as we haven’t modified the original code. Instead, we’ve extended it with new functionality by injecting a separate service to handle warehouse notifications.

Applying the Open-Closed Principle in Angular promotes code reusability, maintainability, and scalability, as it allows for easy extension and addition of new features without modifying existing code.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) focuses on substitutability and behavior preservation when inheriting or implementing classes or interfaces. In Angular, this principle can be demonstrated by adhering to the behavior contract defined by base classes or interfaces. Here’s an example:

Suppose we have a base class Shape with a method calculateArea that calculates the area of a shape. We then have two derived classes Rectangle and Circle that inherit from Shape and provide their own implementations of the calculateArea method.

// shape.ts

export abstract class Shape {
  abstract calculateArea(): number;
}
// rectangle.ts

import { Shape } from './shape';

export class Rectangle extends Shape {
  constructor(private width: number, private height: number) {
    super();
  }

  calculateArea(): number {
    return this.width * this.height;
  }
}
// circle.ts

import { Shape } from './shape';

export class Circle extends Shape {
  constructor(private radius: number) {
    super();
  }

  calculateArea(): number {
    return Math.PI * this.radius * this.radius;
  }
}

In this example, both the Rectangle and Circle classes inherit from the Shape base class and provide their own implementations of the calculateArea method. Each derived class maintains the behavior contract defined by the Shape class, ensuring that they can be substituted for instances of the base class without altering the correctness of the program.

By adhering to the Liskov Substitution Principle, we can confidently use instances of Rectangle or Circle wherever an instance of Shape is expected, without worrying about breaking the behavior contract.

Applying the Liskov Substitution Principle in Angular ensures that derived classes can be used interchangeably with their base classes, allowing for polymorphism and promoting code extensibility and maintainability.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) emphasizes the importance of creating specific and focused interfaces instead of large, general-purpose ones. In Angular, you can demonstrate ISP by designing interfaces that precisely define the required behavior for a particular component or service. Here’s an example:

Suppose we have a component OrderComponent that needs to interact with a backend API to place orders and retrieve order details. We can define two separate interfaces, OrderPlacer and OrderRetriever, each representing a specific behavior:

// order-placer.interface.ts

export interface OrderPlacer {
  placeOrder(order: Order): void;
}
// order-retriever.interface.ts

export interface OrderRetriever {
  getOrderDetails(orderId: string): OrderDetails;
}

In this example, the OrderPlacer interface specifies a behavior for placing orders, while the OrderRetriever interface specifies a behavior for retrieving order details. By defining separate interfaces, we adhere to the Interface Segregation Principle, as each interface focuses on a specific responsibility.

Now, our OrderComponent can implement these interfaces as needed:

import { Component, Injectable } from '@angular/core';
import { OrderPlacer, OrderRetriever } from './interfaces';

@Component({
  // Component configuration
})
@Injectable()
export class OrderComponent implements OrderPlacer, OrderRetriever {
  placeOrder(order: Order): void {
    // Logic for placing the order
  }

  getOrderDetails(orderId: string): OrderDetails {
    // Logic for retrieving order details
  }
}

By implementing the OrderPlacer and OrderRetriever interfaces, the OrderComponent explicitly defines and provides the necessary behavior required by each interface. This allows the component to fulfill its responsibilities without being burdened by unrelated methods or requirements.

Applying the Interface Segregation Principle in Angular promotes better code organization, reusability, and maintainability, as components and services can rely on specific interfaces tailored to their needs, rather than having to implement large, monolithic interfaces with unnecessary methods.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) emphasizes that high-level modules should not depend on low-level modules directly, but both should depend on abstractions. In Angular, you can apply DIP by using dependency injection and relying on abstractions (interfaces) instead of concrete implementations. Here’s an example:

Suppose we have a NotificationService that handles sending notifications to users. We also have a UserService that depends on the NotificationService to send notifications when a user registers.

First, let’s define an abstraction (interface) for the NotificationService:

// notification.service.ts

export interface NotificationService {
  sendNotification(message: string): void;
}

Next, we implement the concrete NotificationServiceImpl that implements the NotificationService interface:

// notification.service.impl.ts

import { NotificationService } from './notification.service';

export class NotificationServiceImpl implements NotificationService {
  sendNotification(message: string): void {
    // Implementation to send a notification
  }
}

Now, we can modify the UserService to depend on the NotificationService abstraction:

// user.service.ts

import { NotificationService } from './notification.service';

@Injectable()
export class UserService {
  constructor(private notificationService: NotificationService) {}

  registerUser(user: User): void {
    // Logic for user registration

    this.notificationService.sendNotification('User registered successfully');
  }
}

By injecting the NotificationService abstraction into the UserService, we follow the Dependency Inversion Principle. The UserService depends on the abstraction (NotificationService) rather than a specific implementation (NotificationServiceImpl). This promotes flexibility and extensibility, as we can easily swap different implementations of the NotificationService without modifying the UserService code.

To provide the concrete implementation of the NotificationService, you can configure Angular’s dependency injection by registering the NotificationServiceImpl with the appropriate provider:

// app.module.ts

import { NotificationService, NotificationServiceImpl } from './notification.service';

@NgModule({
  // Module configuration
  providers: [
    { provide: NotificationService, useClass: NotificationServiceImpl },
  ],
})
export class AppModule {}

By configuring the provider in this way, Angular will inject the NotificationServiceImpl whenever the NotificationService dependency is requested.

Applying the Dependency Inversion Principle through dependency injection and relying on abstractions allows for loose coupling, testability, and flexibility in your Angular applications.