Skip to main content

Solid Principles for Software Design - Part 1

The SOLID principles are five design principles that are used to make software designs more understandable, flexible, and maintainable. They are a part of many principles promoted by software engineer Robert C. Martin, author of Clean Code. Solid stands for Single Responsibility Principle (SRP), Open/Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP)

In this part, we will talk about Single Responsibility Principle, Open/Closed Principle, and Liskov Substitution Principle.

1. Single Responsibility

The Single Responsibility Principle is a fundamental concept in object-oriented design that asserts that a software module, typically a class, should have one, and only one, reason to change. This principle is underpinned by two key concepts: cohesion and loose coupling.

Cohesion refers to the degree to which the elements of a module belong together. In the context of SRP, it implies that a class should encapsulate only behaviors that are closely related to its functionality. High cohesion within methods suggests that they are focused on a single task or purpose, and thus, they should be grouped within the same class.

Loose coupling describes the level of interdependence between modules. A well-designed system minimizes dependencies between classes, allowing for easier maintenance and scalability. For instance, a controller class in an API should be responsible solely for handling HTTP requests from clients and sending responses back, rather than directly implementing business logic or interacting with the database.


public class Book {
    private String title;
    private String author;
    private String content;

    public void printBook() {
        System.out.println("Printing book...");
    }

    public void save() {
        System.out.println("Saving book...");
        // Code to save the book to a database
    }
}

In the given scenario, the Book class is overburdened with multiple responsibilities. It is tasked with managing the book’s data and overseeing its printing and saving processes. This design contravenes the Single Responsibility Principle (SRP), which stipulates that a class should have only one reason to change.

To align with SRP, we can refactor the Book class by segregating its responsibilities into distinct classes. The printBook method can be extracted to a BookPrinter class, dedicated solely to the presentation of the book’s content. Similarly, the save method should be relocated to a BookRepository class, which interacts with the database and handles data persistence. This separation ensures that each class has a single, well-defined role, enhancing maintainability and promoting a cleaner, more modular architecture.


public class Book {
    private String title;
    private String author;
    private String content;
    //getter, setter methods
}

public class BookPrinter {
    public void printBook(Book book) {
        System.out.println("Printing book...");
    }
}

public class BookRepository {
    public void save(Book book) {
        System.out.println("Saving book...");
    }
}

2. Open/Closed Principles

The Open/Closed Principle (OCP) is a design concept that encourages software structures to be open for extension but closed for modification. This means that the behavior of a software component can be extended without altering its existing source code. The advantages of OCP include:

Risk Reduction: It minimizes the chance of introducing errors into an already functioning system when changes are required.

Enhanced Adaptability: By allowing new functionalities through extensions rather than modifications, the system becomes more flexible and easier to evolve over time.

Code Reusability: Extensibility inherently promotes the reuse of existing code and reduces redundancy and duplication.

Let’s examine InsurancePremiumDiscountCalculator class in Java that violates the OCP. This class is responsible for calculating insurance premiums and applying discounts.
public class InsurancePremiumDiscountCalculator {
    private String insuranceType;
    private CustomerProfile customerProfile;

    public int calculatePremiumDiscountPercentage(HealthInsuranceProfile customer) {
		if(customer.isLoyalCustomer()) {
        	return 20;
        }
        return 0;
    }
}

public class HealthInsuranceProfile {
    public boolean isLoyalCustomer() {
        return true;
    }
}
 
Imagine if in the future insurance company wants to add Vehicle insurance and then Home Insurance, we have to modify the existing InsurancePremiumDiscountCalculator class all the time and it may potentially introduce some bugs. So the solution could be refactoring the code and applying OCP principles by creating  a CustomerProfile like below:

public class InsurancePremiumDiscountCalculator {
    private String insuranceType;
    private CustomerProfile customerProfile;

    public int calculatePremiumDiscountPercentage(CustomerProfile customer) {
		if(customer.isLoyalCustomer()) {
        	return 20;
        }
        return 0;
    }
}
public interface CustomerProfile {
    public boolean isLoyalCustomer();
}

public class HealthInsuranceProfile implements CustomerProfile {
  	@Override
    public boolean isLoyalCustomer() {
        return true;
    }
}
public class VehicleInsuranceProfile implements CustomerProfile {
  	@Override
    public boolean isLoyalCustomer() {
        return true;
    }
}
 

3. Liskov Substitution Principle

The Liskov Substitution Principle (LSP) is a concept that states a subclass should be substitutable for its superclass without affecting the correctness of the program. This principle ensures that a subclass can stand in for its superclass in any situation, maintaining the expected behavior. For instance, consider a Product class and a subclass DiscountedProduct, that violates LSP:


class Product {
    protected double price;

    public void setPrice(double price) { 
    	this.price = price; 
    }
    public double getPrice() { 
    	return this.price; 
    }
}

class DiscountedProduct extends Product {
    private double discount;

    public void setDiscount(double discount) { 
    	this.discount = discount; 
    }
    public double getPrice() { 
    	return this.price - this.price * this.discount; 
    }
}
 

The DiscountedProduct class has an additional discount field, and it overrides getPrice method to return discount price. Now let's say we have a function to set a new price and then get new price.


void printNewPrice(Product product, double newPrice) {
    product.setPrice(newPrice);
    System.out.println("New price: " + product.getPrice());
}
 
If substituting a Product object with a DiscountedProduct object results in an incorrect price calculation, it indicates a breach of LSP. To comply with LSP, we can redesign the DiscountedProduct class as follows:

class DiscountedProduct extends Product {
    private double discount;

    public void setDiscount(double discount) { this.discount = discount; }
    public double getDiscountedPrice() { return this.price - this.price * this.discount; }
}
 

The necessity for such a design becomes apparent when dealing with a shopping cart system for example when calculating prices for a mix of discounted and non-discounted products. If both DiscountedProduct and hypothetical NonDiscountedProduct classes inherit from the Product class, they must implement a getPrice method. This method should function correctly, irrespective of the subclass used, ensuring that the system’s behavior remains consistent and predictable when different product types are processed.

By following LSP, we ensure that our classes are properly designed for inheritance, leading to a more robust and reliable system.


Comments

Popular posts from this blog

Declarative Programming in Angular with Async Pipe and shareReplay

A declarative approach is a way that focuses on writing code that specifies what the application should do, rather than detailing how it should be done. For example, with the async pipe in Angular, we don’t need to write code to manually subscribe to an Observable, handle its data, and update the view. Instead, we simply specify in the template that we want the data from the Observable using the async pipe. Angular handles all the underlying processes to retrieve and display the data It's often used in reactive programming with RxJS and Angular's built-in features, such as the async pipe. export class ProductComponent { product$ = this.productService.getProduct(); constructor(private productService: ProductService) {} } The product observable will hold the product data and the async pipe in the template will automatically subscribe and unsubscribe observable <div *ngIf="product$ | async as product"> <h1>{{ product.name }}</h1> <p>{{...

The Developer’s Guide to Clean Code: Tips and Techniques

What is clean code? Clean code is a term used to describe code that is easy to read, understand, and maintain. It is written in a way that makes it simple, concise, and expressive. Clean code follows a set of conventions, standards, and practices that make it easy to read and follow. Here are some signs indicating that the code is not clean: 1. Poor names The name is not clear to understand, meaningless, or misleading . It doesn't reveal the intention of what it want to achieve. Consider the following examples: SqlDataReader drl; int od; void Button1_Click(); Class Pages1 In the examples above, it’s challenging to get the purpose of drl, od, or what Button1_Click() does. To enhance clarity, we can rename these identifiers as follows: SqlDataReader dataReader/reader; int overdueDays; void CheckAvailability_Click(); Class ViewCustomerPage {} Ambiguous names int? incidentNameId for instance. incidentNameId lacks clarity because if it represents the ID of an incident, then the inclu...

Date and Time in .NET: DateTime, DateTimeOffset, TimeZoneInfo, DateOnly, TimeOnly, and TimeSpan

What is UTC UTC (Coordinated Universal Time) is the world’s primary time standard used to regulate clocks and time zones. It serves as the reference point for civil time worldwide, ensuring that all local times are defined by their offset from UTC. DateTime The DateTime class provides a way to work with dates and times without including an offset. This approach can reduce a certain level of accuracy when dealing with time zones. For example, calling DateTime.Now returns the current date and time based on your computer’s local time zone. DateTime.Now gives you the local time, while DateTime.UtcNow returns the universal coordinated time (UTC). You typically use DateTime when you only need to track the date and time itself, without worrying about time zones. This is suitable for scenarios such as birthdays, deadlines, or local schedules, especially when your application is used primarily within a single time zone. The DateTime class also includes a Kind property, which provides limited in...