How SOLID Principles Eradicate Code Smells

Yasindu Sanjeewa
10 min readDec 7, 2023

--

Building a house and developing software are similar. It should be strong, stable, and beautiful. However, your code may deteriorate due to careless practises, much like a house may have leaks and cracks. This is where code smells come in.

Code smells are subtle indications that your code is becoming difficult to maintain, understand, and extend. They might not seem like a big deal at first, but like termites in the foundation, they can gradually weaken your code’s integrity and lead to major problems down the road.

Fortunately, there’s a powerful solution to combat code smells: the SOLID principles. These five principles, introduced by Robert C. Martin and are widely used in object-oriented programming, provide a blueprint for writing clean, maintainable, and flexible code.

Let’s first dive into the world of code smells and understand what they are and why they are detrimental. we’ll explore each SOLID principle and discover how it acts as a shield against specific code smells, protecting your code from deterioration and ensuring its long-term health.

1. Single Responsibility Principle

This principle states that a class should have only one reason to change, meaning it should have only one responsibility. This helps in making the class more maintainable.

Consider a class representing a file manager. According to the Single Responsibility Principle, this class should have one specific responsibility. Let’s say its initial responsibility is to read and write files to disk.

class FileManager:
def read_file(self, filename):
# Code to read file from disk

def write_file(self, filename, content):
# Code to write file to disk

In the above example, the FileManager class has two responsibilities, reading files and writing files. If there are changes to file reading logic or file writing logic, the class would need to be modified for both reasons. This violates the Single Responsibility Principle.

class FileReader:
def read_file(self, filename):
# Code to read file from disk

class FileWriter:
def write_file(self, filename, content):
# Code to write file to disk

In this improved design, we have two separate classes: FileReader and FileWriter. Each class now has a single responsibility — either reading or writing files. If there are changes in how files are read, only the FileReader class needs to be modified, and similarly for writing files. This adheres to the Single Responsibility Principle, making the code more maintainable and modular.

Avoided Code Smells;

  • Long Class - The "Long Class" code smell refers to classes that are too large and complex, containing too many fields, methods, and lines of code. These classes typically violate the Single Responsibility Principle (SRP) and can be difficult to understand, maintain, and test.
  • Long Method - The "Long Method" code smell refers to methods that are excessively long and difficult to understand. These methods typically span many lines of code and handle more than one responsibility.
  • Long Parameter List - The "Long Parameter List" code smell refers to methods and functions that have an excessively large number of parameters. This can lead to several problems.

2. Open Close Principle

One of the SOLID principles, the Open/Closed Principle (OCP), emphasises that software entities, such classes, modules, and functions, should be closed to modification but open for extension. In simpler terms, a module’s behavior can be extended without changing the source code.

Let us consider a system that determines the areas of various geometric shapes, such as circles and rectangles. Every shape has a class at initial;


public abstract class Shape {
public abstract double calculateArea();
}


public class Rectangle extends Shape {
private double width;
private double height;

public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}

@Override
public double calculateArea() {
return width * height;
}
}


public class Circle extends Shape {
private double radius;

public Circle(double radius) {
this.radius = radius;
}

@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}

Let’s now assume that we want to include a Triangle as a new shape. According to the Open/Closed Principle, Without changing the current Shape, Rectangle, or Circle classes, we should be able to achieve this

// Triangle class (new shape)
public class Triangle extends Shape {
private double base;
private double height;

public Triangle(double base, double height) {
this.base = base;
this.height = height;
}

@Override
public double calculateArea() {
return 0.5 * base * height;
}
}

By adhering to the Open/Closed Principle, we can add new shapes (extension) without modifying the existing code (closed for modification). This makes the system more maintainable and less prone to introducing bugs when extending its functionality.

Avoided Code Smells;

  • Long Methods
  • Duplicate Codes — Duplicate code is a code smell that occurs when the same code is repeated multiple times in different parts of the application. It can also increase the risk of bugs, as changes to one copy of the code may not be reflected in all of the other copies. Duplicate code indicates that you are not taking advantage of the OCP. By refactoring your code to eliminate duplicate code, you make it easier to extend the functionality of your code without modifying the existing code
  • To many if statements / Nested Loops — A large number of if statements and nested loops increase time complexity of the application. It will reduce the efficiency of application. OCP encourages using polymorphism to avoid complex branching logic.

3. Liskov Substitution Principle

It 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, if a class S is a subclass of class T, an object of type T should be replaceable with an object of type S without affecting the functionality of the program.

Consider the class Account and two subclasses and CheckingAccount. Following the Liskov Substitution Principle, you should be able to use instances of the subclasses wherever and is expected without affecting the correctness of the program.

// Base class
public class Account {
private String accountNumber;
private double balance;

public void deposit(double amount) {
balance += amount;
}

public void withdraw(double amount) {
balance -= amount;
}

public double getBalance() {
return balance;
}
}
// Subclass 1
public class SavingsAccount extends Account {
private double interestRate;

public void applyInterest() {
// Logic to apply interest to savings account balance
double interest = getBalance() * interestRate / 100;
deposit(interest);
}
}
// Subclass 2
public class CheckingAccount extends Account {
private double overdraftLimit;

@Override
public void withdraw(double amount) {
// Allowing overdraft up to the specified limit
if (getBalance() - amount >= -overdraftLimit) {
super.withdraw(amount);
} else {
System.out.println("Insufficient funds!");
}
}
}

In this example, the SavingsAccount and CheckingAccount classes are subclasses of Account. Each subclass extends the base functionality of Account and provides its own specific features. For instance, SavingsAccount has an interest rate, and CheckingAccount has an overdraft limit. Now, let’s see how these classes might be used in a banking system;

public class BankingSystem {
public static void main(String[] args) {
Account savingsAccount = new SavingsAccount("SA123", 1000.0, 2.5);
Account checkingAccount = new CheckingAccount("CA456", 2000.0, 500.0);

// Using Liskov Substitution Principle
processAccount(savingsAccount);
processAccount(checkingAccount);
}

public static void processAccount(Account account) {
// Perform some operations on the account
account.deposit(500.0);
account.withdraw(200.0);

// Display the balance
System.out.println("Account Balance: " + account.getBalance());

// If it's a SavingsAccount, apply interest
if (account instanceof SavingsAccount) {
((SavingsAccount) account).applyInterest();
}

// If it's a CheckingAccount, display overdraft limit
if (account instanceof CheckingAccount) {
System.out.println("Overdraft Limit: " + ((CheckingAccount) account).getOverdraftLimit());
}
}
}

In this example, the BankingSystem class processes different types of accounts without knowing their specific types. The methods work with the base class Account, and polymorphism allows us to substitute objects of the derived classes (SavingsAccount and CheckingAccount). This adherence to the Liskov Substitution Principle promotes flexibility and maintainability in the system, allowing for easy extension with new types of accounts.

Avoided Code Smells ;

  • Long Parameter List — LSP encourages creating smaller, more specific classes that require fewer parameters

4. Interface Segregation Principle

Interface Segregation Principle asserts that a class should not be forced to implement interfaces it does not use. In other words, it promotes the idea of having small, specific interfaces rather than large, monolithic ones.

Let’s consider a real-world scenario in the context of a user authentication system for a web application. Initially, there might be a comprehensive UserAuthentication interface that includes methods for various authentication methods;

class UserAuthentication:
def authenticate_with_username_password(self, username, password):
# implementation of username-password authentication

def authenticate_with_oauth(self, oauth_token):
# implementation of OAuth authentication

def authenticate_with_biometric(self, biometric_data):
# implementation of biometric authentication

In practice, not all user accounts may support all authentication methods. For example, some accounts might only support username-password authentication, while others support OAuth or biometric authentication.

class BasicAuthentication(UserAuthentication):
def authenticate_with_username_password(self, username, password):
# Code for username-password authentication

# This forces BasicAuthentication to implement methods for OAuth and biometric authentication, even though it doesn't use them.

class UsernamePasswordAuthentication:
def authenticate_with_username_password(self, username, password):
# Code for username-password authentication

class OAuthAuthentication:
def authenticate_with_oauth(self, oauth_token):
# Code for OAuth authentication

# BiometricAuthentication is omitted for simplicity, but it would follow the same pattern.

class AdvancedUserAuthentication(UsernamePasswordAuthentication, OAuthAuthentication):
pass

# This way, a class can implement only the authentication methods it needs.

In this improved design, we’ve segregated the original UserAuthentication interface into smaller, more specific interfaces (UsernamePasswordAuthentication, OAuthAuthentication, and potentially others). Classes can now implement only the interfaces relevant to the authentication methods they support.

Avoided Code Smells ;

  • Forced Implementation — Some classes are forced to implement methods that are not relevant to their behavior or role. Use ISP to create interfaces tailored to specific responsibilities. Classes can then implement only the interfaces that are relevant to their behavior.

5. Dependency Inversion Principle

It suggests that high-level modules (e.g., business logic) should not depend on low-level modules (e.g., specific implementations or details), but rather both should depend on abstractions (e.g., interfaces or abstract classes). Additionally, it states that abstractions should not depend on details; details should depend on abstractions. Let’s consider a real-world scenario in a software application where the Dependency Inversion Principle (DIP) is applied.

Suppose you are developing an e-commerce system that sends email notifications to customers. Initially, you might have a simple implementation where the OrderProcessor class directly depends on a concrete email service class;

// Low-level module (details)
class EmailService {
public void sendEmail(String to, String subject, String message) {
// Implementation to send email
System.out.println("Sending email to " + to + ": " + subject + " - " + message);
}
}

// High-level module
class OrderProcessor {
private EmailService emailService;

public OrderProcessor() {
this.emailService = new EmailService();
}

public void processOrder(String customerEmail, String orderDetails) {
// Order processing logic

// Sending email notification
emailService.sendEmail(customerEmail, "Order Confirmation", "Thank you for your order: " + orderDetails);
}
}

In this design, the OrderProcessor directly depends on the EmailService class, violating the Dependency Inversion Principle. To adhere to DIP, we can introduce an abstraction (interface) for the email service and make OrderProcessor depend on this abstraction

// Abstraction
interface NotificationService {
void sendNotification(String to, String subject, String message);
}

// Low-level module (details)
class EmailService implements NotificationService {
@Override
public void sendNotification(String to, String subject, String message) {
// Implementation to send email
System.out.println("Sending email to " + to + ": " + subject + " - " + message);
}
}

// High-level module
class OrderProcessor {
private NotificationService notificationService;

public OrderProcessor(NotificationService notificationService) {
this.notificationService = notificationService;
}

public void processOrder(String customerEmail, String orderDetails) {
// Order processing logic

// Sending email notification
notificationService.sendNotification(customerEmail, "Order Confirmation", "Thank you for your order: " + orderDetails);
}
}

Now, OrderProcessor depends on the NotificationService interface rather than the concrete EmailService class. This adheres to the Dependency Inversion Principle. You can easily extend the system by introducing new notification services (e.g., SMS, push notifications) without modifying the OrderProcessor class. As long as the new notification service implements the NotificationService interface, it can be seamlessly integrated into the system.

Avoided Code Smells ;

  • Rigidity to changeWhen a class is tightly coupled to other classes, it becomes difficult to change without affecting the rest of the system. By following the DIP, developers can create code that is more flexible and easier to adapt to changing requirements.

By embracing these principles, developers can build software architectures that stand the test of time, enabling easier maintenance, extension, and adaptation to changing requirements. Keep your code clean, follow the SOLID principles, and watch your software flourish in its strength, stability, and beauty.

In addition to the SOLID principles, there are several other code smells that developers should be mindful of to ensure the overall quality of their code. Let’s briefly discuss a few common code smells and how they can be addressed:

Magic Numbers

Using raw, hardcoded numbers in your code without explaining their significance.

Solution: Define constants with meaningful names to replace magic numbers. This enhances code readability and makes it easier to maintain.

Comments Overuse

excessive or unnecessary use of comments in the source code. While comments can be valuable for explaining complex algorithms or providing high-level overviews, overusing them can be counterproductive.

Example;

Example;
// Loop through the list
for (int i = 0; i < list.size(); i++) {
// Get the current item
Item currentItem = list.get(i);

// Process the item
processItem(currentItem);

// Log the item processing
logger.log("Item processed: " + currentItem);
}

In this case, comments are used excessively to describe each step of a simple loop. This not only clutters the code but also makes it harder to see the actual logic

While “To-Do” comments are not recommended as a long-term solution, developers often use them to mark tasks that need attention. A better practice is to integrate a ticket management system (e.g., GitHub issues, Jira) to track tasks and issues, making it easier to manage and prioritize work without cluttering the code with to-do comments.

Solution: Focus on writing self-explanatory code. Use comments sparingly, and when needed, ensure they provide valuable insights that aren’t immediately apparent from the code itself. A better practice is to integrate a ticket management system like GitHub issues or Jira to track tasks and issues, making it easier to manage and prioritize work without cluttering the code with to-do comments.

Poor Exception Handling

Catching generic exceptions or not handling exceptions appropriately.

Solution: Catch specific exceptions and handle them appropriately. This makes troubleshooting easier and improves the robustness of the code.

Misleading Names

Names that mislead or provide inaccurate information about the purpose of a variable, method, or class.

Example;

int average = calculateSum(a, b, c) / 3;

In this example, the variable named average is misleading because it is calculated as the sum divided by 3, not the average. A better name for the variable would be sum to accurately reflect its purpose.

Solution: Ensure that names accurately reflect the behavior and intent of the code entity. Avoid using names that might lead to misconceptions or misinterpretations.

As developers, we strive to create code that is not only functional but also maintainable, scalable, and adaptable to changing requirements. By adhering to the SOLID principles and avoiding common code smells, we can build software architectures that stand the test of time. Embrace these principles, keep your code clean, and watch your software flourish in its strength, stability, and beauty.

--

--