Copied RSS Feed

Angular

Mastering SOLID Principles in Angular: Build Scalable Apps with Clean Code

TL;DR: SOLID principles let developers build maintainable and scalable Angular applications. SRP dictates that components/services should have a single responsibility. OCP says functionality should be extendable without modifying existing code. LSP says subtypes should be substitutable for base types. ISP favors smaller, specific interfaces over large ones. DIP promotes dependency on abstractions, not concrete implementations.

Introduction

Building scalable and maintainable applications in Angular requires more than knowing the framework—it demands strong software design principles. The SOLID principles, originally introduced by Robert C. Martin (Uncle Bob), provide a foundation for writing clean, reusable, and testable code. These principles help Angular developers structure their applications in a way that promotes flexibility, reduces coupling, and enhances maintainability.

In this article, we will explore each of the five SOLID principles:

Each principle will be explained with practical, Angular-based examples to demonstrate its real-world application. By the end of this article, you’ll have a deeper understanding of how to apply SOLID principles in your Angular projects to write more maintainable and scalable code.

Syncfusion® Angular component suite is the only suite you will ever need to develop an Angular application faster.

Single Responsibility Principle (SRP)

The SRP states that a class (or, in Angular terms, a component, service, or directive) should have only one reason to change. In other words, it should have only one responsibility.

For example, consider the OrderComponent, which handles both displaying order details and sending order confirmation emails.

Refer to the following code example.

import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-order',
  imports: [],
  template: `
    <h2>Order Details</h2>
    <p>Order ID: {{ order?.id }}</p>
    <p>Items: {{ order?.items }}</p>
    <button (click)="sendConfirmationEmail()">Send Confirmation Email</button>
  `,
  styleUrl: './order.component.scss',
})
export class OrderComponent implements OnInit {
  order: any;

  constructor(private http: HttpClient) {}

  ngOnInit(): void {
    this.http.get('api/orders/123').subscribe((data) => {
      this.order = data;
    });
  }

  sendConfirmationEmail(): void {
    this.http
      .post('api/email/confirmation', { orderId: this.order.id })
      .subscribe(() => {
        alert('Confirmation email sent!');
      });
  }
}

Problems with this component:

  • The component is responsible for fetching order data from the API.
  • The component is also responsible for sending a confirmation email.
  • The component handles the display of order details in the template.

If the email-sending logic changes (e.g., if we want to use a different email service), we would need to modify the OrderComponent, even if the order display logic remains the same. This violates the SRP.

Refactoring to adhere to SRP

To ensure the OrderComponent adheres to SRP, we should refactor it to handle only display logic and user interactions while delegating data fetching and email sending to services.

First, create an OrderService, as shown in the following code example.

import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class OrderService {
  private readonly http = inject(HttpClient);

  getOrder(orderId: string) {
    return this.http.get(`api/orders/${orderId}`);
  }
}

Second, create an EmailService.

import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class EmailService {
  private readonly http = inject(HttpClient);

  sendConfirmationEmail(orderId: string) {
    return this.http.post('api/email/confirmation', { orderId });
  }
}

Finally, update the OrderComponent.

import { Component, inject, OnInit } from '@angular/core';
import { OrderService } from '../order.service';
import { EmailService } from '../email.service';

@Component({
  selector: 'app-order',
  imports: [],
  template: `
    <h2>Order Details</h2>
    <p>Order ID: {{ order?.id }}</p>
    <p>Items: {{ order?.items }}</p>
    <button (click)="sendConfirmationEmail()">Send Confirmation Email</button>
  `,
  styleUrl: './order.component.scss',
})
export class OrderComponent implements OnInit {
  private readonly orderService = inject(OrderService);
  private readonly emailService = inject(EmailService);
  order: any;

  ngOnInit(): void {
    this.orderService.getOrder('123').subscribe((data) => {
      this.order = data;
    });
  }

  sendConfirmationEmail(): void {
    this.emailService.sendConfirmationEmail(this.order.id).subscribe(() => {
      alert('Confirmation email sent!');
    });
  }
}

Now, we have made the following improvements:

  • OrderService now handles only order data retrieval.
  • EmailService handles only email sending.
  • OrderComponent is now only responsible for the presentation of the order.
  • Each unit has a single reason to change.

Why is SRP important in Angular?

  • Maintainability: When a component or service has a single responsibility, it’s easier to understand, modify, and test. Changes to one part of the application are less likely to affect other parts.
  • Testability: Smaller, focused components and services are easier to unit test.
  • Reusability: Components and services with single responsibilities are more likely to be reusable in other parts of the application.
  • Reduced complexity: Breaking down complex functionality into smaller, well-defined units makes the codebase more manageable.

Open/Closed Principle (OCP)

The OCP states that software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. This means you should be able to add new functionality without changing the existing code.

In Angular, we can apply the OCP by using techniques such as:

  • Interfaces: Defining interfaces allows us to create contracts to which components or services must adhere. This enables us to introduce new implementations without modifying the existing code that relies on the interface.
  • Directives: Directives can extend the behavior of existing HTML elements without modifying the elements themselves.
  • Dependency injection: Dependency injection allows us to inject different implementations of a service or component based on specific conditions, without altering the consuming component.
  • Component composition: Building components that can be composed and reused is a strong way to adhere to the open/close principle.

Let’s illustrate this with a real-world example. Suppose we want to display different types of notifications in an Angular application. Consider the following code example.

export interface Notification {
  display(): string;
}

export class SuccessNotification implements Notification {
  display(): string {
    return 'Success: Operation completed successfully!';
  }
}

export class ErrorNotification implements Notification {
  display(): string {
    return 'Error: An error occurred!';
  }
}

Now, we have created a Notification interface and three classes that implement this interface. Each class provides a specific implementation of the display method, returning a corresponding message:

  • SuccessNotification: Returns a success message.
  • ErrorNotification: Returns an error message.
  • WarningNotification: Returns a warning message.

Next, let’s add a NotificationComponent.

import { Component, Input } from '@angular/core';
import { Notification } from '../../models/notification.interface';

@Component({
  selector: 'app-notification',
  template: `
    @if (notification) {
      <div>
        {{ notification.display() }}
      </div>
    }
  `,
  styleUrls: ['./notification.component.scss'],
})
export class NotificationComponent {
  @Input() notification: Notification | null = null;
}

The NotificationComponent imports the Notification interface and uses it to display notification messages. The component has an @Input property called notification that can be set to any object implementing the Notification interface. If a notification is provided, it displays the result of the display method in a div element.

Finally, we will update the AppComponent to use NotificationComponent, as shown in the next code example.

import { Component } from '@angular/core';
import { 
  ErrorNotification, 
  SuccessNotification, 
  Notification, 
  WarningNotification 
} from '../models/notification.interface';
import { NotificationComponent } from './notification/notification.component';

@Component({
  selector: 'app-root',
  imports: [NotificationComponent],
  template: `
    <button (click)="showSuccess()">Show Success</button>
    <button (click)="showError()">Show Error</button>
    <app-notification [notification]="currentNotification"></app-notification>
  `,
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  currentNotification: Notification | null = null;

  showSuccess() {
    this.currentNotification = new SuccessNotification();
  }

  showError() {
    this.currentNotification = new ErrorNotification();
  }
}

The component template includes buttons to show different types of notifications and a NotificationComponent to display the current notification. The AppComponent has methods. showSuccess and showError to set the currentNotification property to the respective notification type, which is then passed to the NotificationComponent for display.

Now, let’s say we want to add a warning notification. According to the OCP, we should be able to do this without modifying the NotificationComponent or the AppComponent (except by adding the new button and method to create it).

First, let’s create the WarningNotification class.

export class WarningNotification implements Notification {
    display(): string {
        return 'Warning: Proceed with caution!';
    }
}

Next, update the AppComponent.

// Necessary imports
@Component({
  selector: 'app-root',
  imports: [NotificationComponent],
  template: `
    <button (click)="showSuccess()">Show Success</button>
    <button (click)="showError()">Show Error</button>
    <button (click)="showWarning()">Show Warning</button>
    <app-notification [notification]="currentNotification"></app-notification>
  `,
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  currentNotification: Notification | null = null;

  // Existing code

  showWarning() {
    this.currentNotification = new WarningNotification();
  }
}

Notice that we’ve only added a new class, WarningNotification, and updated the AppComponent to use it. The NotificationComponent remains unchanged. This demonstrates the OCP, as we’ve extended the functionality without modifying the existing code.

Benefits of OCP

  • Reduced risk: Minimizes the chance of introducing bugs when adding new features.
  • Maintainability: Makes the code easier to maintain and understand.
  • Reusability: Promotes the creation of reusable components and services.
  • Flexibility: Allows for easier adaptation to changing requirements.

By embracing the OCP, you can build more robust and maintainable Angular applications.

Use the right property of Syncfusion® Angular components to fit your requirement by exploring the complete UG documentation.

Liskov Substitution Principle (LSP)

The LSP states that subtypes must be substitutable for their base types without altering the correctness of the program. In simpler terms, if you have a base class (or interface) and a derived class (or implementation), you should be able to use the derived class anywhere you’d use the base class without breaking anything.

In Angular, we often encounter class hierarchies, especially with services and components. To adhere to the LSP, we must ensure that subclasses maintain the contracts and behaviors defined by their superclasses.

Let’s consider an Angular application that handles different types of data sources. We’ll start with a generic DataSource and then create specialized data sources.

First, create an interface and class, as shown in the next code example.

import { Observable } from "rxjs";

export interface DataSource {
  getData(): Observable<any[]>;
}

export abstract class AbstractDataSource implements DataSource {
  abstract getData(): Observable<any[]>;
}
  • DataSource interface: Declares a getData method that returns an Observable of an array of any type.
  • AbstractDataSource abstract class: Implements the DataSource interface and provides an abstract getData method, which must be implemented by a subclass.

This setup allows for the creation of various data source implementations that adhere to a common interface, promoting extensibility.

Second, add the LocalDataSourceService.

import { Injectable } from '@angular/core';
import { AbstractDataSource } from '../models/datasource.interface';
import { of } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class LocalDataSourceService extends AbstractDataSource {
  getData() {
    return of([
      { id: 1, name: 'Item 1' },
      { id: 2, name: 'Item 2' },
    ]);
  }
}

Third, add the RemoteDataSourceService, as shown.

import { inject, Injectable } from '@angular/core';
import { AbstractDataSource } from '../models/datasource.interface';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root',
})
export class RemoteDataSourceService extends AbstractDataSource {
  private apiUrl = 'https://jsonplaceholder.typicode.com/users';
  private http = inject(HttpClient);

  constructor() {
    super();
  }

  getData() {
    return this.http.get<any[]>(this.apiUrl);
  }
}

Next, add a DataDisplayComponent.

import { Component, Input, OnInit } from '@angular/core';
import { DataSource } from '../../models/datasource.interface';
import { AsyncPipe } from '@angular/common';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-data-display',
  imports: [AsyncPipe],
  template: `
    <ul>
      @for(item of data$ | async; track item) {
        <li>
          {{ item.name }}
        </li>
      }
    </ul>
  `,
  styleUrls: ['./data-display.component.scss'],
})
export class DataDisplayComponent implements OnInit {
  @Input() dataSource!: DataSource;
  data$ = this.dataSource?.getData();

  ngOnInit(): void {
    if (this.dataSource) {
      this.data$ = this.dataSource.getData();
    }
  }
}

This component displays data from any data source that implements the DataSource interface.

Finally, we will update the AppComponent.

import { Component, inject } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { RemoteDataSourceService } from './remote-data-source.service';
import { LocalDataSourceService } from './local-data-source.service';
import { DataDisplayComponent } from './data-display/data-display.component';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, RouterLink, DataDisplayComponent],
  template: `
    <h2>Local Data</h2>
    <app-data-display [dataSource]="localDataSource"></app-data-display>
    <h2>Remote Data</h2>
    <app-data-display [dataSource]="remoteDataSource"></app-data-display>
  `,
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  protected readonly remoteDataSource = inject(RemoteDataSourceService);
  protected readonly localDataSource = inject(LocalDataSourceService);
}

In this example, both LocalDataSourceService and RemoteDataSourceService implement the DataSource interface. The DataDisplayComponent relies on the DataSource interface, not on any specific implementation.

This means we can seamlessly switch between LocalDataSourceService and RemoteDataSourceService without modifying the DataDisplayComponent. This demonstrates the LSP:

  • The DataDisplayComponent works correctly with both subclasses.
  • We can switch between the various DataSource implementations without breaking the code.

Note: This component also adheres to the Open/Closed Principle by using dependency injection and component composition, allowing it to be extended with new data sources or display components without modifying its existing code.

Benefits of LSP

  • Maintainability: Makes code more maintainable and easier to refactor.
  • Flexibility: Enables easy swapping of implementations.
  • Robustness: Reduces the risk of unexpected behavior when using subclasses.
  • Testability: Simplifies testing by allowing easy substitution of test doubles.

Angular-Specific considerations

  • Be mindful of side effects when overriding methods in subclasses. Ensure that subclasses do not introduce unexpected behavior that violates the superclass’s contract.
  • When using dependency injection, ensure that injected subclasses behave as expected based on their superclass interfaces.
  • When using template-driven forms or reactive forms, be sure that child components that extend parent form components do not change the expected behavior of the parent.

By adhering to the LSP, you create more robust and flexible Angular applications that are easier to maintain and extend.

Interface Segregation Principle (ISP)

The ISP states, “Clients should not be forced to depend on interfaces they do not use.”

In simpler terms, it’s better to have multiple, smaller, and more specific interfaces rather than a single, large, general-purpose interface. This way, classes are not forced to implement unneeded methods.

In Angular, we can apply the ISP by:

  • Creating focused interfaces: Instead of creating large, monolithic interfaces, we can break them down into smaller, more specific ones.
  • Using role interfaces: Define interfaces that represent specific roles or responsibilities within the application.
  • Applying it to services and components: Ensure that services and components only depend on the interfaces they use.

Example: A Fat Interface (Violation of ISP)

First, consider the following printer interface.

export interface Printer {
  print(document: any): void;
  scan(document: any): void;
  fax(document: any): void;
}

This interface defines methods for printing, scanning, and faxing.

Now, consider the following code example for two implementations of this interface.

export class AllInOnePrinter implements Printer {
  print(document: any): void {
    console.log('Printing document:', document);
  }

  scan(document: any): void {
    console.log('Scanning document:', document);
  }

  fax(document: any): void {
    console.log('Faxing document:', document);
  }
}

export class SimplePrinter implements Printer {
  print(document: any): void {
    console.log('Printing document:', document);
  }

  scan(document: any): void {
    throw new Error('Scan not supported');
  }

  fax(document: any): void {
    throw new Error('Fax not supported');
  }
}

The current design has some issues. The SimplePrinter class is required to implement scan and fax methods, even though it doesn’t support them, which violates the Interface Segregation Principle. Clients that only need printing functionality are unnecessarily tied to scan and fax methods. Additionally, if the printer interface changes (e.g., a new method is added), all implementing classes must be updated, even if they don’t use the new method.

Refactoring to adhere to ISP

We can refactor this design by creating smaller, more specific interfaces.

First, consider the following set of interfaces.

export interface Printable {
    print(document: any): void;
}

export interface Scannable {
    scan(document: any): void;
}

export interface Faxable {
    fax(document: any): void;
}

Next, we can update the implementation for these interfaces.

export class AllInOnePrinter implements Printable, Scannable, Faxable {
  print(document: any): void {
    console.log('Printing document:', document);
  }

  scan(document: any): void {
    console.log('Scanning document:', document);
  }

  fax(document: any): void {
    console.log('Faxing document:', document);
  }
}

export class SimplePrinter implements Printable {
  print(document: any): void {
    console.log('Printing document:', document);
  }
}

We’ve improved the design by creating separate interfaces for Printable, Scannable, and Faxable. SimplePrinter only implements the Printable interface, ensuring it follows the ISP. This allows clients that only require printing functionality to depend on the Printable interface without being affected by changes in the Scannable or Faxable interfaces.

Benefits of ISP

  • Reduced dependencies: Clients only depend on the interfaces they need.
  • Improved flexibility: Easier to change or extend functionality without affecting other clients.
  • Enhanced maintainability: Code is easier to understand and maintain.
  • Cleaner design: Results in a more organized and modular codebase.

Angular-specific considerations

  • When creating services that provide different functionalities, break them down into smaller, focused interfaces.
  • When designing components that handle different types of data or interactions, create role-based interfaces.
  • When using directives, consider very focused interfaces if the directive is to be used in multiple situations.

By adhering to the ISP, you can create more modular, maintainable, and flexible Angular applications.

Be amazed exploring what kind of application you can develop using Syncfusion® Angular components.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that:

  • High-level modules should not depend on low-level modules. Both should depend on abstractions.
  • Abstractions should not depend on details. Details should depend on abstractions.

Essentially, it promotes decoupling by having high-level modules (business logic) depending on abstractions (interfaces or abstract classes) rather than concrete implementations of low-level modules (data access, external services).

In Angular, we can apply the DIP by using Angular’s built-in dependency injection system.

Dependency injection (DI) is a design pattern that allows a class to receive its dependencies from external sources rather than creating them itself. In Angular, DI is a core mechanism that makes applications more modular, testable, and maintainable.

Key concepts in Angular Dependency Injection (DI)

  • Dependency provider: Anything that can be used as a dependency, such as a class, function, object, or even a simple value like a string or Boolean.
  • Dependency consumer: A class that needs a dependency to work properly.
  • Injector: An abstraction that connects consumers with the right dependencies.

How Angular DI works

Declaring Dependencies

  • Dependencies are usually declared in a class’s constructor or using the inject() method.
  • Angular looks at the type of the constructor parameter to find the correct dependency.

Providing Dependencies

  • Dependencies are registered in modules, components, or directives using the provider’s array.
  • Providers tell Angular how to create an instance of a dependency.

 Injecting Dependencies

  • When Angular creates a class instance, it checks the injector for the required dependencies.
  • If the dependency doesn’t exist, the injector creates a new instance using the registered provider.
  • Once found, Angular injects the dependency into the class’s constructor.

Let us understand this with the help of an example.

First, create a LoggerService that needs to act as a dependency in a component.

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

@Injectable({
  providedIn: 'root',
})
export class LoggerService {
  log(message: string): void {
    console.log(`LOG: ${message}`);
  }
}

`@Injectable({ providedIn: ‘root’ })` marks a class as a service that can be injected into other parts of the app, whereas `providedIn: ‘root’` registers the service in the root injector, ensuring that:

  • Angular creates one shared instance of the service for the entire app.
  • Any class that needs the service gets the same instance.

This approach helps optimize the app by allowing Angular and JavaScript tools to remove unused services through tree-shaking.
 Finally, inject the service into a component.

import { Component, inject, OnInit } from '@angular/core';
import { LoggerService } from '../logger.service';

@Component({
  selector: 'app-my-component',
  templateUrl: './my-component.component.html',
  styleUrl: './my-component.component.scss',
})
export class MyComponentComponent implements OnInit {
  private readonly logger = inject(LoggerService);

  ngOnInit() {
    this.logger.log('MyComponent initialized.');
  }
}

The LoggerService is injected into the MyComponentComponent using the inject function. Angular uses the type LoggerService as the token to resolve the dependency.

Providing a service at the component level

We can also register a service at the component level using the providers array in the component metadata.

Create a LoggerService that needs to act as a dependency in a component.

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

@Injectable()
export class LoggerService {
  log(message: string) {
    console.log(`LOG: ${message}`);
  }
}

Please note that we have not provided any options to the @Injectable function.

We can provide this service at the component level, as shown.

import { Component, inject, OnInit } from '@angular/core';
import { LoggerService } from '../logger.service';

@Component({
  selector: 'app-my-component',
  templateUrl: './my-component.component.html',
  styleUrl: './my-component.component.scss',
  providers: [LoggerService] // Provide service here
})
export class MyComponentComponent implements OnInit {
  private readonly logger = inject(LoggerService);

  ngOnInit() {
    this.logger.log('MyComponent initialized.');
  }
}

Providing a Service at the Component Level will create a new instance of LoggerService specific to the MyComponent.

Creating dependency dynamically

We can use the useFactory to create a dependency dynamically. Create a token.ts file and add the following code to it.

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

export const API_URL = new InjectionToken<string>('API_URL');

export function apiUrlFactory(production: boolean) { 
  return production ? 'https://production.api.com' : 'https://development.api.com'; 
}

This file defines an Angular injection token and a factory function.

We have created an InjectionToken called API_URL to provide a strongly typed token for dependency injection.

The apiUrlFactory is a factory function that returns different API URLs based on whether the application is in production mode or not. This setup allows for injecting different API URLs into services or components based on the application’s environment.

We can provide the injection token in a component as shown in the next code example.

import { Component, inject } from '@angular/core';
import { API_URL, apiUrlFactory } from '../token';
import { isProduction } from '../env/env';

@Component({
  selector: 'app-my-component',
  template: ` {{ apiUrl }}`,
  styleUrl: './my-component.component.scss',
  providers: [
    { provide: API_URL, useFactory: () => apiUrlFactory(isProduction) },
  ],
})
export class MyComponentComponent {
  protected readonly apiUrl = inject(API_URL);
}

The providers array in the @Component decorator is used to configure dependency injection for this component.

The useFactory function uses the apiUrlFactory function to determine the value of API_URL. The factory function takes the isProduction flag as an argument to decide whether to return the production or development API URL. This configuration allows the component to inject the appropriate API URL based on the application’s environment.

Benefits of Dependency Injection

  • Testability: Easily swap dependencies with mocks or stubs for testing.
  • Modularity: Encourages loose coupling, making components more independent.
  • Maintainability: Simplifies updates and reduces direct dependencies between classes.
  • Reusability: Makes it easier to reuse services and components across the app.

Harness the power of feature-rich and powerful Syncfusion® Angular UI components.

Summary

In this article, we explored how the SOLID principles can help us write better Angular applications—applications that are more maintainable, scalable, and easier to extend. By following these principles, we can design cleaner, more modular code that stands the test of time. Whether you’re working on a small project or a large enterprise app, keeping SOLID in mind will make a significant difference.

The Syncfusion® Angular UI components library is a comprehensive suite that provides everything you need to build an application. It includes over 90 high-performance, lightweight, modular, and responsive UI components—all in a single package. You can try our 30-day free trial to check out its features.

You can also contact us through our support forumssupport portal, or feedback portal. We are always happy to assist you! 

Meet the Author

Ankit Sharma

Ankit Sharma is an author, a speaker, and a passionate programmer. He is a Google Developer Expert (GDE) for Angular and Microsoft’s Most Valuable Professional (MVP). Currently, he works in Cisco Systems as a software development engineer.