A Deep Dive Into Clean Code Principles

In this article:
Subscribe to our blog:

Consistent coding practices become crucial as software projects grow in complexity, involving multiple contributors and moving parts. Clean code principles are best practices that aim to enhance software systems' quality, maintainability, and scalability. By employing these guidelines, developers create functional code that is also readable, extensible, and easy to collaborate on.

This article will explore the details of clean code principles, including SOLID, DRY, and KISS, as well as their practical applications, real-world examples, and implementation. 

SOLID Principles

Robert C. Martin (also known as Uncle Bob) introduced the SOLID principles, which have become a cornerstone of object-oriented design and clean code practices. These five principles, Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion, provide a comprehensive framework for creating modular, maintainable, and extensible software systems. 

Understanding and applying these clean code principles can significantly improve the quality and scalability of your codebase.

1. Single Responsibility Principle

In a large application, it's common for classes or modules to accumulate multiple responsibilities over time, leading to bloated and hard-to-maintain code. The Single Responsibility Principle (SRP) dictates that a class or module should have one, and only one, well-defined responsibility.

Imagine a user onboarding application with a UserManager class responsible for the following:

  • User authentication (verifying login credentials)

  • User data retrieval (fetching user information)

  • Sending welcome emails to new users
c-sharp
// Violation of the Single Responsibility Principle
public class UserManager
{
    private readonly IUserRepository _userRepository;
    private readonly IEmailService _emailService;

    public UserManager(IUserRepository userRepository, IEmailService emailService)
    {
        _userRepository = userRepository;
        _emailService = emailService;
    }

    public bool AuthenticateUser(string username, string password)
    {
        // Authentication logic
    }

    public User GetUserById(int userId)    
    {
        // User data retrieval logic
    }

    public void SendWelcomeEmail(User user)
    {
        // Email sending logic
    }
}

While this approach might seem convenient at first, it violates the SRP. The UserManager class handles too many functionalities. If the user authentication logic changes, the software engineer must modify the UserManager class, which might affect the data retrieval and email-sending functionality.

By adhering to SRP, we can decompose the UserManager class into more focused components:

  • AuthenticationService: Handles user login and credential verification

  • UserRepository: Provides methods to interact with user data (retrieval, storage)

  • EmailService: Takes care of sending emails, including welcome messages
// AuthenticationService responsible for user authentication
public class AuthenticationService
{
    public bool AuthenticateUser(string username, string password)
    {
        // Authentication logic
        // Return true if authentication is successful, false otherwise
        return true;
    }
}

// UserRepository responsible for user data retrieval and storage
public class UserRepository
{
    public string GetUserInformation(string userId)
    {
        // Database query to retrieve user information based on userId
        return "User Information";
    }

    public void SaveUserInformation(string userId, string userData)
    {
        // Database operation to save user data
    }
}

// EmailService responsible for sending emails
public class EmailService
{
    public void SendWelcomeEmail(string email)
    {
        // Email sending logic to send a welcome email to the provided email address
        Console.WriteLine("Welcome email sent to: " + email);
    }
}

This separation creates a cleaner, more maintainable codebase. Each class has a clear purpose, making it easier to understand, modify, and test individual functionalities.

2. Open/Closed Principle

As software evolves, new requirements will often arise. If the existing codebase is not designed with extensibility, adding new features or modifying existing behavior can become increasingly difficult and error-prone

The Open/Closed Principle (OCP) addresses this need by advocating that software entities should be open for extension but closed for modification. This principle promotes using abstractions, interfaces, and inheritance to allow for extensibility without always modifying the existing code.

For example, if we have a Tax Calculator functionality in our application, instead of modifying the “TaxCalculator” class every time a new tax subsection is introduced, it would be better to create an abstract “TaxStrategy” interface and implement specific tax strategies as separate classes (e.g., “SalesTaxStrategy,” “VATTaxStrategy,” etc.). The “TaxCalculator” can be extended to support new tax strategies without modifying its core logic.

Java
// Abstract TaxStrategy interface
interface TaxStrategy {
    double calculateTax(double amount);
}

// Concrete implementation for SalesTaxStrategy
class SalesTaxStrategy implements TaxStrategy {
    @Override
    public double calculateTax(double amount) {
        // Calculate sales tax based on specific rules
        double taxRate = 0.10; // Example tax rate
        return amount * taxRate;
    }
}

// Concrete implementation for VATTaxStrategy
class VATTaxStrategy implements TaxStrategy {
    @Override
    public double calculateTax(double amount) {
        // Calculate VAT tax based on specific rules
        double vatRate = 0.20; // Example VAT rate
        return amount * vatRate;
    }
}

TaxCalculator class adhering to Open/Closed Principle.

class TaxCalculator {
    private TaxStrategy taxStrategy;

    public TaxCalculator(TaxStrategy taxStrategy) {
        this.taxStrategy = taxStrategy;
    }

    public double calculateTotalTax(double amount) {
        return taxStrategy.calculateTax(amount);
    }
}

// Usage example
public class Main {
    public static void main(String[] args) {
        TaxStrategy salesTaxStrategy = new SalesTaxStrategy();
        TaxCalculator salesTaxCalculator = new
TaxCalculator(salesTaxStrategy);

        double amount = 1000.0;
        double totalTax = salesTaxCalculator.calculateTotalTax(amount);

        System.out.println("Total tax using Sales Tax Strategy: $" + totalTax);
    }
}

Following the Open/Closed Principle, you can create a more flexible and maintainable codebase to accommodate changes without breaking existing functionality.

3. Liskov Substitution Principle

The guideline asserts that child classes should be able to replace parent classes without affecting functionality. In other words, the child class must be substitutable for the parent class. This ensures that derived classes can be used interchangeably with their base classes without breaking the application's performance.

Imagine a scenario where you have a Rectangle class with methods like set_width and set_height. Now, you introduce a derived class Square from Rectangle. The Square class should maintain the same behavior as the Rectangle class while adding specific square-related functionality.

Python

class Rectangle:
    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

class Square(Rectangle):
    def set_side(self, side):
        # Set both width and height to maintain square invariant
        self.set_width(side)
        self.set_height(side)

In this example, the Square class inherits from Rectangle but ensures that setting the side (equivalent to setting width and height) maintains the square's invariant of having equal sides. This adheres to the Liskov Substitution Principle by preserving the expected behavior of the base class (the Rectangle) while extending functionality in the derived class (the Square).

4. Interface Segregation Principle

In large software systems, it's common for interfaces to grow over time, accumulating more methods. This can lead to clients depending on interfaces with many methods, causing tight coupling and making the code harder to maintain and extend. 

The Interface Segregation Principle (ISP) addresses this issue by promoting the separation of interfaces into smaller units. It asserts that clients should not be compelled to rely on interfaces they do not utilize. The guideline encourages the separation of interfaces into smaller, more cohesive ones, reducing coupling and improving flexibility.

Consider a vehicle management application with a single VehicleService interface that contains methods for cars, trucks, and motorcycles. A client that only needs to work with cars would still need to depend on the entire VehicleService interface, including methods related to trucks and motorcycles that it doesn't use.

Violation of the Interface Segregation Principle:

csharp

public interface IVehicleService
{
    void StartCar();

    void StopCar();
    void StartTruck();
    void StopTruck();
    void StartMotorcycle();
    void StopMotorcycle();
}

public class CarService : IVehicleService
{
    public void StartCar()
    {
        // Start car logic
    }

    public void StopCar()
    {
        // Stop car logic
    }

    // Unused methods for trucks and motorcycles
    public void StartTruck() { /* ... */ }
    public void StopTruck() { /* ... */ }
    public void StartMotorcycle() { /* ... */ }
    public void StopMotorcycle() { /* ... */ }
}

By adhering to the Interface Segregation Principle, we can separate the VehicleService interface into smaller, more cohesive interfaces:

csharp

// Adhering to the Interface Segregation Principle
public interface ICarService
{
    void StartCar();
    void StopCar();
}

public interface ITruckService
{
    void StartTruck();
    void StopTruck();
}

public interface IMotorcycleService
{
    void StartMotorcycle();
    void StopMotorcycle();
}

public class CarService : ICarService
{
    public void StartCar()
    {
        // Start car logic
    }

    public void StopCar()
    {
        // Stop car logic
    }
}

This separation creates more focused and cohesive interfaces, reducing coupling and improving the overall flexibility and maintainability of the codebase. Clients that only need to work with cars can depend solely on the ICarService interface without being forced to depend on methods related to trucks or motorcycles.

5. Dependency Inversion Principle

As more features are added to a large-scale software system, high-level modules (responsible for core functionalities) often start relying directly on specific low-level modules (handling utility tasks). This creates a scenario where changes in one low-level module can ripple through the entire system. Maintaining and extending such a codebase becomes a nightmare.

Imagine you're building an e-commerce platform. A core functionality is the OrderProcessor class, which is responsible for processing and finalizing customer orders. Initially, having the OrderProcessor directly call a PaymentGateway class (say, PayPal) to handle payments might seem convenient.

Java
// Tight Coupling (using a specific implementation)
public class OrderProcessor {
  public void processOrder(Order order) {
    // ... (order processing logic)
    PayPal paymentGateway = new PayPal();
    paymentGateway.makePayment(order.getAmount());
    // ... (order finalization logic)
  }
}

This approach works initially, but as features evolve, problems arise:

  • You might want to offer additional payment gateways (e.g., Stripe). The OrderProcessor would need modification to handle each new gateway, increasing its complexity.

  • Unit testing the OrderProcessor becomes tricky as it relies on the external PayPal class.

The Dependency Inversion Principle (DIP) offers a solution through abstraction. You can create an IPaymentGateway interface that outlines the payment functionality (makePayment).

Java
public interface IPaymentGateway {
  public void makePayment(double amount);
}

Then, implement the IPaymentGateway interface for specific payment gateways (e.g., PayPalGateway, StripeGateway).

Java
public class PayPalGateway implements IPaymentGateway {
  @Override
  public void makePayment(double amount) {
    // ... (Interact with PayPal API)
  }
}

public class StripeGateway implements IPaymentGateway {
  @Override
  public void makePayment(double amount) {
    // ... (Interact with Stripe API)
  }
}

Modify the OrderProcessor to depend on the IPaymentGateway abstraction instead of a specific implementation.

Java
public class OrderProcessor {
  private IPaymentGateway paymentGateway;

  public OrderProcessor(IPaymentGateway paymentGateway) {
    this.paymentGateway = paymentGateway;
  }

  public void processOrder(Order order) {
    // ... (order processing logic)
   paymentGateway.makePayment(order.getAmount());
    // ... (order finalization logic)
  }
}

By adopting DIP, you create a more flexible and maintainable system. The core functionalities remain independent of the underlying payment mechanisms, making the code easier to adapt and extend as your e-commerce platform grows.

DRY (Don't Repeat Yourself)

Code duplication is a common issue that often arises from copy-pasting code snippets or implementing similar functionality in multiple places. While it may seem convenient in the short term, duplicated code can quickly become a maintenance nightmare, as changes need to be made in multiple locations, increasing the risk of bugs and inconsistencies. 

The Don't Repeat Yourself (DRY) clean code principle addresses this issue by stating that every piece of function or logic should have a single, unambiguous representation within the codebase.

Consider an application where the logic for calculating product price is duplicated across multiple classes or modules. 

def calculate_book_price(quantity, price):
  return quantity * price
def calculate_laptop_price(quantity, price):
  return quantity * price

In the above example, both functions calculate the total price using the same formula. This violates the DRY principle.

We can fix this by defining a single calculate_product_price function for books and laptops. This reduces code duplication and helps improve the codebase's maintenance.

def calculate_product_price(product_quantity, product_price):
 return product_quantity * product_price

KISS (Keep It Simple, Stupid)

All the programs and scripts you write are for humans. You write them only once but read them thousands of times. Therefore, it's your responsibility to make them as easy and quick to read as possible. Your code design's objective should be to offer the best user experience possible. 

Keep it simple, stupid (KISS) is a clean code principle that states that systems should be as simple as possible. Wherever possible, complexity should be avoided in a system, as simplicity guarantees the greatest levels of code usability. The KISS principle promotes simplicity and readability, making the code easier to understand and work with. 

Consider the following "Non-KISS" example:

Python

f = lambda x: 1 if x <= 1 else x * f(x - 1)

In the above approach using a lambda function, the factorial calculation is compressed into a single line, which can be harder to read and understand, especially for someone unfamiliar with lambda functions or functional programming concepts.

Now look at this version:

def factorial(n):
    if n <= 1:
        return 1
    else:
        return n * factorial(n - 1)

Adding a few descriptive names, type hints, and splitting the line into multiple lines makes the code easier to read. 

The main goal in writing code should always be to make it as easy to understand as possible. Following the KISS principle, you can make the codebase more accessible to current and future developers.

Naming Conventions

Naming is crucial to writing clean and maintainable code. Poor naming conventions can make it difficult for developers to understand the purpose and functionality of code elements. 

When writing code, ensure you choose names for variables, functions, and classes that reflect their purpose and behavior. This makes the code self-documenting and easier to understand without extensive comments. Here are some examples of popular naming conventions used in different programming languages:

  1. Camel Case:
  • Common in: Java, C#, JavaScript, Python (for private members)

  • Convention: Words are joined together with the first letter of each word capitalized (except the first word).

  • Example: userName, calculateArea, fileOutputStream
  1. Snake Case:
  • Common in: Python (for public members), PHP, Ruby

  • Convention: Words are joined together with underscores (_) separating them, all lowercase.

  • Example: user_name, calculate_area, file_output_stream

Code-Writing Standards

Code-writing standards are essential components of clean code principles. They promote code readability, maintainability, and consistency, making it easier for developers to understand and work with the codebase.

Most programming languages have community-accepted coding standards and style guides, such as PEP 8 for Python and Google JavaScript Style Guide for JavaScript. These standards may vary depending on the programming language, framework, or team preferences, but they typically include guidelines for:

  1. Code Formatting: Consistent use of indentation, spacing, and line breaks

  2. Comments and Documentation: Clear and concise comments and documentation to explain the purpose, functionality, and usage of the code

  3. Error Handling: Proper handling of exceptions and error cases

  4. Code Organization: Logical organization of code into modules, classes, and functions

  5. Testing: Writing unit and integration tests to ensure code correctness and maintainability

By following these code-writing standards in your codebase, you can create a codebase that is not only clean and maintainable but also easier for other developers to understand and contribute to.

Embrace Clean Code Principles with Codacy

Writing clean code is not just a set of rules or principles; it's a mindset and a commitment to delivering high-quality software that is easy to read, maintain, and extend. However, implementing and enforcing these principles can be daunting, especially in large and complex codebases with multiple developers. That's where Codacy comes in.

Codacy offers a comprehensive suite of features that automate keeping your code clean and secure. With its static code analysis capabilities, you can detect potential bugs and code smells early in the development process, allowing you to address them before they become more significant problems. Also, Codacy's duplication detection feature helps you identify and eliminate redundant code, making your codebase cleaner and more efficient.

Codacy seamlessly integrates with popular version control systems like GitHub, GitLab, and Bitbucket, ensuring it fits seamlessly into your existing development workflow. Try it out today.



RELATED
BLOG POSTS

What Is Clean Code? A Guide to Principles and Best Practices
Writing code is like giving a speech. If you use too many big words, you confuse your audience. Define every word, and you end up putting your audience...
11 Common Java Vulnerabilities and How to Avoid Them
Java is a cornerstone of modern software development, powering everything from enterprise-level applications to Android apps and large-scale data...
10 Common Programming Errors and How to Avoid Them
Famed American computer scientist Alan J. Perlis once joked, "There are two ways to write error-free programs; only the third one works."

Automate code
reviews on your commits and pull request

Group 13