Be SOLID!
Sometimes when things are solid it’s good. A SOLID start on something is good. When you have a SOLID understanding of whatever you do, it’s good. So is being SOLID in your software development!
In our last article (find it here - The taste of a DRY KISS), we discussed some of the key software development principles. In this article, we discuss another one, the SOLID principles!
SOLID stands for:
Let’s try to take a different approach to understand these 5 principles.
This principle is the talk of everywhere. It’s the same as the Separation of Concerns (SoC). Things are clearer and transparent when only the designated party does the job. It’s easier to make improvements or to pinpoint and isolate the issues. It’s also easier to monitor the functionality.
With software development, we want a class in the presentation layer to just do functionality related to that - handle API requests and responses only. A class in the service layer must only handle the business logic. Those in the data persistence layer should handle the operations related to persisting data and only that. An entity representing a database table should only act as a model and not directly interact with the database.
Staying in one’s respective lane makes things easier for everyone!
Entities we define to represent objects in the real world must only be allowed to extend but not modify. This comes tightly coupled with the idea of inheritance in the OOP programming paradigm.
Let’s say we want to introduce new abilities to an entity. Say we have different singers singing songs from different genres. If we have a CountrySinger, a JazzSinger, a RockSinger who all can sing but different styles, these abilities are best introduced through a canSing ability (an interface) which is implemented by each class.
interface canSing {
void sing();
}
class CountrySinger implements canSing {
@Override
public void sing() {
System.out.println("Singing country music!");
}
}
class PopSinger implements canSing {
@Override
public void sing() {
System.out.println("Singing pop music!");
}
}
class RockSinger implements canSing {
@Override
public void sing() {
System.out.println("Singing rock music!");
}
}
Imagine instead you have a common Singer class with a concrete implementation of a sing() method with different singing behaviors as below.
class Singer {
private final String genre;
public Singer(String genre) {
this.genre = genre;
}
public void sing() {
if ("country".equalsIgnoreCase(genre)) {
System.out.println("Singing a country song!");
} else if ("jazz".equalsIgnoreCase(genre)) {
System.out.println("Singing a jazz song!");
} else if ("rock".equalsIgnoreCase(genre)) {
System.out.println("Singing a rock song!");
} else {
System.out.println("Singing a song of unknown genre!");
}
}
}
What’s the problem with this?
Obviously, as we define more and more genres, the if-else if-else ladder is going to get longer and longer in the first place making it harder to maintain. It can easily introduce bugs in complex scenarios. Not only that but it doesn’t ensure that the changes have no impact on the existing behavior, meaning we need to put more effort on regression testing.
So always make your code extendable and avoid modification!
This one is a tricky one! But once you get to the core, you will never miss it!
Named after the computer scientist Barbara Liskov who introduced this principle for the first time in 1987, states that “objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program”. 😵💫
Wait, whaaaaaat?
This essentially means that if our program expects a list of Birds, I should be able to introduce a Parrot, an Eagle or even an Ostrich without breaking the existing functionality of the program. Let’s look at an example.
class Bird {
public void fly() {
System.out.println("Bird is flying");
}
}
class Parrot extends Bird {
@Override
public void fly() {
System.out.println("Parrot is flying");
}
public void speak() {
System.out.println("Parrot is speaking");
}
}
class Eagle extends Bird {
@Override
public void fly() {
System.out.println("Eagle is soaring high");
}
public void hunt() {
System.out.println("Eagle is hunting");
}
}
class Ostrich extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Ostriches cannot fly");
}
public void run() {
System.out.println("Ostrich is running fast");
}
}
From the look of this, it correctly overrides the fly() method of the Bird class in all of its child classes. But what if we had a list of Birds and stored different instances of each of its subclasses and invoked the fly() method on them? Everything will work fine until we make an Ostrich fly! That breaks our program unless we have explicitly handled that!
Recommended by LinkedIn
But that breaks the rule!
Why is this important?
That exception thrown when flying the Ostrich needs to be handled and doesn’t guarantee that the change didn’t introduce further bugs into the existing functionality. Makes our code less reliable!
Say we handled this exception. This is very unique to the Ostrich. The other birds don’t do that. So the code becomes complex and difficult to maintain!
This is also closely related to the concept of polymorphism in OOP and the open/ closed principle we just discussed which supports reusability!
How do you fix this?
In the same way we did with the Open/ Closed principle above, make it an ability! Define an interface canFly(). Thus, the Ostrich doesn’t need to implement it.
The ISP enforces that “No client should be forced to depend on methods it does not use!”
This principle further highlights the importance of using interfaces to introduce abilities rather than having them overridden from parent classes.
abstract class Car {
public void start();
public void stop();
public void drive();
public void chargeBattery();
}
class PetrolCar extends Car {
@Override
public void start() {
System.out.println("Petrol car started");
}
@Override
public void stop() {
System.out.println("Petrol car stopped");
}
@Override
public void drive() {
System.out.println("Driving petrol car");
}
@Override
public void chargeBattery() {
throw new UnsupportedOperationException("Not supported yet.");
}
}
class ElectricCar extends Car {
@Override
public void start() {
System.out.println("Electric car started");
}
@Override
public void stop() {
System.out.println("Electric car stopped");
}
@Override
public void drive() {
System.out.println("Driving electric car");
}
@Override
public void chargeBattery() {
System.out.println("Charging electric car battery");
}
}
We segregate these abilities to interfaces that can be selectively implemented by each class depending on the necessity! We don’t force the subclasses to override all the unnecessary abilities.
What are the pros?
Same as the ones we saw in LSP above! Pause here for a minute and see how these principles are tightly knit together!
DIP states that High-level modules should not depend on low-level modules and that both should only depend on abstractions like interfaces! 🤔
Wait till you spot the similarities!
class MySQLDatabase {
public void connect() {
System.out.println("Connected to MySQL");
}
}
class ReportService {
private final MySQLDatabase db = new MySQLDatabase();
public void generate() {
db.connect(); // tightly coupled to MySQL
}
}
What’s the issue here?
We have a class MySQLDatabase, a very specialised class to connect to a MySQL database only. That delivers some low-level functionality. We have a high-level ReportService class which depends on the low-level MySQLDatabase because it has an instance of it as a property. What if the ReportService wanted to use a different type of database? This means we have to modify the high-level ReportService class to support this ability. We may want to have different ReportServices running in different customers with different databases. What do we do then?
In SOLID principles, we try to minimize the impact of inevitable changes to the code at different levels. We saw some examples above. Now DIP tries to avoid the same at another level.
This implementation also restricts the ability to easily perform unit tests in addition to rigidity towards change and less reusability. The MySQLDatabase being very specialized prevents it from getting mocked in unit tests.
interface Database {
void connect();
}
class MySQLDatabase implements Database {
public void connect() {
System.out.println("Connected to MySQL");
}
}
class PostgreSQLDatabase implements Database {
public void connect() {
System.out.println("Connected to PostgreSQL");
}
}
class ReportService {
private final Database db;
public ReportService(Database db) {
this.db = db;
}
public void generate() {
db.connect(); // depends on abstraction, not implementation
}
}
Now with this approach, we can have a MockDatabse extending from the Database and use that to test the ReportService.
Now that we’ve had some further enlightenment on the SOLID principles, I hope it will help you further leverage on the powerfulness of these principles in your next design!