A Comprehensive Guide to Writing Clean, Maintainable, and Scalable Code
Writing clean, maintainable, and scalable code is essential for building robust software. The SOLID principles-Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion-provide a foundation for achieving this. These principles help developers to create flexible, modular,and easy-to-maintain code.
In this article, we will explore each SOLID principle explain its significance and demonstrate how to apply it in real world scenarios, helping you improve your software design and development practices.
Basic Terminology
Tight Coupling : It refers to situation where classes(or components) are highly dependent on each other.When one class changes, it often requires changes to other classes that dependent on it .This strong interdependency makes the system difficult to maintain,extend and test.
Loose Coupling : It refers to a situation where classes (or components) are minimally dependent on each other. Changes in one class typically do not require changes in other classes that depend on it. This reduces interdependencies, making the system more flexible, maintainable, and easier to test.
Single Responsibility Principle ( SRP ) :
“A class or function should have one and only one reason to change, meaning it should have only one responsibility”
why SRP is important?
If a function or class does too many things, changes in one responsibility can accidentally break the other. Keeping responsibilities separate makes the code easier to maintain.
Let’s take an example without SRP applied…
// Imagine you are building a simple system to manage books in library . public class Book { private String title; private String author; public Book(String title ,String author) { this.title=title; this.author=author; } // method to get book details public String getDeatils(){ retrun "Title:" + title + ", author:" + author; } // Method to save book details to a database public void saveToDatabase() { System.out.println("Saving book: " + title + " to the database."); } } // what's wrong in this code ? // the Book class is responsible for two things : //1) representing book data 2) saving the book to DB //if we want to change the how books are saved(moving saving to DB --> saving to File), //we have to modify the Book class..
Solution with SRP
// Class to manage book data
public class Book {
private String title;
private String author;
public Book(String title, String author) {
this.title = title;
this.author = author;
}
public String getDetails() {
return "Title: " + title + ", Author: " + author;
}
}
// Class to handle saving books to the database
public class BookDatabase {
public void save(Book book) {
System.out.println("Saving book: " + book.getDetails() + " to the database.");
}
}
// Main class to use the classes
public class Main {
public static void main(String[] args) {
Book book = new Book("Java Programming", "Paradigm");
BookDatabase bookDatabase = new BookDatabase();
bookDatabase.save(book);
}
}
//The Book class only manages book details (title and author).
//The BookDatabase class handles saving books to the database.
//If we change the way we save books (e.g. saving to a file or cloud storage), we only need to modify BookDatabase,
//and the Book class remains untouched.
Benefits of SRP:
Easy to Maintain
Reusability
Scalable
Open - Closed Principle ( OCP ):
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.”
What does this mean?
Open for extension: You should be able to add new functionality to a class without changing its existing code.
Closed for modification: You should not have to modify a class’s existing code to add new behavior. Instead, you should use inheritance, interfaces, or other patterns to add new features.
let’s take an example without OCP
// Suppose we have a payment system that supports different payment methods like Credit Card and PayPal.
class PaymentProcessor {
public void processPayment(String paymentType, double amount) {
if (paymentType.equals("CreditCard")) {
// Process Credit Card payment
System.out.println("Processing Credit Card payment of $" + amount);
} else if (paymentType.equals("PayPal")) {
// Process PayPal payment
System.out.println("Processing PayPal payment of $" + amount);
} else {
System.out.println("Unsupported payment method");
}
}
}
// problem with this code
//In this design, the PaymentProcessor class is tightly coupled to the specific payment types (CreditCard, PayPal).
// Every time we add a new payment method (e.g., Bank Transfer or Bitcoin),
//we will have to modify the PaymentProcessor class.
Solution with OCP :
// PaymentMethod interface
interface PaymentMethod {
void processPayment(double amount);
}
// CreditCard class implements PaymentMethod
class CreditCard implements PaymentMethod {
@Override
public void processPayment(double amount) {
System.out.println("Processing Credit Card payment of $" + amount);
}
}
// PayPal class implements PaymentMethod
class PayPal implements PaymentMethod {
@Override
public void processPayment(double amount) {
System.out.println("Processing PayPal payment of $" + amount);
}
}
// PaymentProcessor class is open for extension but closed for modification
class PaymentProcessor {
public void processPayment(PaymentMethod paymentMethod, double amount) {
paymentMethod.processPayment(amount);
}
}
Advantage:
Open for Extension: We can now easily add a new payment method by simply creating a new class that implements the
PaymentMethod
interface. For example, we can add a Bitcoin payment method without changing thePaymentProcessor
class.Closed for Modification: The
PaymentProcessor
class doesn't need any changes, regardless of how many new payment methods we add. It remains closed for modification but is open for extension.
Liskov Substitution Principle ( LSP ) :
“Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.”
What does this mean ?
This principle ensures that any class that is the child of a parent class should be usable in place of its parent without any unexpected behavior.
let’s take a scenario : A Vehicle Rental Service
We have a base class Vehicle
, and we want to represent different types of vehicles, such as Car
and Bicycle
. Here's the process:
Initial Design: We create a
Vehicle
base class with a methodstartEngine()
, assuming all vehicles have engines.Subclasses: We create
Car
andBicycle
classes that inherit fromVehicle
.here is the example which Violets LSP
// Base class public class Vehicle { public void startEngine() { System.out.println("Starting the engine..."); } public void drive() { System.out.println("Driving the vehicle..."); } } // Subclass: Car (inherits from Vehicle) public class Car extends Vehicle { @Override public void startEngine() { System.out.println("Car engine started."); } } // Subclass: Bicycle (inherits from Vehicle) public class Bicycle extends Vehicle { @Override public void startEngine() { throw new UnsupportedOperationException("Bicycles don't have an engine!"); } @Override public void drive() { System.out.println("Pedaling the bicycle..."); } } // Main class public class Main { public static void main(String[] args) { Vehicle car = new Car(); Vehicle bicycle = new Bicycle(); // Both should behave as a Vehicle car.startEngine(); // Works fine bicycle.startEngine(); // Throws an exception } } // what's wrong here //Not all vehicles have engines, so startEngine() should not be part of the general Vehicle class. //The design assumes all vehicles share the same characteristics, which isn’t true(Case of bicycle).
Fixing the Violation
To follow Liskov Substitution Principle, we need to:
Avoid putting
startEngine()
in theVehicle
base class.Introduce a new interface or abstract class for vehicles that have engines.
// Base class: Vehicle (generalized for all vehicles) public abstract class Vehicle { public abstract void drive(); // All vehicles can drive (general behavior) } // Interface: EnginePowered (specific for vehicles with engines) public interface EnginePowered { void startEngine(); } // Subclass: Car (a vehicle with an engine) public class Car extends Vehicle implements EnginePowered { @Override public void startEngine() { System.out.println("Car engine started."); } @Override public void drive() { System.out.println("Driving the car..."); } } // Subclass: Bicycle (a vehicle without an engine) public class Bicycle extends Vehicle { @Override public void drive() { System.out.println("Pedaling the bicycle..."); } } // Main class public class Main { public static void main(String[] args) { Vehicle car = new Car(); Vehicle bicycle = new Bicycle(); // Driving both vehicles works as expected car.drive(); // Outputs: Driving the car... bicycle.drive(); // Outputs: Pedaling the bicycle... // Start engine only for vehicles that support it if (car instanceof EnginePowered) { ((EnginePowered) car).startEngine(); // Outputs: Car engine started. } if (bicycle instanceof EnginePowered) { ((EnginePowered) bicycle).startEngine(); // No operation, as Bicycle doesn't have an engine } } }
Interface Segregation Principle ( ISP ) :
“Classes that implement interfaces, should not be forced to implement methods they do not use.”
What Does This Mean?
- Smaller Interfaces:
If a class only needs certain methods from an interface, we shouldn’t force it to implement unnecessary methods. A class should only implement the methods that are relevant to it.
- Smaller Interfaces:
Scenario : Waiter and Restaurant
Imagine we have a restaurant system where the Waiter is responsible for taking orders and serving food.
code for Violation of ISP
// interface that has methods unrelated to Waiter
interface RestaurantEmployee {
void takeOrder(); // task of waiter
void cookFood(); // NOT a waiter's task
void serveFood(); // task of waiter
}
// Waiter class implementing RestaurantEmployee interface
class Waiter implements RestaurantEmployee {
@Override
public void takeOrder() {
System.out.println("Taking order from customer");
}
@Override
public void cookFood() {
// Waiter doesn't cook food, this is a violation of ISP!
System.out.println("I am a waiter. I don't cook food.");
}
@Override
public void serveFood() {
System.out.println("Serving food to the customer");
}
}
Problem:
- The Waiter class is forced to implement the
cookFood
method, even though it doesn't cook food. This violates the Interface Segregation Principle because the waiter is being forced to implement methods it doesn't use.
Fixed Code :
// specific interfaces
interface OrderTaker {
void takeOrder();
}
interface FoodServer {
void serveFood();
}
interface Chef {
void cookFood();
}
// Waiter class implements only the relevant interfaces
class Waiter implements OrderTaker, FoodServer {
@Override
public void takeOrder() {
System.out.println("Taking order from customer");
}
@Override
public void serveFood() {
System.out.println("Serving food to the customer");
}
}
// Chef class implements Chef interface
class Chef implements Chef {
@Override
public void cookFood() {
System.out.println("Cooking food");
}
}
Dependency Inversion Principle ( DIP ) :
“High-level modules should not depend on low-level modules. Both should depend on abstractions. Additionally, abstractions should not depend on details. Details should depend on abstractions.”
In simpler terms:
High-level modules: The core functionality of your application.
Low-level modules: Specific implementations or details.
Abstractions: Interfaces or abstract classes that define the contract between components.
The goal of DIP is to reduce the dependency of high-level components on low-level components by introducing abstractions. This makes the system more flexible and easier to change.
Scenario:
We have a system where the Computer can connect to either a Wired or Bluetooth Keyboard.
Violated code :
// Low-level module: WiredKeyboard
class WiredKeyboard {
public void connect() {
System.out.println("Wired keyboard connected");
}
}
// Low-level module: BluetoothKeyboard
class BluetoothKeyboard {
public void connect() {
System.out.println("Bluetooth keyboard connected");
}
}
// High-level module: Computer
class Computer {
private WiredKeyboard wiredKeyboard;
// Direct dependency on concrete classes (Violation of DIP)
public Computer() {
wiredKeyboard = new WiredKeyboard(); // Directly creating specific device
}
public void connectDevice() {
wiredKeyboard.connect();
}
}
Problem:
The Computer class is tightly coupled to the WiredKeyboard.
If we want to switch to a BluetoothKeyboard, we must modify the Computer class. This violates the Dependency Inversion Principle.
Corrected Code :
// Abstraction: Keyboard
interface Keyboard {
void connect();
}
// Low-level module: WiredKeyboard implements Keyboard
class WiredKeyboard implements Keyboard {
@Override
public void connect() {
System.out.println("Wired keyboard connected");
}
}
// Low-level module: BluetoothKeyboard implements Keyboard
class BluetoothKeyboard implements Keyboard {
@Override
public void connect() {
System.out.println("Bluetooth keyboard connected");
}
}
// High-level module: Computer
class Computer {
private Keyboard keyboard;
// Constructor injection: Dependency is passed from outside
public Computer(Keyboard keyboard) {
this.keyboard = keyboard;
}
public void connectDevice() {
keyboard.connect();
}
}
public class Main {
public static void main(String[] args) {
// Example 1: Using Bluetooth keyboard with Computer
Keyboard bluetoothKeyboard = new BluetoothKeyboard();
Computer computer = new Computer(bluetoothKeyboard);
computer.connectDevice(); // Outputs: Bluetooth keyboard connected
}
}
This implementation follows the Dependency Inversion Principle (DIP), ensuring loose coupling and making the system more flexible and maintainable.
Remember, the journey doesn't end here**.** Web development is a dynamic field, and as you continue to master the art of applying the SOLID principles, you'll find endless opportunities to improve your codebase and create more efficient, scalable software.
Thank you for exploring the SOLID principles with us**.** As you integrate these practices into your projects, may your architecture be clean, your code be maintainable, and your applications be robust. Keep coding, keep refining, and keep building towards exceptional software. Happy coding! 👨💻