DEV Community

Cover image for πŸ”§ Solid Principles Made Easy: Building a Strong Foundation for Your Code βš™οΈ
Priyanshu Belwal
Priyanshu Belwal

Posted on

πŸ”§ Solid Principles Made Easy: Building a Strong Foundation for Your Code βš™οΈ

Software development is like building a house. To construct a strong and reliable structure, architects follow specific principles. Similarly, in software design, we have SOLID principles to guide us in building robust and maintainable code. Let's explore these principles in simple terms:

1. S: Single Responsibility Principle

Imagine you have a toolbox, and each tool has a specific purpose. Just like that, a class should have one clear responsibility. It should do one thing and do it well. If a class has multiple responsibilities, it becomes hard to understand, maintain, and change without affecting other parts of the code.

How to identify:

Ask yourself, "What is the main responsibility of my class?" If you find yourself using the word "and" in the answer, you might be breaking the single responsibility principle.

Example:

Imagine a FileManager class responsible for both reading files and processing data from the file.

class FileManager {
    public String readFile(String filePath) {
        // Logic to read the file and return its content as a string
    }

    public void processData(String data) {
        // Logic to process the data read from the file
    }
}
Enter fullscreen mode Exit fullscreen mode

This violates the single responsibility principle. Instead, you can create two separate classes: FileReader and DataProcessor, each handling its respective responsibility.

class FileReader {
    public String readFile(String filePath) {
        // Logic to read the file and return its content as a string
    }
}

class DataProcessor {
    public void processData(String data) {
        // Logic to process the data read from the file
    }
}
Enter fullscreen mode Exit fullscreen mode
What to avoid:

Avoid creating classes that try to do too much. Also there is no need to have multiple classes that all hold just one function. Aim for a good balance by keeping each class focused on a single task.

2. O: Open/Closed Principle

Think of software as a LEGO set. When you want to expand your creation, you don't modify existing bricks; you add new ones. Similarly, in your code, you should be able to extend functionality without changing existing code.

Assume you have a class, which stores the binary data into database like below:

class BinaryDataDbSaver {
    public void save(Blob binaryData) {
        // ... DB Persistance Logic Here.
    }
}
Enter fullscreen mode Exit fullscreen mode

Now in future, you want to save this binary data into an object storage (like: AWS S3 or Microsoft Azure Blob) One possible solution can be like this:

class BinaryDataDbSaver {
    public void saveToDB(Blob binaryData) {
        // ... DB Persistance Logic Here.
    }
    public void saveToBlob(Blob binaryData) {
        // ... DB Persistance Logic Here.
    }
}
Enter fullscreen mode Exit fullscreen mode

In above solution, to add a new functionality, we modified an existing class, which was already well tested and serving the traffic.

To Handle such scenarios efficiently, we can extract an interface from it and can provide a solution like below:

public interface BinaryDataSaver {
    void save(Blob binaryData);
}

class BinaryDataDbSaver implements BinaryDataSaver {
    public void save(Blob binaryData) {
        // ... DB Persistance Logic Here.
    }
}

class BinaryDataBlobSaver implements BinaryDataSaver {
    public void save(Blob binaryData) {
        // ... Blob Persistance Logic Here.
    }
}
Enter fullscreen mode Exit fullscreen mode

By adapting this solution, we did not changed any existing implementation and were able to meet our purpose.

Important Pointers:
  • The initial idea of Open/Closed principal was related to the inheritance.
  • But inheritance introduces tight coupling if the subclasses depend on implementation details of their parent class.
  • Thats why the Open/Closed Principal was re-defined to the Polymorphic Open/Closed Principle.
  • It uses interfaces instead of superclasses to allow different implementations which you can easily substitute without changing the code that uses them.
  • The interfaces are closed for modifications, and you can provide new implementations to extend the functionality of your software.

3. L: Liskov Substitution Principle

In a well-designed software system, you should be able to replace an object of a parent class with an object of its subclass without causing issues. Subclasses should enhance the behavior of the parent class, not restrict or alter it.

In simple terms:

If you have a Fruit class and an Apple class that inherits from Fruit, you should be able to use an Apple object wherever you expect a Fruit object.

Why is it useful:

By adhering to this principle, your code becomes more flexible and allows for code reuse.

Example:

Consider a Bird base class and a Penguin subclass. Since penguins cannot fly, calling the fly() method on a Penguin object should not lead to errors or unexpected behavior.

class Bird {
    public void fly() {
        // Fly behavior for birds
    }
}

class Penguin extends Bird {
    // Penguins cannot fly, so this method should not be here
}
Enter fullscreen mode Exit fullscreen mode

Complying with Liskov Substitution Principle, one possible solution can be as:

abstract class Bird {
    public abstract void fly();
}

class Penguin extends Bird {
    public void fly() {
        // Penguins cannot fly, so this method is not implemented
    }
}
Enter fullscreen mode Exit fullscreen mode

4. I: Interface Segregation Principle

Imagine ordering a meal at a restaurant and getting a plate with everything on it, even the dishes you don't like. The Interface Segregation Principle advises against forcing clients to implement methods they don't need.

In simple terms:

Create smaller, specific interfaces, rather than one large interface with many methods.

Why it matters:

This prevents clients from being burdened with unnecessary methods and makes code more maintainable and adaptable.

Example:

You have a large interface, Vehicle, containing methods like startEngine(), accelerate(), and playRadio(). If a class implementing Vehicle has no use for playRadio(), it's forced to implement it, violating the Interface Segregation Principle.

interface Vehicle {
    void startEngine();
    void accelerate();
    void playRadio();
}

class Car implements Vehicle {
    public void startEngine() {
        // Car specific engine starting logic
    }

    public void accelerate() {
        // Car specific acceleration logic
    }

    public void playRadio() {
        // Radio playing logic - not needed for all vehicles
    }
}
Enter fullscreen mode Exit fullscreen mode

To resolve this, you can split the interface into smaller, specific interfaces as below:

interface Vehicle {
    void startEngine();
    void accelerate();
}

interface RadioPlayable {
    void playRadio();
}

class Car implements Vehicle, RadioPlayable {
    public void startEngine() {
        // Car specific engine starting logic
    }

    public void accelerate() {
        // Car specific acceleration logic
    }

    public void playRadio() {
        // Radio playing logic - implemented only for vehicles that can play radio
    }
}
Enter fullscreen mode Exit fullscreen mode
Important note:

Violating the Interface Segregation Principle can lead to issues with Liskov Substitution Principle if clients are forced to implement methods they don't properly support.

5. D: Dependency Inversion Principle

Think of software modules as pieces in a jigsaw puzzle. Instead of directly connecting each piece, you use connectors that allow different pieces to fit together. Similarly, the Dependency Inversion Principle encourages depending on abstractions (interfaces) rather than concrete implementations.

In simple terms:

Your classes should rely on interfaces, not on specific classes.

Why it matters:

By depending on abstractions, your code becomes more flexible, maintainable, and easier to test. You can easily swap implementations without changing the dependent classes.

Lets consider below code, which represents a MacBook of very old generation and imagine it uses Wired Keyboard and Mouse.

class MacBook {
    private WiredKeyboard keyboard;
    private WiredMouse mouse;

    public MacBook(WiredKeyboard keyboard, WiredMouse mouse) {
        this.keyboard = keyboard;
        this.mouse = mouse;
    }
}
Enter fullscreen mode Exit fullscreen mode

In above example, in future if you would have to replace WiredKeyboard to WirelessKeyboard, then we need to change the existing class, which can violate Open/Closed Principal and lead to buggy code.

Instead, MacBook class should rely on interfaces, rather then implementor classes. Above problem can be solved like below:

interface Keyboard {
    //... Methods
}

interface Mouse {
    //.. Methods
}

class WiredKeyboard implements Keyboard {
    //... Implementations
}
class WirelessKeyboard implements Keyboard {
    //... Implementations
}

class WiredMouse implements Mouse {
    //... Implementations
}
class WirelessMouse implements Mouse {
    //... Implementations
}

class MacBook {
    private Keyboard keyboard;
    private Mouse mouse;

    public MacBook(Keyboard keyboard, Mouse mouse) {
        this.keyboard = keyboard;
        this.mouse = mouse;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now in future, if we need to change the components, just we need to inject other type of SubClasses and our work will be done very easily, without touching MacBook class implementation.

Conclusion

By following these SOLID principles, you can create software that is easier to understand, maintain, and extend, just like building a strong foundation for your projects.

Top comments (0)