How to Apply SOLID Design Principles in Angular for Clean, Maintainable Code
Today, we’re going to talk about design principles in Angular. These are like a set of rules that we, as Angular developers, follow to make sure our code is organized, easy to understand, and easy to maintain over time.
Think of it like building a big tower out of blocks. You want the blocks to fit together perfectly, right? And you don’t want the tower to fall apart easily. That’s what design principles help us achieve in our code.
One popular set of principles is called SOLID, which stands for Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. These five principles help us write code that’s easy to understand, modify, and add to.
By following these principles, we can build code that’s like a strong, tall tower — it won’t fall apart easily and it’s easy to add new blocks (features) to it.
In this article, we will delve into each of the SOLID design principles and explore how they can be applied in Angular.
Let’s take a closer look at the Single Responsibility Principle.
Single Responsibility Principle (SRP).
Single Responsibility Principle (SRP).
The SRP is one of the most important principles in Angular because it helps us keep our code organized and easy to understand. Simply put, it says that each piece of code should have one, clear job to do.
Think of it like this: Imagine you have a friend who’s really good at cooking. But they’re also great at cleaning, fixing cars, and giving haircuts. That’s a lot of jobs for one person! It would be much easier if they just focused on cooking and did that really well.
The same thing goes for our code. If we have a piece of code that’s trying to do too many things, it can get complicated and hard to understand. But if we have a piece of code that has one clear job, it’s easier to maintain and understand.
Here’s an example in Angular code: Let’s say we have a component that’s responsible for displaying a list of books. But, it also has a function that calculates the total number of pages in all the books. That’s two jobs! So, to follow the SRP, we could split those two responsibilities into two separate components: one to display the list of books, and another to calculate the total number of pages.
// BookListComponent
@Component({
selector: 'app-book-list',
template: `
<ul>
<li *ngFor="let book of books">{{ book.title }}</li>
</ul>
`
})
export class BookListComponent {
@Input() books: Book[];
}
// BookTotalPagesComponent
@Component({
selector: 'app-book-total-pages',
template: `
Total Pages: {{ totalPages }}
`
})
export class BookTotalPagesComponent {
@Input() books: Book[];
get totalPages() {
return this.books.reduce((total, book) => total + book.pages, 0);
}
}
By splitting our code into separate components, each with a single responsibility, we can make our code easier to understand and maintain.
Open/Closed Principle (OCP)
The Open/Closed Principle (OCP) is another important principle in Angular that helps us make our code flexible and easy to change.
The OCP says that our code should be open for extension, but closed for modification. In other words, we should be able to add new features to our code without changing the code itself.
Here’s an example to illustrate the OCP in Angular: Let’s say we have a component that displays a list of books. But, we want to allow the user to sort the books by either title or author.
One way to do this would be to add a sort function directly into the component. But, what if we want to add more sort options later, like sorting by publication date? We would have to go into the component and modify the code, which goes against the OCP.
A better way to handle this would be to create a separate service that handles the sorting. That way, we can add new sort options without changing the component code.
Here’s what that would look like in code:
// BookListComponent
@Component({
selector: 'app-book-list',
template: `
<select [(ngModel)]="sortBy">
<option value="title">Title</option>
<option value="author">Author</option>
</select>
<ul>
<li *ngFor="let book of books | sort:sortBy">{{ book.title }} by {{ book.author }}</li>
</ul>
`
})
export class BookListComponent {
books: Book[];
sortBy: string;
constructor(private bookService: BookService) {
this.books = this.bookService.getBooks();
}
}
// BookService
@Injectable({
providedIn: 'root'
})
export class BookService {
getBooks() {
return [
{ title: 'The Great Gatsby', author: 'F. Scott Fitzgerald' },
{ title: 'To Kill a Mockingbird', author: 'Harper Lee' },
{ title: 'Pride and Prejudice', author: 'Jane Austen' },
];
}
}
// SortPipe
@Pipe({
name: 'sort'
})
export class SortPipe implements PipeTransform {
transform(books: Book[], sortBy: string): Book[] {
return books.sort((a, b) => {
if (a[sortBy] < b[sortBy]) {
return -1;
}
if (a[sortBy] > b[sortBy]) {
return 1;
}
return 0;
});
}
}
By following the Open/Closed Principle, we can make our code more flexible and easier to change. We can add new features without having to modify the code itself, which makes our code more maintainable and easier to understand.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) is another important principle in Angular that helps us write code that is flexible and easy to change.
The LSP states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program. In other words, any method that works with a superclass object should work with a subclass object without any issues.
Here’s an example to illustrate the LSP in Angular: Let’s say we have a component that displays a list of animals. Each animal has a makeSound
method that returns the sound that the animal makes.
// Animal class
export class Animal {
makeSound() {
return 'Some generic animal sound';
}
}
// Dog class
export class Dog extends Animal {
makeSound() {
return 'Woof woof!';
}
}
// Cat class
export class Cat extends Animal {
makeSound() {
return 'Meow meow!';
}
}
// AnimalListComponent
@Component({
selector: 'app-animal-list',
template: `
<ul>
<li *ngFor="let animal of animals">{{ animal.makeSound() }}</li>
</ul>
`
})
export class AnimalListComponent {
animals: Animal[];
constructor() {
this.animals = [new Dog(), new Cat()];
}
}
In this example, the AnimalListComponent
works with a list of Animal
objects. But, since Dog
and Cat
are subclasses of Animal
, they can be used in place of Animal
objects without affecting the correctness of the program. The makeSound
method of each animal works correctly, and the AnimalListComponent
is able to display the correct sounds for each animal.
By following the LSP, we can write code that is flexible and easy to change. We can add new subclasses and modify existing subclasses without affecting the correctness of the program, which makes our code more maintainable and easier to understand.
Interface Segregation Principle (ISP)
The Interface Segregation Principle (ISP) is another important principle in Angular that helps us write code that is flexible and easy to change.
The ISP states that a class should not be forced to implement interfaces it does not use. In other words, a class should only have to implement the methods that are relevant to it, rather than being required to implement a large and complex interface with many methods that it does not need.
Here’s an example to illustrate the ISP in Angular: Let’s say we have an interface Animal
that defines the basic methods that all animals should have.
// Animal interface
export interface Animal {
makeSound(): string;
move(): void;
}
// Dog class
export class Dog implements Animal {
makeSound() {
return 'Woof woof!';
}
move() {
console.log('The dog runs');
}
}
// Cat class
export class Cat implements Animal {
makeSound() {
return 'Meow meow!';
}
move() {
console.log('The cat walks');
}
}
// AnimalDisplayComponent
@Component({
selector: 'app-animal-display',
template: `
<p>{{ animal.makeSound() }}</p>
<button (click)="animal.move()">Move</button>
`
})
export class AnimalDisplayComponent {
@Input() animal: Animal;
}
In this example, the Animal
interface defines two methods, makeSound
and move
. The Dog
and Cat
classes both implement the Animal
interface, but they only have to implement the methods that are relevant to them. For example, Dog
has a move
method that makes the dog run, while Cat
has a move
method that makes the cat walk.
By following the ISP, we can write code that is flexible and easy to change. We can add new methods to the Animal
interface without affecting existing classes that only need to implement a subset of the methods. This makes our code more maintainable and easier to understand.
Dependency Inversion Principle
The Dependency Inversion Principle (DIP) is another important principle in Angular that helps us write code that is flexible and easy to change.
The DIP states that high-level modules should not depend on low-level modules, but rather both should depend on abstractions. In other words, we should aim to write code that is decoupled and independent, so that changes in one part of the codebase do not affect other parts.
Here’s an example to illustrate the DIP in Angular: Let’s say we have a service that provides data to our application, and a component that displays that data.
// DataService
@Injectable({
providedIn: 'root'
})
export class DataService {
getData() {
return [1, 2, 3, 4, 5];
}
}
// DataDisplayComponent
@Component({
selector: 'app-data-display',
template: `
<ul>
<li *ngFor="let item of data">{{ item }}</li>
</ul>
`
})
export class DataDisplayComponent {
data: number[];
constructor(private dataService: DataService) {
this.data = this.dataService.getData();
}
}
In this example, the DataDisplayComponent
depends on the DataService
to provide data to display. However, this creates a tight coupling between the component and the service, and makes it difficult to change the data source in the future.
By following the DIP, we can write code that is decoupled and independent. Instead of depending directly on the DataService
, the component could depend on an abstraction, such as an interface, that defines the methods it needs.
// DataSource
export interface DataSource {
getData(): number[];
}
// DataService
@Injectable({
providedIn: 'root'
})
export class DataService implements DataSource {
getData() {
return [1, 2, 3, 4, 5];
}
}
// DataDisplayComponent
@Component({
selector: 'app-data-display',
template: `
<ul>
<li *ngFor="let item of data">{{ item }}</li>
</ul>
`
})
export class DataDisplayComponent {
data: number[];
constructor(private dataSource: DataSource) {
this.data = this.dataSource.getData();
}
}
By depending on the abstraction, the component is decoupled from the specific implementation of the data source. This makes it easier to change the data source in the future, without affecting the component.
So, following the Dependency Inversion Principle, it’s a good practice to create an interface that defines the methods that should be used in the component instead of directly consuming the service. This way, we achieve greater flexibility and maintainability in the application, as it allows the component to depend on abstractions rather than concrete dependencies. This way, if changes need to be made to the service, only the interface needs to be updated, rather than touching each component that directly consumes it.
Conclusion
The SOLID design principles are a set of guidelines that help us create high-quality, maintainable software applications. By following these principles in Angular, we can build applications that are easy to maintain and extend over time, without sacrificing functionality or performance. Whether you’re a seasoned Angular developer or just starting out, incorporating these principles into your workflow can help you create better, more effective applications.
Did you enjoy the post? Do you think there’s anything that could be improved or done differently? Don’t hesitate to leave it in the comments! You can also connect with me on my Twitter, facebook y LinkedIn accounts. ☺
Don’t forget to share it and feel free to give it a clap or two! 👏
Here’s what that would look like in code: