SOLID Design Principles (+ Example in JAVA)

SOLID Design Principles (+ Example in JAVA)

SOLID Design Principle. The things that we usually asked or ask in an interview. It is an acronym for words:

S: Single Responsibility Principle

O: Open/Closed Principle

L: Liskov Substitution Principle

I: Interface Segregation Principle

D: Dependency Inversion Principle

Each character defines a specific rule on how we can achieve a good code. Especially on Object Oriented Programming (OOP).

Today, I found my old notes from a couple of years ago about SOLID, and I think it is still good and relevant now. Here they are:

[S] Single Responsibility Principle

A class should have just one reason to change.

What [S]

When building classes, it's important to consider the context of the class itself. Don't add methods that don't align with the function and responsibility of the class.

Why [S]

Reducing the complexity of a program is crucial. The more changes made to a program, the harder it becomes to maintain. One way to simplify the process is by creating well-organized classes with functions that align with the class name.

How [S]

class User {
    String name;
    String email;

    User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    String getName() {
        return this.name;
    }

    String getEmail() {
        return this.email;
    }

    void makePurchase() {
        .....
    }
}

makePurchase method not relevant to User class. By referring to the Single Responsibility Principle, we can transform it like this.

class User {
    String name;
    String email;

    User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    String getName() {
        return this.name;
    }

    String getEmail() {
        return this.email;
    }
}

class Purchase {
    User user;
    Cart cart;
    Ship ship;

    Purchase(User user, Cart cart, Ship ship) {
        this.user = user;
        this.cart = cart;
        this.ship = ship;
    }
    void checkout() {
        ...
    }
}

class Purchase is added to do the purchasing stuff

[O] Open/Closed Principle

Classes should be open for extension but closed for modification

What [O]

When implementing updates to a program, it's important to create new classes that are related to the updates, rather than directly modifying existing code.

Why [O]

By creating new classes, we can avoid inadvertently breaking existing code that is already functioning as intended.

How [O]

class Item {
    int price;
    String name;
    String type;

    Item(int price, String name, String type) {
        this.price = price;
        this.name = name;
        this.type = type;
    }

    int getTruePrice() {
        if (this.type == “second”) {
            return price * 80%
        }
        else if (this.type == “luxury”) {
            return price * 130%
        }
    }
}

If we want to add a new type to the getTruePrice() method, we would typically need to modify the code within the Item class. However, by applying the Open/Closed principle, we can transform our approach to look like this:

interface IType {
    int getTruePrice(int price);
}

We create interface IType that used for the implementation of the method getTruePrice for every class Type that we want to add.

class Second implement IType {
    int getTruePrice(price) {
        return price * 80%;
    }
}

class Luxury implement IType {
    int getTruePrice(price) {
        return price * 130%;
    }
}

class Donation implement IType {
    int getTruePrice(price) {
        return (price + 10000)*105%;
    }
}

Now we modify the code for class Item

class Item {
    int price;
    String name;
    IType type;

    Item(int price, String name, IType type) {
        this.price = price;
        this.name = name;
        this.type = type;
    }

    int getTruePrice() {
        return this.type.getTruePrice(this.price)
    }
}

We can create instances of class Item with the following way:

Donation donationItem = new Donation()
Item item = new Item(500000, 'Guitar', donationItem )

When we need a new item type, we just need to create a new type class that implements interface IType

[L] Liskov Substitution Principle

When extending a class, remember that you should be able to pass objects of the subclass in place of objects of the parent class without breaking the client code.

What [L]

An approach to creating sub-classes that is always compatible with the parent class" in English

Why [L]

Preventing broken code when a subclass is used in code but in fact not compatible with their parent class

How [L]

//Parent Class
class Robot{
    moving(Destination destination)
    firing(Object object)
}

//Subclass
class HumanRobot extend Robot{
    moving(Destination destination)
    firing(Object object) {
        throw new Exception (“Human Robot is friendly robot, i made to help human job”)
    }
}
class WingsRobot extend Robot{
    moving(Destination destination)
    flying(Destionation destination)
    firing(Object object)
}

When we create a new class by extending class HumanRobot

Class ShopKeeperRobot extend HumanRobot {
    @override
    moving(Destination destination)
    @override
    firing(Object object)
}

ShoperKeeperRobot skRobot = new ShopKeeperRobot()
skRobot.firing(someObject)

skRobot.firing seems like ok to call, but in fact, it makes the program closed. Because method firing() in their parent class HumanRobot throw an exception.

This is not in accordance with the Liskov Substitution principle. We need some change.

class Robot {
    moving()
}

class LandRobot extend Robot {
    moving()
    firing()
}

class WingsRobot extend LandRobot{
    moving(Destination destination)
    firing(Object object)
    flying(Destionation destination)
}

With limiting method on class Robot, it's subclass LandRobot can have more flexibility for adding more methods that it truly needs, without making the parent method obsolete or disabled.

[I] Interface Segregation Principle

Clients should not be forced to depend on methods they do not use

What [I]

Make sure the class implements all methods from the interface they implement. If there is any situation where some method is not needed, separate that method and create a new interface

Why [I]

If in the future we need to change, make it smaller or leaner for example delete some method from the interface, it may potentially become a broken code. Therefore, make sure that methods are truly being used by the implementing class to avoid having to overhaul the interface code.

How [I]

interface Phone {
    void accessCamera()
    void accessNFC()
    void accessBluetooth()
}
class SamsungPhone implement Phone{
    void accessCamera(){...}
    void accessNFC(){...}
    void accessBluetooth(){...}
}

class XiaomiPhone implement Phone{
    void accessCamera(){...}
    void accessNFC(){...}
    void accessBluetooth(){...}
}

class SmartfrenPhone implement Phone {
    void accessCamera(){...}
    void accessNFC(){...}
    void accessBluetooth(){...}
}

All class implementing interface phone. But on SmartfrenPhone because it only creates the old type of smartphone, it does not have an NFC feature. So method accessNFC() on class SmartfrenPhone cannot be used.

We can fix this by following Interface Segregation Principle:

interface Phone {
    void accessCamera()
    void accessBluetooth()
}

interface NewPhone {
    void accessNFC()
}

We create a new interface, and separate it by the feature that smartphones can have

class SamsungPhone implement Phone, NewPhone{
    void accessCamera() {...}
    void accessNFC() {...}
    void accessBluetooth(){...}
}

class XiaomiPhone implement Phone, NewPhone{
    void accessCamera() {...}
    void accessNFC() {...}
    void accessBluetooth(){...}
}

class SmartfrenPhone implement Phone {
    void accessCamera() {...}
    void accessBluetooth(){...}
}

[D] Dependency Inversion Principle

High-level classes shouldn’t depend on low-level classes. Both should depend on abstractions. Abstractions shouldn’t depend on details. Details should depend on abstractions.

What [D]

When we build a module that contains a high-level (business logic application) that uses the low-level module, it should not depend directly on that low-level module. Create an abstraction between them.

Why [D]

Make high-level modules easy to reuse without worrying about the side effects of changes in low-level modules.

How [D]

class Mysql {
    void insert(Object object, String table) {...}
    void update(){...}
    void delete(){...}
}
class Cart {
    Mysql mysql;
    List<item> items;
    Cart() {
        this.mysql = new Mysql()
    }

    void save() {
        this.mysql.insert(items, 'cart')
    }
}

class Cart use Mysl to insert items into the database. But when in the future the team wants to change the database to MongoDB, the code will be broken. By following Dependency Inversion Principle, we can change this:

interface Database {
    void insert(Object object, String table)
    void update(Object object, String table)
    void delete(Object object, String table)
}
class MySql implement Database {
    void insert(Object object, String table) {...}
    void update(Object object, String table) {...}
    void delete(Object object, String table) {...}
}

class Mongo implement Database {
    void insert(Object object, String table) {...}
    void update(Object object, String table) {...}
    void delete(Object object, String table) {...}
}
class Cart {
    Database database;
    List<item> items;
    Cart() {
        this.database = new Mongo()
    }
    void save() {
        this.database.insert(items, ‘cart’)
    }
}

Use interface Database as an abstraction for the database module.

That's all. Thank you for reading!