SOLID Principles

Experienced full-stack developer skilled in Node.js, React.js, and Django, with expertise in building and maintaining web applications. Proficient in using Redis DB and MongoDB for back-end development, and Antd and Material UI for front-end design. Strong knowledge of C++, Python, Rust, and Julia. Well-versed in tools such as Postman, Git, Github, and Linux.
The SOLID Principles are five principles of Object-Oriented class design. They are a set of rules and best practices to follow while designing a class structure.
History
The SOLID principles were introduced by Robert J. Martin in his paper in 2000. But the SOLID acronym was introduced later by Michael Feathers.
SOLID principles acronym for
The Single Responsibility Principle.
The Open-Closed Principle.
The Liskov Substitution Principle.
The Interface Segregation Principle.
The Dependency Inversion Principle.
Single Responsibility Principle
The idea behind the Single Responsibility Principle is that every class, module, and function should have only one responsibility.
type User struct {
ID int
FirstName string
LastName string
Email string
}
type UserRegister struct{}
func (ur *UserRegister) RegisterUser(user *User) {
ur.saveUserToDB(user)
ur.sendWelcomeEmail(user)
}
func (ur *UserRegister) saveUserToDB(user *User) {
fmt.Printf("Saving user %s to the database...\n", user.FirstName)
time.Sleep(1 * time.Second)
fmt.Printf("User %s saved to the database successfully \n", user.FirstName)
}
func (ur *UserRegister) sendWelcomeEmail(user *User) {
fmt.Printf("Sending welcome email to %s at %s \n", user.FirstName, user.Email)
time.Sleep(1 * time.Second)
fmt.Printf("Welcome email to %s at %s \n", user.FirstName, user.Email)
}
The RegisterUser has two responsibility
saveUserToDBsendWelcomeEmail
According to Single Responsibility Principle, every class, module, and function should have only one responsibility.
Code after applying Single Responsibility Principle
type User struct {
ID int
FirstName string
LastName string
Email string
}
type UserDB struct{}
func (udb *UserDB) SaveUser(user *User) {
fmt.Printf("Saving user %s to the database...\n", user.FirstName)
time.Sleep(1 * time.Second)
fmt.Printf("User %s saved to the database successfully \n", user.FirstName)
}
type EmailSender struct{}
func (es *EmailSender) SendWelcomeEmail(user *User) {
fmt.Printf("Sending welcome email to %s at %s \n", user.FirstName, user.Email)
time.Sleep(1 * time.Second)
fmt.Printf("Welcome email to %s at %s \n", user.FirstName, user.Email)
}
Open/Closed Principle
The open-closed principle states that software entities (classes, modules, functions, and so on) should be open for extension, but closed for modification.

As the diagram suggests, we can not modify the shape but extend it.
type Circle struct {
Radius float64
}
type Square struct {
SideLength float64
}
type Rectangle struct {
Width float64
Height float64
}
type Calculator struct{}
func (c *Calculator) CalculateCircleArea(circle Circle) float64 {
return math.Pi * circle.Radius * circle.Radius
}
func (c *Calculator) CalculateSquareArea(square Square) float64 {
return square.SideLength * square.SideLength
}
func (c *Calculator) CalculateRectangleArea(rectangle Rectangle) float64 {
return rectangle.Width * rectangle.Height
}
If we want to add a new shape, we have to modify the existing Calculator struct and add a new method for the triangle, which violates the principle.
Let's apply the open-closed principle
type Shape interface {
Area() float64
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
type Square struct {
SideLength float64
}
func (s Square) Area() float64 {
return s.SideLength * s.SideLength
}
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
type Calculator struct{}
func (c *Calculator) CalculateArea(shape Shape) float64 {
return shape.Area()
}
Now, We have defined the Interface name Shape with an Area() method and each shape implements this interface. The Calculator struct can now work with any shape that implements the Shape interface without requiring modification. If you want to add a new shape, you can create a new type that implements the Shape interface and Calculator will work with it seamlessly.
Liskov Substitution Principle
According to Barbara Liskov and Jeannette Wing, the Liskov substitution principle states that:
Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.
This Principle implies that when an instance of a class is passed/extended to another class, the inheriting class should have a use case for all the properties and behavior of the inherited class.
Let's say we have a class called Amphibian for animals that can live on both land and water. This class has two methods to show the features of an amphibian – swim() and walk().
public class Amphibian {
public void swim();
public void walk();
}
The Amphibian class can extend to a Frog class because frogs are amphibians so that they can inherit the properties of the Amphibian class without altering the logic and purpose of the class.
public class Frog extends Amphibian {
public void swim() {
System.out.println("The frog is swimming");
}
public void walk() {
System.out.println("The frog is walking on land");
}
}
But we cannot extend the Amphibian class to a Dolphin class because dolphins only live in water which implies that the walk() method would be irrelevant to the Dolphin class.
In summary, if a class inherits another, it should do so in a manner that all the properties of the inherited class would remain relevant to its functionality.
Interface Segregation Principle
The interface segregation principle states that the interface of a program should be split in a way that the user/client would only have access to the necessary methods related to their needs.
type SPrinter interface {
PrintDocument()
ScanDocument()
FaxDocument()
}
type SimplePrinter struct{}
func (sp *SimplePrinter) PrintDocument() {
fmt.Println("Printing document......")
}
func (sp *SimplePrinter) ScanDocument() {
fmt.Println("Scanning document......")
}
func (sp *SimplePrinter) FaxDocument() {
fmt.Println("Faxing document......")
}
type OfficePrinter struct{}
func (op *OfficePrinter) PrintDocument() {
fmt.Println("Printing office document......")
}
func (op *OfficePrinter) ScanDocument() {
fmt.Println("Scanning office document......")
}
type HomePrinter struct{}
func (hp *HomePrinter) PrintDocument() {
fmt.Println("Printing document at home...")
}
func (hp *HomePrinter) ScanDocument() {
fmt.Println("Scanning document at home...")
}
In the given code, the SPrinter interface has three methods: PrintDocument(), ScanDocument(), and FaxDocument(). However, the implementations of the interface in the SimplePrinter, OfficePrinter, and HomePrinter structs suggest that not all of them require or support all three methods.
For example, the SimplePrinter only needs to print and scan documents, but it is forced to implement the FaxDocument() method from the SPrinter interface. Similarly, the HomePrinter only need to print and scan documents, but it's also forced to implement the FaxDocument() method.
Let's apply Interface Segregation Principle
type Printer interface {
PrintDocument()
}
type Scanner interface {
ScanDocument()
}
type Faxer interface {
FaxDocument()
}
type Simple_Printer struct{}
func (sp *Simple_Printer) PrintDocument() {
fmt.Println("Printing document......")
}
func (sp *Simple_Printer) ScanDocument() {
fmt.Println("Scanning document......")
}
func (sp *Simple_Printer) FaxDocument() {
fmt.Println("Faxing document......")
}
type Office_Printer struct{}
func (op *Office_Printer) PrintDocument() {
fmt.Println("Printing document at the office......")
}
func (op *Office_Printer) ScanDocument() {
fmt.Println("Scanning document at the office......")
}
type Home_Printer struct{}
func (hp *Home_Printer) PrintDocument() {
fmt.Println("Printing document at the home......")
}
func (hp *Home_Printer) ScanDocument() {
fmt.Println("Scanning document at the home......")
}
Dependency Inversion Principle
The dependency inversion principle states:
High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).
And,
Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions
Here is a code example that violates this principle:
type User struct {
Name string
Email string
PhoneNumber string
Notification Notification // Dependency on the Notification interface
}
func (u User) Notify(message string) {
u.Notification.Send(message)
}
By improving
type User struct {
Name string
Email string
PhoneNumber string
}
func (u User) Notify(notifier Notification, message string) {
notifier.Send(message)
}
Conclusion
There are so many ways to solve a problem. But there are also many ways to create problems from a solution.
The more rigid and coupled the classes and methods in our code are, the more difficult it would be to maintain and reuse our code.
Neglecting or violating these principles could pose a serious threat to not just the codebase and the developer, but to the organization that owns the product as well.
A rigid and tightly coupled codebase makes it difficult to add or remove features in a product, test, and reuse blocks of code and introduces possible breaking changes with every code modification made.
The SOLID principles act as a guide to help us create a flexible and dynamic product and we went over each principle in this article to help us understand how the objects we create should interact which each other.


