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.
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.
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!'); }); } }
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.
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:
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:
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:
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.
By embracing the OCP, you can build more robust and maintainable Angular applications.
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[]>; }
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:
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.
By adhering to the LSP, you create more robust and flexible Angular applications that are easier to maintain and extend.
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:
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.
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.
By adhering to the ISP, you can create more modular, maintainable, and flexible Angular applications.
The Dependency Inversion Principle states that:
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.
Declaring Dependencies
Providing Dependencies
Injecting Dependencies
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:
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.
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.
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.
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 forums, support portal, or feedback portal. We are always happy to assist you!