seymour.co.za

The SOLID principles of Object Oriented Design

What is SOLID?

SOLID helps you to write code that is easy to maintain, extend and understand.

It is an acronym for the following 5 principles:

S = Single-responsibility principle
O = Open-closed principle
L = Liskov substitution principle
I = Interface segregation principle
D = Dependency inversion principle

Single-responsibility principle

  • A class/module should only be responsible for one thing.
  • A class/module should have only one reason to be changed.

Here’s an example that violates this principle:

public class Customer {
    public void add(Database db) {
        try {
            db.execute("INSERT INTO...");
        } catch (Exception e) {
            File.writeAllText("/var/log/error.log", e.toString());
        }
    }
}

The Customer class is responsible for both writing to the database and writing to the logfile.

  • If we want to change the way we log errors, Customer needs to change.
  • If we want to change the way we write to the DB, Customer needs to change.

This code should be refactored to:

class Customer {
    private FileLogger logger = new FileLogger();
    void add(Database db) {
        try {
            db.execute("INSERT INTO...");
        } catch (Exception e) {
            logger.log(e.toString());
        }
    }
}
class FileLogger {
    void log(String error) {
        File.writeAllText("/var/log/error.log", error);
    }
}

Some other examples where you’d need separate classes are: user input validation, authentication, caching.

Be careful not to over-fragment code (creating too many responsibilities). Remember the whole point of SOLID is to make your code easier to maintain.

Further reading:

Open-closed principle

  • Classes/modules should be open for extension but closed for modification.
  • Rather extend functionality by adding new code instead of changing existing code.
  • The goal is to get to a point where you can never break the core of your system.

Here’s an example that violates this principle:

public void pay(Request request) {
    Payment payment = new Payment();
    if (request.getType().eq("credit")) {
        payment.payWithCreditCard();
    } else {
        payment.payWithPaypal();
    }
}
public class Payment {
    public void payWithCreditCard() {
        // logic for paying with credit card
    }
    public void payWithPaypal() {
        // logic for paying with paypal
    }
}

What if we wanted to add a new payment method? We would have to modify the Payment class, which violates the open-closed principle.

This code should be refactored to:

public void pay(Request request) {
    PaymentFactory paymentFactory = new PaymentFactory();
    payment = paymentFactory.initialisePayment(request.getType());
    payment.pay();
}
//-----------------------
public class PaymentFactory {
    public Payment intialisePayment(String type) {
        if (type.eq("credit")) {
            return new CreditCardPayment();
        } elseif (type.eq("paypal")) {
            return new PaypalPayment();
        } elseif (type.eq("wire")) {
            return new WirePayment();
        }
        throw new Exception("Unsupported payment method");
    }
}
//-----------------------
interface Payment {
    public void pay();
}
class CreditCardPayment implements Payment {
    public void pay() {
        // logic for paying with credit card
    }
}
class PaypalPayment implements Payment {
    public void pay() {
        // logic for paying with paypal
    }
}
class WirePayment implements Payment {
    public void pay() {
        // logic for paying with wire
    }
}

Now we can add new payment methods by adding new classes, instead of modifying existing classes.

Further reading:

Liskov substitution principle

  • If a class implements an interface, it must be able to substitute any reference that implements that same interface.
  • e.g. if a class called MySQL implements Database, and another class called MongoDB implements Database, you should be able to substitute MySQL objects for MongoDB objects.

Here’s an example that violates this principle:

public abstract class Bird {
    public abstract void Fly();
}
public class Parrot : Bird {
    public override void Fly() {
        // logic for flying
    }
}
public class Ostrich : Bird {
    public override void Fly() {
         // Can't implement as ostriches can't fly
        throw new NotImplementedException();
    }
}

The above is a bad design as Bird assumes all birds can fly.

This code could then be refactored to:

public abstract class Bird {
}
public abstract class FlyingBird : Bird {
    public abstract void Fly();
}
public class Parrot : FlyingBird {
    public override void Fly() {
        // logic for flying
    }
}
public class Ostrich : Bird {
}

The gist of this principle is to be careful when using polymorphism and inheritance.

Here is another, more real-world encounter of this principle:

  • You have a class called BankAccount with a withdrawal() method. Do all bank accounts allow withdrawals? A fixed deposit account won’t allow withdrawals, for example.

Further reading:

Interface segregation principle

  • No client should be forced to depend on methods it does not use.

Here’s an example that violates this principle:

public interface Athlete {
    void compete();
    void swim();
    void highJump();
    void longJump();
}
public class JohnDoe implements Athlete {
    @Override
    public void compete() {
        System.out.println("John Doe started competing");
    }
    @Override
    public void swim() {
        System.out.println("John Doe started swimming");
    }
    @Override
    public void highJump() {
    }
    @Override
    public void longJump() {
    }
}

JohnDoe is just a swimmer, but is forced to implement methods like highJump and longJump that he’ll never use.

This code could then be refactored to:

public interface Athlete {
    void compete();
}
public interface SwimmingAthlete extends Athlete {
    void swim();
}
public interface JumpingAthlete extends Athlete {
    void highJump();
    void longJump();
}
public class JohnDoe implements SwimmingAthlete {
    @Override
    public void compete() {
        System.out.println("John Doe started competing");
    }
    @Override
    public void swim() {
        System.out.println("John Doe started swimming");
    }
}

Now, JohnDoe does not have to implement actions that he is not capable of performing.

Further reading:

Dependency inversion principle

  • High-level modules should not depend on low-level modules. They should depend on abstractions.
  • This allows you to change an implementation easily without altering the high level code.

Here’s an example that violates this principle:

public class BackEndDeveloper {
    public void writeJava() {
    }
}
public class FrontEndDeveloper {
    public void writeJavascript() {
    }
}
public class Project {
    private BackEndDeveloper backEndDeveloper = new BackEndDeveloper();
    private FrontEndDeveloper frontEndDeveloper = new FrontEndDeveloper();
    public void implement() {
        backEndDeveloper.writeJava();
        frontEndDeveloper.writeJavascript();
    }
}

The Project class is a high-level module, and it depends on low-level modules such as BackEndDeveloper and FrontEndDeveloper. This violates the principle.

This code should be refactored to:

public interface Developer {
    void develop();
}
public class BackEndDeveloper implements Developer {
    @Override
    public void develop() {
        writeJava();
    }
    private void writeJava() {
    }
}
public class FrontEndDeveloper implements Developer {
    @Override
    public void develop() {
        writeJavascript();
    }
    public void writeJavascript() {
    }
}
//-----------------------
public class Project {
    private List<Developer> developers;
    public Project(List<Developer> developers) {
        this.developers = developers;
    }
    public void implement() {
        developers.forEach(d -> d.develop());
    }
}

Now, the Project class does not depend on lower level modules, but rather abstractions.

Further reading:

Final note: Don’t be too strict with SOLID principles

  • SOLID design principles are principles, not rules.
  • Always use common sense when applying SOLID (know your trade-offs).
  • Usually with SOLID, it requires more time writing code, so you can spend less time reading it later.
  • Finally, remember to use SOLID as a tool, not as a goal.