S.O.L.I.D Principles

Shubham Gupta
Jun 14 · 10 min read

Every wannabe a Developer should know to write more readable, extensible and maintainable programs

Object oriented programming has brought new paradigm in software development. OOP has enabled developers to design classes which combine the data and its functionality in single unit to deal with sole purpose of its existence.

But, this Object-oriented programming doesn’t prevent confusing, inflexible or unmaintainable programs.

Then comes the Uncle Bob aka Robert C. Martin, with his 5 guidelines/principles. These 5 principles helps developers to create more readable, flexible and maintainable program. SOLID Principles is a coding standard that all developers should have a clear concept for developing software in a proper way to avoid a bad design.

SOLID is a mnemonic acronym for five design principles which individually stands for :

  • S : Single Responsibility Principle
  • O : Open Close Principle
  • L : Liskov Substitution Principle
  • I : Interface Segregation Principle
  • D : Dependency Inversion Principle

Before we start looking at each of the principles individually, let me give you a brief introduction about me and my work. I work as a Developer on an IIOT platform (Field/Edge to Cloud/DC) enabling simplification and acceleration of Real Time data.

I’ll be explaining SOLID principles using telemetry data formats which is used in IOT fields. The data format is nothing but a combination of a Key and a Value.

For example :

Key : Temperature
Value : 35

Combination of both will create a KvEntry (Key-Value Entry). Based on different data type of Value there are multiple implementation for KvEntry. For Example, DoubleKvEntry, StringKvEntry, BooleanKvEntry, LongKvEntry.

Those who didn’t get the context of KvEntry data, you guys don’t have to worry, I’ll give more example with each principle to make is easy to understand.

Let’s take each principle one by one :

S : Single Responsibility Principle

As the name says :

“A Class should have one and only one job”

A class should be responsible for only one thing. If a class has more than one responsibility than its a violation of the first principle. One job/thing doesn’t mean the class should have only one method. Instead it says all method should relate directly to the responsibility of the class and work towards the same goal.

For example,

class KvEntry {
constructor(String key , Object value);
getKey();
saveKvEntry(KvEntry kvEntry);
}

Does this KvEntry class violates the Single Responsibility Principle. If yes, then How?

Here we can draw out that KvEntry class have two responsibility : KvEntry properties management and KvEntry database management. The constructor, getKey methods do property management whereas saveKvEntry manages KvEntry storage on database.

In future it will affects the application adversely. As in, if application changes the way it manages database management, then the classes that uses KvEntry class need to touched and recompiled to compensate for the changes.

We can see the rigidity in the application.

To make this conform to Single Responsibility Principle, we create another class that will handle the database management for KvEntry.

class KvEntry {
constructor(String key , Object value);
getKey();
}
class KvEntryDb {
getKvEntry();
saveKvEntry(KvEntry kvEntry);
}

With these changes our application will become highly cohesive.

Another example, I will just provide the class that violates the Single Responsibility Principle. Try by yourself and see how it violates the principle and suggest changes.

class Animal {
constructor(String name)
getAnimalName();
saveAnimal(Animal animal);
}

O : Open Close Principle

In simple words its says :

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

Software entities are like classes, modules, functions, etc.

In much simpler words, it means that a class should be easily extendable without modifying the existing class or function itself.

Let’s continue with our KvEntry class

class KvEntry {
constructor(String key, String value);
getKey();
getValue();
}

We want to iterate through the list of KvEntry and print the data type for its values.

//… Construct a list of KvEntryArrayList<KvEntry> kvEntries = new ArrayList<KvEntry>();
kvEntries.add(new KvEntry("temp",35.24));
kvEntries.add(new KvEntry("switchStatus",true));

void printDataType(List<KvEntry> kvEntries) {
foreach(KvEntry kvEntry :kvEntries){
if (kvEntry.getValue() instanceof Double) {
log.info("Double");
} else if (kvEntry.getValue() instanceof Boolean) {
log.info("Boolean");
}
}
}

printDataType(kvEntries);

As we can see, for every new implementation of KvEntry, we need to add a new logic to printDataType method. For such a small application it’s pretty easy to handle these conditions. But as our application grows and become complex, we will see that the if statements are getting repeated again and again in printDataType method each time a new KvEntry is added.

Now, the question is how to conform Open Close Principle?

class KvEntry() {
getDataType();
//…
}
class DoubleKvEntry extends KvEntry {
getDataType() {
return "Double";
}
}

class BooleanKvEntry extends KvEntry {
getDataType() {
return "Boolean";
}
}

class StringKvEntry extends KvEntry {
getDataType() {
return "String";
}
}

//…
void printDataType(List<KvEntry> kvEntries) {
foreach(KvEntry kvEntry : kvEntries) {
log.info(kvEntry.getDataType());
}
}

printDataType(kvEntries);

KvEntry now has a virtual method getDataType. We have each KvEntry extend the KvEntry class and implement the virtual getDataType method. Here each implementation of KvEntry have is own implementation of data type.

Now, if we add a new data type of KvEntry, the printDataType method doesn’t have to change. All we need to do is to add a new KvEntry in KvEntries arraylist.

Another example to think on, extending our previous Animal class.

class Animal {
constructor(String name)
getAnimalName();
}

//… Construct a list of Animals
ArrayList<Animal> animals = new ArrayList<Animal>();
animals.add(new Animal("lion"));
animals.add(new Animal("dog"));

void animalSound(List<Animal> animals) {
foreach(Animal animal :animals){
if (animal.getAnimalName().equals("lion")) {
log.info("roar");
} else if (animal.getAnimalName().equals("dog")) {
log.info("bark");
}
}
}

animalSound(animals);

L : Liskov Substitution Principle

Its wikipedia definition says :

“Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.

Pretty complex right, let me put it in simple words :

“Every subclass/derived class should be substitutable for their base/parent class.”

The principle says that a sub-class can take the place of its super-class without errors. In other words, a subclass should override the parent class methods in a way that it doesn’t break functionality from a client’s point of view.

If the code finds itself checking the type of class then, it must have violated this principle.

Let’s take an example :

void printValues(List(KvEntry) kvEntries) {
foreach(KvEntry kvEntry : kvEntries) {
if (kvEntry.getValue() instanceof Double) {
log.info("Double Value : " +(Double)kvEntry.getValue());
} else if (kvEntry.getValue() instanceof Boolean) {
log.info("Boolean Value : " +(Boolean)kvEntry.getValue);
} else if (kvEntry.getValue() instanceof String) {
log.info("String Value : " + (String)kvEntry.getValue);
}
}
}

printValues(kvEntries);

The above implementation of printValues violates the Liskov Substitution Principle ( and also the Open Close Principle). It must know of every KvEntry type and call the associated getValue function.

With every new implementation of KvEntry, the printValues method must be modified to accept the new KvEntry.

//…
class LongKvEntry extends KvEntry {
}

ArrayList<KvEntry> kvEntries = new ArrayList<KvEntry>();

kvEntries.add(new

LongKvEntry("distance",12345));


void printValues(List(KvEntry) kvEntries) {
foreach(KvEntry kvEntry :kvEntries) {
if (kvEntry.getValue() instanceof Double) {
log.info("Double Value : " + (Double) kvEntry.getValue());
} else if (kvEntry.getValue() instanceof Boolean) {
log.info("Boolean Value : " + (Boolean) kvEntry.getValue);
} else if (kvEntry.getValue() instanceof String) {
log.info("String Value : " + (String) kvEntry.getValue);
} else if (kvEntry.getValue() instanceof Long) {
log.info("Long Value : " + (Long) kvEntry.getValue);
}
}
}

printValues(kvEntries);

So to follow the Liskov Substitution Principle there are two rules:

  • If the super-class (KvEntry) has a method that accepts a super-class type (KvEntry) parameter, then is sub-class (LongKvEntry) should accept an argument of super-class type (KvEntry) or sub-class type (LongKvEntry).
  • If the super-class returns a super-class type (KvEntry). Its sub-class should return a super-class type (KvEntry type) or sub-class type(LongKvEntry).

Now, let’s reimplement the printValues method :

void printValues(List(KvEntry) kvEntries) {
foreach(KvEntry kvEntry : kvEntries) {
log.info("Value : " + kvEntry.getValue());
}
}

printValues(kvEntries);

The printValues method cares less about the type of KvEntry passed, it just calls the getValue method. All it knows is that the parameter must be of an KvEntry type, either the KvEntry class or its sub-class.

The KvEntry class now have to implement/define a getValue method:

class KvEntry {
//…
getValue();
}

And its sub-classes have to implement the getValue method:

//…
class LongKvEntry extends KvEntry {
//…
getValue() {
//…
}
}

When it’s passed to the printValues function, it returns the long value it has.

We can see that, the printValues doesn’t need to know the type of KvEntry to return its value, it just calls the getValue method of the KvEntry type because by contract a sub-class of KvEntry class must implement the getValue function.

Here is an another problem for you to try implementing Liskov Substitution Principle :

//…
class Pigeon extends Animal {
}

ArrayList<Animal> animals = new ArrayList<Animal>();
animals.add(new Pigeon("pigeon"));

void animalLegCount(List<Animal> animals) {
foreach(Animal animal : animals) {
if (animal instance of Lion) {
log.info(animal.lionLegCount());
} else if (animal instance of Dog) {
log.info(animal.dogLegCount());
} else if (animal instanceof Pigeon) {
log.info(animal.pigeonLegCount());
}
}
}

animalLegCount(animals);

I : Interface Segregation Principle

What wikipedia says :

“many client-specific interfaces are better than one general-purpose interface.”

In more simple words :

“A client should never be forced to implement an interface that it doesn’t use or clients shouldn’t be forced to depend on methods they do not use.”

This principle mainly deals with the disadvantages of implementing big interfaces.

Let’s take a break from KvEntry example and try an old and typical example of Shape :

interface IShape {
drawCircle();
drawSquare();
}

This interface draws circle and square. class Circle, Square implementing IShape interface must implement both methods drawCircle, drawSquare.

class Circle implements IShape {
drawCircle(){
//…
}

drawSquare(){
//…
}
}

class Square implements IShape {
drawCircle(){
//…
}

drawSquare(){
//…
}
}

Doesn’t the above implementation looks weird and somewhat funny. class Circle implements method drawSquare it has no use of, likewise class Square implementing drawCircle.

Now, in a new requirement we need to support a new Shape Triangle.

interface IShape {
drawCircle();
drawSquare();
drawTriangle();
}

All classes need to implement the new method otherwise error will be thrown.

We see that it is impossible to implement a shape that can draw a circle but not a square or a triangle. We can just implement the methods to throw an error that shows the operation cannot be performed.

Interface segregation principle frowns against the design of this IShape interface. Clients (here Circle, and Square) should not be forced to depend on methods that they do not need or use. Also, Interface segregation principle states that interfaces should perform only one job (just like the Single Responsibility Principle) any extra grouping of behavior should be abstracted away to another interface.

Here, our IShape interface performs actions that should be handled independently by other interfaces.

To make our IShape interface conform to the Interface Segregation principle, we segregate the actions to different interfaces:

interface IShape {
draw();
}

interface ICircle {
drawCircle();
}

interface ISquare {
drawSquare();
}

interface ITriangle {
drawTriangle();
}

class Circle implements ICircle {
drawCircle() {
//…
}
}

class Square implements ISquare {
drawSquare() {
//…
}
}

class Triangle implements ITriangle {
drawTriangle() {
//…
}
}

class CustomShape implements IShape {
draw(){
//…
}
}

The ICircle interface handles only the drawing of circles, IShape handles drawing of any shape, ISquare handles the drawing of only squares and ITriangle handles the drawing of only triangles.

OR

Classes (Circle, Square, Triangle, etc) can just inherit from the IShape interface and implement their own draw behavior.

class Circle implements IShape {
draw(){
//…
}
}

class Triangle implements IShape {
draw(){
//…
}
}

class Square implements IShape {
draw(){
//…
}
}

We can then use the I-interfaces to create Shape specifics like Semi Circle, Right-Angled Triangle, Equilateral Triangle, Blunt-Edged Rectangle, etc.

D : Dependency Inversion Principle

By Definition it says :

“Entities must depend on abstractions, not on concretions. It states that the high level module must not depend on the low level module, but they should depend on abstractions.”

There comes a point in software development where our app will be largely composed of modules. When this happens, we have to clear things up by using dependency injection. High-level components depending on low-level components to function.

By applying the Dependency Inversion the modules can be easily changed by other modules just changing the dependency module and High-level module will not be affected by any changes to the Low-level module.

Let’s take a look at an example :

class MySqlConnection {
connect() {
log.info("MySql DB Connection ");
}
}

class QueryExecutor {
private MySqlConnection dbConnection;
constructor(MySqlConnection dbConnection) {
this.dbConnection = dbConnection;
}
}

There’s a common misunderstanding that dependency inversion is simply another way to say dependency injection. However, the two are not the same.

In the above code in spite of Injecting MySQLConnection class in QueryExecutor class but it depends on MySQLConnection. High-level module QueryExecutor should not depend on low-level module MySQLConnection.

If we want to change the connection from MySQLConnection to PostgresDBConnection, we have to change hard-coded constructor injection in QueryExecutor class.

QueryExecutor class should depend upon on Abstractions not on concretions. But How can we do it ?

interface Connection {
connect();
}

class MySqlConnection implements Connection{
connect() {
log.info("MySql DB Connection ");
}
}

class PostgresDBConnection implements Connection{
connect() {
log.info("Postgres DB Connection ");
}
}

class QueryExecutor {
private Connection dbConnection;
constructor(Connection dbConnection) {
this.dbConnection = dbConnection;
}
}

In the above code, we want to change the connection from MySQLConnection to PostgresDBConnection, we don’t need to change constructor injection in QueryExecutor class. Because here QueryExecutor class depends upon on Abstractions, not on concretions.

Conclusion

So, we have covered all the 5 SOLID principles. In start it will look like some rocket science, but trust me with steady practice and a little patience, these principles will be a part your programs.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade