The ultimate guide to Software Design Principles - Part 2
Design principles make software flexible to change
We often find ourselves breaking a large application into several classes. Those classes contain a lot of information and too much responsibility. We end up writing classes that are less cohesive, tightly coupled, and difficult to maintain and refactor. They are messy, hard to read and understand.
Are you tired of working with code that's difficult to understand, maintain, and modify?
Do you often find yourself spending more time fixing bugs and making changes than actually developing new features?
If so, you're not alone.
Many developers have faced similar challenges, which is why the SOLID principles
were created. The SOLID principles were developed by Robert C. Martin in a 2000 essay, “Design Principles and Design Patterns,” although the acronym was coined later by Michael Feathers. In his essay, Martin acknowledged that successful software will change and develop. As it changes, it becomes increasingly complex. Without good design principles, Martin warns that software becomes rigid, fragile, immobile, and vicious. The SOLID principles were developed to combat these problematic design patterns.
The broad goal of the SOLID principles is to reduce dependencies so that engineers change one area of software without impacting others. Additionally, they’re intended to make designs easier to understand, maintain, and extend. Ultimately, using these design principles makes it easier for software engineers to avoid issues and build adaptive, effective, and agile software.
In this article, we'll explore these principles and show you how to apply them to your own projects. By the end, you'll have a good understanding of how to write robust and scalable code and be able to work well with others.
Whether you're a seasoned developer or just getting started, read on to discover the power of SOLID principles.
Single Responsibility Principle
a class should have one, and only one, reason to change
This principle stated that each class only does one thing and every class or module has responsibility for only one part of the software’s functionality. More simply, each class should solve only one problem.
Let's look at the code for a simple bookstore invoice program as an example.
class Book {
String name;
String authorName;
int year;
int price;
String isbn;
public Book(String name, String authorName, int year, int price, String isbn) {
this.name = name;
this.authorName = authorName;
this.year = year;
this.price = price;
this.isbn = isbn;
}
}
This is a simple book class with some fields. Nothing fancy.
Now let's create the invoice class which will contain the logic for creating the invoice and calculating the total price.
public class Invoice {
private Book book;
private int quantity;
private double discountRate;
private double taxRate;
private double total;
public Invoice(Book book, int quantity, double discountRate, double taxRate) {
this.book = book;
this.quantity = quantity;
this.discountRate = discountRate;
this.taxRate = taxRate;
this.total = this.calculateTotal();
}
public double calculateTotal() {
double price = ((book.price - book.price * discountRate) * this.quantity);
double priceWithTaxes = price * (1 + taxRate);
return priceWithTaxes;
}
public void printInvoice() {
System.out.println(quantity + "x " + book.name + " " + book.price + "$");
System.out.println("Discount Rate: " + discountRate);
System.out.println("Tax Rate: " + taxRate);
System.out.println("Total: " + total);
}
public void saveToFile(String filename) {
// Creates a file with given name and writes the invoice
}
}
Here is our invoice class. It also contains some fields about invoicing and 3 methods:
calculateTotal
method, which calculates the total price,printInvoice
method, which should print the invoice to the console, andsaveToFile
method, responsible for writing the invoice to a file.
You should give yourself a second to think about what is wrong with this class design before reading the next paragraph.
Ok, so what's going on here? Our class violates the Single Responsibility Principle in multiple ways.
The first violation is the printInvoice
method, which contains our printing logic. The SRP states that our class should only have a single reason to change, and that reason should be a change in the invoice calculation for our class.
But in this design, if we ever wanted to change the printing format, we would need to change the invoice class. This is why we should not have printing logic mixed with business logic in the same class.
There is another method that violates the SRP in our class: the saveToFile
method. It is also an extremely common mistake to mix persistence logic with business logic.
Don't just think in terms of writing to a file – it could be saving to a database, making an API call, or other stuff related to persistence.
So how can we fix this print function, you may ask?
We can create new classes for our printing and persistence logic so we will no longer need to modify the invoice class for those purposes.
We create 2 classes, InvoicePrinter
and, InvoicePersistence
and move the methods.
public class InvoicePrinter {
private Invoice invoice;
public InvoicePrinter(Invoice invoice) {
this.invoice = invoice;
}
public void print() {
System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $");
System.out.println("Discount Rate: " + invoice.discountRate);
System.out.println("Tax Rate: " + invoice.taxRate);
System.out.println("Total: " + invoice.total + " $");
}
}
public class InvoicePersistence {
Invoice invoice;
public InvoicePersistence(Invoice invoice) {
this.invoice = invoice;
}
public void saveToFile(String filename) {
// Creates a file with given name and writes the invoice
}
}
Now our class structure obeys the Single Responsibility Principle and every class is responsible for one aspect of our application.
Open Close Principle
Objects or entities should be open for extension but closed for modification.
Modification means changing the code of an existing class, and extension means adding new functionality.
So what this principle wants to say is: We should be able to add new functionality without touching the existing code for the class. This is because whenever we modify the existing code, we are taking the risk of creating potential bugs. So we should avoid touching the tested and reliable (mostly) production code if possible.
But how are we going to add new functionality without touching the class, you may ask? It is usually done with the help of interfaces and abstract classes.
Now that we have covered the basics of the principle, let's apply it to our Bookstore application.
Let's say our boss came to us and said that they want invoices to be saved to a database so that we can search them easily. We think okay, this is easy peasy boss, just give me a second!
We create the database, connect to it, and we add a save method to our InvoicePersistence
class.
public class InvoicePersistence {
Invoice invoice;
public InvoicePersistence(Invoice invoice) {
this.invoice = invoice;
}
public void saveToFile(String filename) {
// Creates a file with given name and writes the invoice
}
public void saveToDatabase() {
// Saves the invoice to database
}
}
Unfortunately we, as the lazy developer for the bookstore, did not design the classes to be easily extendable in the future. So in order to add this feature, we have modified the InvoicePersistence
class.
If our class design obeyed the Open-Closed principle we would not need to change this class.
So, as the lazy but clever developer for the bookstore, we see the design problem and decide to refactor the code to obey the principle.
interface InvoicePersistence {
public void save(Invoice invoice);
}
We change the type of InvoicePersistence
to Interface and add a save method. Each persistence class will implement this save method.
public class DatabasePersistence implements InvoicePersistence {
@Override
public void save(Invoice invoice) {
// Save to DB
}
}
public class FilePersistence implements InvoicePersistence {
@Override
public void save(Invoice invoice) {
// Save to file
}
}
So our class structure now looks like this:
Now our persistence logic is easily extendable. If our boss asks us to add another database and have 2 different types of databases like MySQL and MongoDB, we can easily do that.
You may think that we could just create multiple classes without an interface and add a save method to all of them.
But let's say that we extend our app and have multiple persistence classes like InvoicePersistence
, BookPersistence
and we create a PersistenceManager
a class that manages all persistence classes:
public class PersistenceManager {
InvoicePersistence invoicePersistence;
BookPersistence bookPersistence;
public PersistenceManager(InvoicePersistence invoicePersistence,
BookPersistence bookPersistence) {
this.invoicePersistence = invoicePersistence;
this.bookPersistence = bookPersistence;
}
}
We can now inject any class that implements the InvoicePersistence
interface into this class with the help of polymorphism. This is the flexibility that interfaces provide.
Liskov Substitution Principle
Objects of a superclass should be replaceable with an object of its subclasses without breaking the application
Of the five SOLID principles, the Liskov Substitution Principle is perhaps the most difficult one to understand. This Principle states that subclasses should be substitutable for their base classes.
This means that, given that class B is a subclass of class A, we should be able to pass an object of class B to any method that expects an object of class A and the method should not give any weird output in that case.
This is the expected behavior because when we use inheritance we assume that the child class inherits everything that the superclass has. The child class extends the behavior but never narrows it down.
Therefore, when a class does not obey this principle, it leads to some nasty bugs that are hard to detect.
Liskov's principle is easy to understand but hard to detect in code. So let's look at an example.
class Rectangle {
protected int width, height;
public Rectangle() {
}
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
public int getArea() {
return width * height;
}
}
We have a simple Rectangle class and a getArea
function which returns the area of the rectangle.
Now we decide to create another class for Squares. As you might know, a square is just a special type of rectangle where the width is equal to the height.
class Square extends Rectangle {
public Square() {}
public Square(int size) {
width = height = size;
}
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height);
}
}
Our Square class extends the Rectangle class. We set height and width to the same value in the constructor, but we do not want any client (someone who uses our class in their code) to change height or weight in a way that can violate the square property.
Therefore we override the setters to set both properties whenever one of them is changed. But by doing that we have just violated the Liskov substitution principle.
Let's create a main class to perform tests on the getArea
function.
class Test {
static void getAreaTest(Rectangle r) {
int width = r.getWidth();
r.setHeight(10);
System.out.println("Expected area of " + (width * 10) + ", got " + r.getArea());
}
public static void main(String[] args) {
Rectangle rc = new Rectangle(2, 3);
getAreaTest(rc);
Rectangle sq = new Square();
sq.setWidth(5);
getAreaTest(sq);
}
}
Your team's tester just came up with the testing function getAreaTest
and tells you that your getArea
function fails to pass the test for square objects.
In the first test, we create a rectangle where the width is 2 and the height is 3 and call getAreaTest
. The output is 20 as expected, but things go wrong when we pass in the square. This is because the call to setHeight
function in the test is setting the width as well and results in an unexpected output.
Interface Segregation Principle
Prevent classes from relying on things that they don't need
The general idea of the interface segregation principle is that it’s better to have a lot of smaller interfaces than a few bigger ones. Martin explains this principle by advising, “Make fine-grained interfaces that are client-specific. Clients should not be forced to implement interfaces they do not use.”
Let's look into a situation where we've got a Payment
interface used by an implementation BankPayment
:
public interface Payment {
void initiatePayments();
Object status();
List<Object> getPayments();
}
And the implementation:
public class BankPayment implements Payment {
@Override
public void initiatePayments() {
// ...
}
@Override
public Object status() {
// ...
}
@Override
public List<Object> getPayments() {
// ...
}
}
This is very clear — so far, the implementing class BankPayment
needs all the methods in the Payment
interface. Thus, it doesn't violate the principle.
Now, as we move ahead in time, and more features come in, there's a need to add a LoanPayment
service. This service is also a kind of Payment
but has a few more operations.
To develop this new feature, we'll add new methods to the Payment
interface:
public interface Payment {
// original methods
...
void intiateLoanSettlement();
void initiateRePayment();
}
Next, we'll have the LoanPayment
implementation:
public class LoanPayment implements Payment {
@Override
public void initiatePayments() {
throw new UnsupportedOperationException("This is not a bank payment");
}
@Override
public Object status() {
// ...
}
@Override
public List<Object> getPayments() {
// ...
}
@Override
public void intiateLoanSettlement() {
// ...
}
@Override
public void initiateRePayment() {
// ...
}
}
Now, since the Payment
interface has changed and more methods were added, all the implementing classes now have to implement the new methods. The problem is, implementing them is unwanted and could lead to many side effects. Here, the LoanPayment
implementation class has to implement the initiatePayments()
without any actual need for this. And so, the principle is violated.
So, what happens to our BankPayment
class:
public class BankPayment implements Payment {
@Override
public void initiatePayments() {
// ...
}
@Override
public Object status() {
// ...
}
@Override
public List<Object> getPayments() {
// ...
}
@Override
public void intiateLoanSettlement() {
throw new UnsupportedOperationException("This is not a loan payment");
}
@Override
public void initiateRePayment() {
throw new UnsupportedOperationException("This is not a loan payment");
}
}
Note that the BankPayment
implementation now has implemented the new methods. And since it does not need them and has no logic for them, it's just throwing an UnsupportedOperationException. This is where we start violating the principle.
We have intentionally polluted the interface and violated the principle. Let's see how to add the new feature for loan payment without violating the principle.
let's break up the interfaces and apply the Interface Segregation Principle.
public interface Payment {
Object status();
List<Object> getPayments();
}
And two more interfaces for the two types of payments:
public interface Bank extends Payment {
void initiatePayments();
}
public interface Loan extends Payment {
void intiateLoanSettlement();
void initiateRePayment();
}
And the respective implementations, starting with BankPayment:
public class BankPayment implements Bank {
@Override
public void initiatePayments() {
// ...
}
@Override
public Object status() {
// ...
}
@Override
public List<Object> getPayments() {
// ...
}
}
And finally, our revised LoanPayment
implementation:
public class LoanPayment implements Loan {
@Override
public void intiateLoanSettlement() {
// ...
}
@Override
public void initiateRePayment() {
// ...
}
@Override
public Object status() {
// ...
}
@Override
public List<Object> getPayments() {
// ...
}
}
As we can see, the interfaces don't violate the principle. The implementations don't have to provide empty methods. This keeps the code clean and reduces the chance of bugs.
Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on the abstraction.
Abstractions should not depend on details. Details should depend on abstractions.
Firstly, let’s define the terms used here more simply
High-level Module(or Class): Class that executes an action with a tool.
Low-level Module (or Class): The tool that is needed to execute the action
Abstraction: Represents an interface that connects the two Classes.
Details: How the tool works
This principle says a Class should not be fused with the tool it uses to execute an action. Rather, it should be fused to the interface that will allow the tool to connect to the Class.
It also says that both the Class and the interface should not know how the tool works. However, the tool needs to meet the specification of the interface.
When we say abstraction should not depend on details, it basically means you should not do something like this
public class PersistenceManager {
FilePersistence filePersistence;
public PersistenceManager(FilePersistence filePersistence) {
this.filePersistence = filePersistence;
}
}
Instead, you should do this
public class PersistenceManager {
InvoicePersistence invoicePersistence;
public PersistenceManager(InvoicePersistence invoicePersistence) {
this.invoicePersistence = invoicePersistence;
}
}
Uncle Bob summarizes this principle as follows:
If the OCP states the goal of OO architecture, the DIP states the primary mechanism
These two principles are indeed related and we have applied this pattern before while we were discussing the Open-Closed Principle.
We want our classes to be open to extension, so we have reorganized our dependencies to depend on interfaces instead of concrete classes. Our PersistenceManager class depends on InvoicePersistence instead of the classes (DatabasePersistence, FilePersistence) that implement that interface.
Credits / References:
https://medium.com/backticks-tildes/the-s-o-l-i-d-principles-in-pictures-b34ce2f1e898
https://www.freecodecamp.org/news/solid-principles-explained-in-plain-english
https://1kevinson.com/solid-principles-timeless-wisdom-on-building-high-quality-software/
Illustrations: https://medium.com/@ugonnat
Part 1: iamazizbohra.hashnode.dev/the-ultimate-guid..
Thanks for reading
I really hope you like reading it
Follow me: https://www.linkedin.com/in/imazizbohra/