How to use SOLID as a framework to approach design problems: Low level design Part #1

Malay : November 23,2020

If you're system is hard to understand, It almost cetainly due to bad design of the low level components.

You can create a nightmare of a system by not knowing how to logically layout your modules or by abusing design patterns. Without understanding why you need low level design and what problem they are trying to solve, you are guaranteed to misuse them. So in this installment of the low level design series we will discuss about what’s the purpose of design. We will discuss on how to use SOLID as framework to think about common design problems.


TL;DR included. But you need to read this.

TL;DR

1.    Use design pattern only when you are absolutely sure about the problem it will solve.
2.    Goal of design is to lay the abstractions of a system to reflect the problem space and hide implementation details under appropriate abstractions.
3.    SOLID are guide lines to create coherent systems.
4.    SRP: Function/class/module should do one thing and do it well
5.    OCP: Modules should be open for extension but closed for modification. ( A bit confusing, so read it)
6.    LSP: How to not misuse inheritance: Users of base class should not notice if you pass them an object of derived class.
7.    ISP: Users should only conform to the part of interface that they use.
8.    DIP: This is the most important one. It tells you how to decouple a system. It says depend of abstraction rather than concretion.

(Some resources/ papers/ and references included at the end if you want to dive deeper.)

 

Abuse of design patterns

If you have been programing for some time you have come across some terms like “Singleton”, “Factory”, “Builder”, “Abstract factory”, “Decorator” etc. And if you have dug enough you might have discovered that there are design patterns. So you have Googled design patterns, picked up how to implement these and have started using them in your project. Great, but “why these patterns do exists? What problem do they solve?” are few questions you didn’t ask.
You definitely have encountered a code base where there are so many factories and builders and worse, managers, that it creates a giant mess, and losses the purpose of those patterns doing so. “why design these patterns exist”, “what problem do they solve” and more importantly “When to use them” are some questions you need to understand so that you don’t jam in a design pattern in your code just because you know how to implement it.
 

Why is it a stupid idea to code an enterprise grade software in machine code?

Why do we have high level programming languages? The answer that you will give me might be along the lines that we need them to explain to a computer what to do. But, is that correct? No! It is not. You can explain what to be done to a computer using machine code as well and the computer will execute it faster. So why do we use HLLs at all?
High level languages (HLL) exists to facilitate thinking about certain ideas without needing to understand how it works in machine level at excruciating details. Effectively, HLLs are a way to hide details so that you can think about something at a higher level of abstraction.
 

The goal of a design of system is similar.

Goal of design is to hides details of how it works under the hood in a logical manner. So that when you are thinking about the system you can think about the abstract entities and their interactions, without needing to understand the entirety of how it works.
This implies you make assumptions about a system without understanding it fully? Absolutely, many of the times, eh, most of the times, you don’t understand how a certain thing works, so while working on the system you make assumptions about how a certain part of that system works. 


The purpose of low level design is to let you make assumptions about the system that is closest to, if not exactly, how it actually works. That means, you have to create the abstractions of a system in a way that allows you to think about it the most logical way, which makes most sense to a human. Low level system design is to make a system cognitively less expensive to think about and work with.
Now you should be asking: Alright how do I lay the abstractions, i.e. functions/classes/modules, in a manner that makes most sense?
The good news is that this is question has been asked and addressed. And few veteran programmers got together and packed their wisdom in a set of principles, which if you stick to will help you create a coherent system (most of the times). These principles are called SOLID principles. These principles introduces common circumstances where you need to make a design decision and tells you how to approach them. The beloved design patterns are mere instantiations of these principles.
Following is discussion on how to use SOLID principles to guide design decisions:

 

S.O.L.I.D

SOLID is an acronym of acronyms that expands as following:
a)    Single Responsibility Principle (SRP)
b)    Open Closed Principle (OCP)
c)    Liskov Substitution principle (LSP)
d)    Interface segregation principle (ISP)
e)    Dependency inversion principle (DIP)

 

Single Responsibility Principle

A class or a function should have one and only one reason to change.

You definitely have seen classes with 4000 lines of code in them. That’s a guaranteed violation of SRP. You may have written that class called “SomethingManager” with 4000 lines of code. And If I ask you what this class is doing you will say it is “managing something”.

For example, while managing that “something” you are also managing a database connection AND you have SQL queries scattered all over it AND you are cleaning up some input for the “something” your class is supposed to manage. Now, if how you need manage a DB connection changes OR the table structures of tables you are querying changes OR you have new type of data to clean up, you have to come to this class and edit it. See these are reasons, different reasons, for which you’d have to come to this class and edit it and break something else doing so.

When you are creating a function or a class you are creating an abstraction. Meaning when you will call that function you don’t want to think how that function is working once you have implemented it. 
Single Responsibility Principle helps you think about which details of to hide under one such abstraction. It says, don’t pack things in an abstraction that has different reasons to change.
What SRP says is split such class in smaller classes and composer objects to add these functionalities. Split large functions into smaller functions which does one thing and does it well. So that when a specific thing changes you know where to look, specifically.

 

Open Closed Principle

A module/ function / class should be open to extension but closed to modification.

This is one of the principles that I had hardest time wrapping my head around. It says you are allowed to modify the behavior of a system but you cannot touch the existing code to do that. The logical question now is: 

How the hell am I supposed to do that?

Well, it is doable. First you will see how you should NOT be doing it.
We will do the classic example. We want to design a function draw_all that is compliant to OCP.

 

enum ShapeType ( circle, square );

struct Shape{
    ShapeType type;
};

typedef Shape* ShapePtr;

struct Circle{
    ShapeType   type;
    float       radius;
    Point       center;
};

struct Squar{
    ShapeType   itsType;
    float       height;
    Point       leftTop;
};

// Implemented somewhere else
void draw_squar(struct Squar*);
void draw_circle(struct Circle*);


void draw_all(ShapePrt shapes[], int n)
{
    for(int i=0; i<n; i++)
    {
        Shape *s = shapes[i];
        switch(s->type)
        {
            case circle:
                draw_circle((struct Circle*) s);
                break;
            
            case square:
                draw_squar((struct Squar*) s);
                break;
        }
    }
}

 

Now you need to extend the draw_all function to be able to draw a triangle. How many places do you have to change the code? Well, a lot of places. You have to add a new element in enum, you have to implement a function to draw a triangle, and you have to modify draw_all function with a new switch case statement.

So you need to touch the draw_all function to extend it. That’s a violation of OCP.
The goal now is to design a draw_all function that we don’t have to alter to make it draw a triangle.

 

class IDrawable {
    public:
        virtual void draw() = 0;
};

void draw_shape(std::vector<IDrawable> &shapes)
{
    for(auto i = shapes.begin(); i != shapes.end(); i++)
    {
        i->draw();
    }
}


class Circle : public IDrawable
{
    private:
        float radius;
        Point center;

    public:
        void draw()
        {
            // Impliment how to draw a circle
        }
};

class Squar : public IDrawable
{
    private:
        float height;
        Point top_left;

    public:
        void draw()
        {
            // Impliment how to draw a squar
        }
};

class Triangle : public IDrawable
{
    private:
        float base, height;
        Point centoid;

    public:
        void draw()
        {
            // Impliment how to draw a traiangle
        }
};



int main()
{
    std::vector<IDrawable> shapes;

    Triangle t;
    Circle c;
    Squar s;

    shapes.push_back(t);
    shapes.push_back(c);
    shapes.push_back(s);

    draw_all(shapes);


}

In this example we have created an abstraction only on which our draw_all function depends on. IDrawable is an interface, which if any class implements will be accepted by our draw_all function.

Now if you are asked to extend the behavior the draw_all function to draw a trapezium for example, you don’t have to touch the code of draw_all function at all, just implemented IDrawable in a Trapezium class and you’re done.
 

Is it always doable? How do you know what is the appropriate abstraction to depend on?

The answers are: No and you don’t. You cannot implement open closed principle in all the cases. What if your requirement asks you to extend the draw_all function so that it draws circles before squares if any are present? In that case you have to modify the draw_all function to take care of the ordering.
The key take away from open closed principle is: If you find yourself checking for type of an object before passing it to a set of functions than you can create an abstraction for that set of functions to make the function reusable and extensible without touching the code of that set of functions.
 

 

Liskov Substitution Principle

The user of a class should be able to expect same behavior from all the classes that derives from it. In other words, you should be able to substitute objects of derived class in place of objects of the base class and the user of the base class should not notice a difference.

Alright. What does that mean? Let’s understand using an example.
We have a rectangle class and a square class. Since the square is just a rectangle with same width and height. We will establish an IS-A relationship.
 

class Rectangle
{
    protected:
        int width, height;

    public:
        virtual void set_width(int w) { this.width = w; }
        virtual void set_height(int h) { this.h = h; }

        int get_width() { return width; }
        int get_height() { return height; }

        int calc_area()
        {
            return w*h;
        }
};

class Square : public Rectangle
{
    void set_width(int w)
    {
        this.width = w;
        this.height = w;
    }
    void set_height(int h)
    {
        this.width = h;
        this.height = h;  
    }
}

We have inherited Square from Rectangle and we have over ridden the set_width and set_height method to set make the height and width same.

Now some other developer comes and implements the following function. He is accepting a reference to Rectangle

 

void do_something(Rectangle *rPtr)
{
    rPtr->set_height(5);
    rPrt->set_width(4);

    assert( rPtr->get_height() * rPtr->get_width() == 20);
}

int main()
{
    Rectangle *r = new Rectangle;
    Rectangle *s = new Square;

    //Works
    do_something(r);

    // Fails
    do_something(s);

}


If you pass a square to this function it will clearly break down. Did the programmer of do_something method made a mistake in his assumptions? No! He was expecting a Rectangle to behave like a rectangle.

 

What went wrong?

Squares hold the IS-A relationship with Rectangle, mathematically. But Square objects don’t behave like a Rectangle object. So to deal with it, you can check the type of incoming Rectangle object in do_something method. But that will violate the open closed principle, as discussed above.

So what’s to be done then?

LSP says, Use inheritance to establish a IS-A relationship if and only if the derived classes behaves the same way as the base class does. Otherwise it will violate LSP and will create inconsistency in your system.In software the IS-A relationship isn’t valid if objects of the derived classes have different behavior. So don’t use inheritance in these scenarios.

Rule of thumb to stick to LSP is to ask yourself, “Alright, I am about to inherit from a class. Will my derive class override some method of base class to modify the result that the method produces?” If yes, don’t inherit.

 

Interface Segregation Principle

Clients should not be forced to depend upon interfaces that they don’t use.

 

class IWorker 
{
    public:
        virtual void do_work() = 0;
        virtual void eat() = 0; 
};

class Worker : public IWorker
{
    void do_work() 
    { 
        // Do work
    }

    void eat()
    {
        // eat
    }
};

class SuperWorker : public IWorker
{
    void do_work() 
    { 
        // Do a lot of work
    }

    void eat()
    {
        // eat a lot
    }
};

class Robot : public IWorker
{
    void do_work() 
    { 
        // Do work
    }

    void eat()
    {
        // Don't do anything
    }
};

class Manager
{
    IWorker worker;

    void set_worker(IWorker w)
    {
        worker = w;
    }

    void make_worker_work()
    {
        worker.work();
    }
}

 

In this example we have a Manger class that manages workers who eats and works. Manager accepts only workers who implements IWorker interface. So if we need to add a Robot for the manager to manage, it also has to implement the eat() function even though it doesn’t eat.

ISP says, make the interface in such a way that implementations doesn’t have to implement unreverent methods just to satisfy the interface. Segregate the interfaces into smaller once.

So to make our example compliant with ISP, we need to split the interfaces as follows:

 

class IWorable
{
    virtual void work() = 0;
}
class IFeedable
{
    virtual void eat() = 0;
}

class Manager
{
    IWorkable worker;

    void set_worker(IWorkable w)
    {
        worker = w;
    }

    void make_worker_work()
    {
        worker.work();
    }
}

class Worker : public IWorkable, IFeedable
{
    void do_work() 
    { 
        // Do work
    }

    void eat()
    {
        // eat
    }
};

class Robot : public IWorkable
{
    void do_work() 
    { 
        // Do work
    }
};


Dependency Inversion Principle

This is the most important of them all. This alone will save you enormous amounts of work and help you design decoupled systems. Even if you didn’t understand any of the above make sure you get this.

It says, the user of a class should not depend on concrete classes, it should depend on an abstraction.

Alright, before we go any further, answer this:

 

Why do you think interfaces exist? In fact, think what does having an interface means?

An interface is how you interact with an object. An interface is a contract which says how any object implementing that interface should behave. Think about it like this, once you see the interface, as a user, you should understand how any object bearing that interface will behave.

Why do you care about this? What is the consequence of this idea on an interface?
The consequence of this is quite profound. Let’s try to understand this with an example.

Let’s say you are writing an operating system that supports few IO devices like a hard Drive, a floppy disk, and some weird device that stores data. Now you want to write a file object to a file. Now you need to write the driver software to facilitate reading and writing to these devices. Let’s look at an implementation of it.

 

enum IODevices (hard_disk, floppy_disk, weird_device );

class Device
{
    public:
        IODevices type;
        IODevices get_type() { return type; }
};

class HardDisk : public Device
{
    public:
        HardDisk() { type = hard_disk; }
        void write_to_hdd(FileObj &file) { /* Writes data to hdd */}
        FileObj read_from_hdd() { /* read from disk */}
};

class FloppyDisk : public Device
{
    public:
        FloppyDisk() { type = floppy_disk; }
        void write_to_floppy(FileObj &file, int len) { /* Writes data to floppy */}
        FileObj read_from_floppy() { /* read from floppy */}
};

class WeirdDevice : public Device
{
    public:
        WeirdDevice() { type = weird_device; }
        bool write_to_wd(char *data, int len) { /* Writes data to weird device */}
        char* read_from_floppy(int len) { /* read from floppy */}
};


//function that writes to devices look like this
// People who are doing to write to the devices will use this function
void write_to_device(Device &device, FileObj &file)
{
    switch(device.get_type())
    {
        case hard_disk:
            write_to_hdd(file);
            break;
        case floppy_disk:
            // Assuming FileObj class has a lenght function
            write_to_floppy(file, file.lenght());
            break;

        case weird_device:
            // Assuming FileObj has a to_char() method
            write_to_wd( file.to_char(), file.lenght());
            break;
    }
}


Wonderful. You wrote your drivers and you wrote your write_to_device and read_from_device functions and shipped them. It is working wonderfully.
Now, you want add support for another weird device. You wrote the driver and now you have to add the changes to your OS code as well. Add a new entry in the enum and a new switch-case entry. And worst, you have to compile your entire OS and ship it, just to support just that one other weird device.

Now your OS is getting popular and people want you to add support to new devices. And every time someone wants your OS to support a new device you have to recompile your entire OS. Your code is dependent on the drivers of these devices. 
This is not sustainable. You want to say to your users that, “If you want to use some weird devices with my OS, write your driver and use it. I am not changing my OS’s code to support all this weird devices.”
Okay. How do make that happen? How do you make them depend on you instead you depending on them? You invert the dependency.

How?

You say, “Anyone who wants a device to work with my OS needs to implement read, write, close, seek functions in their drivers with the exact function signature as I say. Do whatever you need to do under the hood, I don’t care, as long as your driver is correct, and it implements the interface I gave and you my OS will support it.”
Then you modified the code as below:

 

// For any device to work with your system it has to implement the interface,
// If these are not implemented correctly, you don't care, thier device will not work
class IODevice
{
    virtual int write(char *buffer, int len) = 0;
    virtual int read(int offset, char *buffer, int len) = 0;
    virtual int close() = 0;
    virtual int seek(int offset) = 0;
}

// your write function becomes the following
void write_to_device(IODevice &device, char *buffer, int len)
{
    device.write(buffer,len);
}

// your read function becomes
void read_from_device(IODevice &device, int offset, char *buffer, int len)
{
    device.read(offset,buffer,len);
}


// The drivers may look like this
class WeirdDevice : public IODevice
{
    // Implement thes function to work with the weird device
    int write(char *buffer, int len) { }
    int read(int offset, char *buffer, int len) { }
    int close() { }
    int seek(int offset) = { }
}

 

You have made this interface to your OS and you declared how these functions are expected to behave and you shipped your OS. This time whoever wants to add a new device looks at the IODevice interface and implements it and gives the driver to your OS to load.
Now you don’t depend on them. They depend on you. 

This is profound. Why? Think about this, the OS’s ability to handle a new device is its capability. That means you can add capability to your system without modifying any code of your system. Or you can make your system behave in a different way based on what you plug into it.

You can design certain core parts of your system in a way that it doesn’t depend on how a peripheral of the system works, instead it depends on an abstraction and the implementations of that abstraction can implement it however they would like to as long as they stick the interfaces that the core part of system provides

Your system hereby becomes decoupled from the peripheries. That’s really beautiful and unbelievably useful.

Think about Google Chrome. You can modify the behavior of Chrome or add functionalities to it without needing to modify its code just by using a plugin. How that is possible is, Chrome provides and API or an interface through which you get access to certain things in Chrome that you can tweak to make it behave in some different ways in the confines of the interface that it provides.

DIP says invert the dependency of selected part of your system to depend on an abstraction, instead of a concrete implementation, that way you can decouple certain selected parts of your system.

With this I will conclude my introduction to SOLID principle. Take your time to absorb this, because in the next one I will cover how to use design patterns effectively.
Until then, see yaa…
 

For further digging