As part of one of my learning days at work, I spent some time studying the SOLID principles, going through a UDEMY course and putting together some notes that I can refer back to in the future.

I’ve written up my notes from the course here, along with some sample code I put together from one of my oldest repositories to help demonstrate the concepts in practice!

Hopefully this resource can be useful for other developers looking to learn about SOLID principles or refresh their understanding! 😊

Single Responsibility Principle

  • S stands for ‘Single Responsibility Principle’, often abbreviated and referred to as SRP.

“Every software component should have one and only one responsibility”

Component can be a class, a method or a module

Here is a sample class

public class Square {
    int side = 5;

    public int calculateArea() {
        return side * side;
    }

    public int calculatePerimeter() {
        return side * 4;
    }

    public void draw() {
        if (highResolutionMonitor) {
            // Render a high resolution image of a square
        } else {
            // Render a normal image of a square
        }
    }

    public void rotate(int degree) {
        // Rotate the image of the square clockwise to
        // the required degree and re-render

    }
}

This class has four methods, and they do what they’re titled to do.

Cohesion, in the software world is defined as:

“The degree to which the various parts of a software component are related”

If we apply this to the sample code, we have this more clean, and cohesive class

public class Square {

    int side = 5;

    public int calculateArea() {
        return side * side;
    }

    public int calculatePerimeter() {
        return side * 4;
    }
}
public class SquareUI{

    public void draw(){
        if (highResolutionMonitor) {
            // Render a high resolution image of a square
        } else {
            // Render a normal image of a square
        }
    }

    public void rotate(int degree) {
        // Rotate the image of the square clockwise to
        // the required degree and re-render
    }
}

By doing this, splitting this into two classes, the cohesion is better in each class.

“Higher Cohesion helps attain better adherence to the Single Responsibility Principle”

So now with this in place we can say that the Square classes responsibility as a whole is to deal with the measurements related to a square, and then for the SquareUI, the responsibilities is to deal with the rendering of the square.

Coupling is defined as

“The level of inter dependency between various software components”

The example on my lesson was between two rails at a train station. One rail is 1.4 meters wide, and the other one is 1.6 meters wide.

The difference in width makes it two entirely different railway systems. In other words, the train is tightly coupled to its track. Tight coupling may be a necessity in railways, but in software, tight coupling is something undesirable.

Here is a class called student:

public class Student {

    private String studentId;
    private Date studentDOB;
    private String address;

    public void save() {
        // Serialize object into a string representation
        String objectStr = MyUtils.serializeIntoAString(this);
        Connection connection = null;
        Statement stmt = null;
        try {
        Class.forName("com.mysql.jdbc.Driver");
        connection =
        DriverManager.getConnection("jdbc:mysql ://Localhost: 3306/
        MyDB", "root", "password");
        stmt = connection.createStatement();
        stmt.execute("INSERT INTO STUDENT VALUES (" + objectStr +
        ")");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public String getStudentId() {
        return studentId;
    }

    public void setStudentId(String studentId) {
        this.studentId = studentId;
    }

    ...
}

One of the methods inside the Student class is the save method. The save method converts the student class into a serialized form and sends it to a database. This method deals with a lot of low-level details related to handling record insertion into a database. In this example, if we assume we are using mySQL, sometime in the future, if you decide to go with a NoSQL database, like MongoDB, most of the code would need to change. So, the student class is tightly coupled with the database layer we use at the backend. The student class should ideally deal with ONLY basic student related functionalities, like getting the student ID, date of birth, etc, the student class should NOT be in control of dealing with the backend database.

Tight coupling is bad in software, so to fix the above class, we take the database related code, and move it to a new Repository class.

public class Student {

    private String studentId;
    private Date studentDOB;
    private String address;

    public void save(){
        new StudentRepository().save(this);
    }

    public String getStudentId() {
        return studentId;
    }

    public void setStudentId(String studentId) {
        this.studentId = studentId;
    }

    ...

}

public class StudentRepository{
    public void save() {
        // Serialize object into a string representation
        String objectStr = MyUtils.serializeIntoAString(this);
        Connection connection = null;
        Statement stmt = null;
        try {
        Class.forName("com.mysql.jdbc.Driver");
        connection =
        DriverManager.getConnection("jdbc:mysql ://Localhost: 3306/
        MyDB", "root", "password");
        stmt = connection.createStatement();
        stmt.execute("INSERT INTO STUDENT VALUES (" + objectStr +
        ")");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

“Loose Coupling helps attain better adherence to the Single Responsibility Principle”

Examples In Codebase

This code comes from one of my earliest codebases, written many years ago! It is not very clean, polished or refactored, but it is useful for this blog's example code.

BlockyRoad -> HoldMouse.cs

public class HoldMouse : MonoBehaviour
{
    [Header("References")]
    public UI_Ring ring_UI;
    public PlayerMovement[] players;
    public LevelManager levelManager;
    public SoundFX soundFX;
    public AudioClip[] gridSounds;
    
    [Header("Data Values")]
    [Space(20)]
    public int numberSpaces = 0;
    public float clickDelay = 0.25f;
    bool isCharging = false;
    public bool upright = true;
    public float fullChargeDelay = 0.5f;

    void Update()
    {
        ButtonPress();
        
        if(numberSpaces >= 5)
        {
            numberSpaces = 1;

        }
    }

    void ButtonPress()
    {
        if(Input.GetMouseButton(0))
        {
            clickDelay -= Time.deltaTime; 
            if(clickDelay <= 0)
            {
                OnHoldDown();
            }

        }
        else if(isCharging)
        {
            MoveSpaces();
        }
        else
        {
            numberSpaces = 0;  
        }

        if(Input.GetMouseButtonDown(0))
        {

        }
        if(Input.GetMouseButtonUp(0))
        {
            clickDelay = 0.25f;
            if(numberSpaces <= 0)
            {
                levelManager.FlipMap(upright);
                upright = !upright;
            }
        }
    }

    void MoveSpaces()
    {
        ring_UI.FadeOut_Fin();
        isCharging = false;
        clickDelay = 0.25f;
        foreach(PlayerMovement p in players)
        {
            p.PlayerMove(numberSpaces);
        }
    }

    void OnHoldDown()
    {
        ring_UI.ChargeRing();
        isCharging = true;
        numberSpaces+= 1;
        clickDelay = 0.25f;
        soundFX.PlaySound(gridSounds[numberSpaces - 1]);
    }

    void OnClick()
    {
        if(Input.GetMouseButtonUp(0))
        {
            clickDelay = 0.25f;
        }
    }

}

This code sample has low cohesion, as it is handling multiple unrelated responsibilities:

  1. Input handling (mouse clicks and holds)
  2. Game state management (numberSpaces counter, upright toggle)
  3. UI updates (calling ring_UI.ChargeRing() and ring_UI.FadeOut_Fin())
  4. Player movement triggering (calling p.PlayerMove())
  5. Level management (calling levelManager.FlipMap())
  6. Audio playback (calling soundFX.PlaySound())

This code is also tightly coupled to 4+ other classes:

Advertisement
  1. UI_ring
  2. PlayerMovement
  3. LevelManager
  4. SoundFX

Here is how this would be fixed:

AmyE123/SOLID-Principles-Study#3 AmyE123/SOLID-Principles-Study · #3
Loading pull request…

Open-Closed Principle

“Software components should be closed for modification, but open for extension.”

Closed for modification: Adding new features shouldn’t require changing existing code

Open for extension: The component should be designed so that new features or behaviours can be added.

Nintendo Wii Analogy

The wii comes with the console and a standard wii controller, but there is accessories like the Wii Zapper and the steering wheel which you can put the standard controller into. The important part is you don’t need to modify the Wii console, or controller to use these accessories, you just put the controller into the accessories and it works.

So, the wii behaves as closed for modification (you don’t need to open the console and change its hardware/software) and open for extension (you can add accessories, and new functionality can be added without changing the existing system)

When designing software we want the same idea.

Closed for modification: When a new feature is required, existing code should NOT need to be modified. This reduces the risk of breaking existing functionality, introducing bugs and having to change well-tested code.

Open for extension: The existing component should provide a way to add new features or behaviours without modifying the existing implementation.

The key idea is that we want to structure our code so that new requirements can be handled by adding new code rather than changing existing code.

Here is how we would update the Blocky Road codebase to use Open-Closed Principles:

  • FlipMap() function - Previously, every new feature regarding the map would require editing FlipMap(), now we have a notifylisteners function which means flipmap never changes and just notifies listeners. This is closed for modification.
  • Added new behaviour, previously, editing LevelManager.cs meant you were risking breaking existingg logic and also new ways to test things with additional listeners.
  • Closed for modification (LevelManager stays stable) , open for extension (new behaviours can be added with new listeners)
AmyE123/SOLID-Principles-Study#4 AmyE123/SOLID-Principles-Study · #4
Loading pull request…

Liskov Substitution Principle

“Objects should be replaceable with their subtypes without affecting the correctness of the program.”

Did you know that this principle is named after the computer scientist Barbara Liskov?

LSP is closely related to inheritance.

I made some changes to Blocky Road to showcase Liskov Substitution Principles.

  • I created IPlayerInteractable.cs that all collission types and implement
  • Refactored PlayerMovement.cs to use the interface so it is now closed for modifiction
  • and added implementations. With this implementation, new things, like collectibles (CollectiblePickup.cs) can be added without changing PlayerMovement.cs
AmyE123/SOLID-Principles-Study#5 AmyE123/SOLID-Principles-Study · #5
Loading pull request…

Interface Segregation Principle

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

The main idea is that interfaces should not force classes to implement functionality they don’t need.

Office Machines Analogy

Imagine an office with around 200 employees and several types of machines, like printers, scanners, fax machines, other devices and you want to represent these devices in code using interfaces.

The Problem With One Giant Interface

You create a single interface called IMultiFunction

interface IMultiFunction
{
    Print();
    GetPrintSpoolDetails();

    Scan();
    ScanPhoto();

    Fax();
    InternetFax();
}

This interface contains functionality for: printing, scanning and faxing. In our office, a multi-function device supports all of these functions, therefore the IMultiFunction interface is fine and has no problem here.

But the problem comes up with simpler devices. One printer might be able to print and scan, but it won’t be able to fax, and because it implements IMultiFunction, it must implement every method. So you end up with something like:

Print();
GetPrintSpoolDetails();

Scan();
ScanPhoto();

Fax();          // Doesn't actually work
InternetFax();  // Doesn't actually work

With the fax methods left blank or unimplemented.

Why is this bad?

Because unimplemented methods are an indication of poor design. If another coder was working in the codebase, they might see printer.Fax() and then assume the device has a fax function, and they call it, but the method is actually blank, or unimplemented, which can lead to incorrect behaviour or broken functionality.

This interface has given the programmer an incorrect expectation about what the device can do. The problem is that we are using one large interface containing many different responsibilities. Instead, interfaces should be split into smaller, more focused interfaces.

Rather than IMultiFunction with Print, Scan, Fax, etc - You can have IPrinter, with print methods, IScanner, with scan methods, and IFax with fax methods. Then devices only implement the interfaces for functionality they actually support, which means we prevent classes from having to implement irrelevant methods.

Code Example

AmyE123/SOLID-Principles-Study#6 AmyE123/SOLID-Principles-Study · #6
Loading pull request…

Dependency Injection

AmyE123/SOLID-Principles-Study#7 AmyE123/SOLID-Principles-Study · #7
Loading pull request…