S.O.L.I.D Principles
S.O.L.I.D. is an acronym proposed by the famous American programmer Robert C. Martin. Underneath it are five principles that tell you how to write good object-oriented code. The very word “solid” is also a play on words that can be translated as solid, concrete, strong. In this article, I will try to explain (using examples) the individual principles.
- S-ingle responsibility principle
- O-pen close principle
- L-iskov substitution principle
- I-nterface segregation principle
- D-ependency inversion principle
6. Useful terms
7. Summary
*Note: Although these rules may apply to different programming languages, the sample code in this article will use java.
**Note: I also assume that the reader has at least basic programming knowledge, with particular emphasis on object-oriented programming.
Single responsibility principle – each class should be responsible for one specific thing.
This principle boils down to the fact that a given class should have one responsibility. The one functionality it performs. Classes that implement only one responsibility are not directly coupled to other functionality.
Wrong code:
We have this class:
public class Student {
private String firstName;
private String lastName;
private int age;
public long calculateFee() { }
public String reportAttendance() { }
public void saveDetails() { }
}
Note that it is responsible not only for storing data about students, but also for charging fees, reporting attendance and saving their data in the database. So the ‘Student’ class has more than just one functionality, which is a violation of the Single Responsibility Principle.
To fix this, we’ll move different functionalities to different classes:
public class Student {
private String firstName;
private String lastName;
private int age;
}
public static class FeeCalculator {
public long calculateFee(Student s) { }
}
public static class AttendanceCalculator {
public String reportAttendance(Student s) { }
}
public static class StudentInfo {
public void saveDetails(Student s) { }
}
Thanks to such a separation of specific functionalities, any change in the logic, e.g. charging fees, does not lead to modification of our main ‘Student’ class.
Open close principle – each class should be open to extension but closed to modification.
Each class should be written in such a way that it is possible to add new functionalities without having to modify it. In other words, the programmer should not change the already existing application code (i.e. modify it), but rather extend it with new functionalities (extend it), adding new classes and methods. It can be said that it comes down to the conscious use of object-oriented programming mechanisms – composition, inheritance, polymorphism or access modifiers.
Wrong code:
Let’s say we have an application that calculates the areas of geometric figures. It may look like this:
public class Square {
public int A;
}
public class Rectangle {
public int A;
public int B;
}
public class AreaCalculator {
public int area(Object shape) {
int result = 0;
if (shape instanceof Square square) {
result = square.A * square.A;
return result;
}
if (shape instanceof Rectangle rectangle) {
result = rectangle.A * rectangle.B;
return result;
}
return result;
}
}
Now let’s test our application:
public class Launcher {
public static void main(String[] args) {
AreaCalculator calculator = new AreaCalculator();
int result = calculator.area(new Square(5));
System.out.println(result);
}
}
We got the correct result – 25. Note, however, that adding any new figure involves the need to modify the existing class. And this is against the open/closed principle. We will improve our code using, for example, polymorphism. But to be able to do that, we first need to add a new abstract class ‘Shape’. It contains only one method, which we will override later – then each class representing the figure will calculate its own area.
abstract class Shape {
public abstract int Area();
}
public class Square extends Shape {
public int A;
@Override
public int Area() {
return A * A;
}
}
public class Rectangle extends Shape {
public int A;
public int B;
@Override
public int Area() {
return A * B;
}
}
public class AreaCalculator {
public int Area(Shape shape) {
return shape.Area();
}
}
public class Launcher {
public static void main(String[] args) {
AreaCalculator calculator = new AreaCalculator();
int result = calculator.Area(new Square(5));
System.out.println(result);
}
}
Now, adding a new figure will not require us to interfere in the already existing code. In addition, the transparency of our application has improved.
Liskov substitution principle – any derived class (its subclass) can be used in place of the base class.
Its name comes from the name of the American programmer Barbara Liskov. This principle says that our code should work correctly with the base class as well as all its subclasses. It is somewhat an extension of the open/closed principle, and its application to the inheritance mechanism allows us to provide an alternative implementation of a given functionality without interfering with the operation of our program.
Wrong code:
Let’s create a ‘MediaPlayer’ class that will have two methods – play audio and video:
public class MediaPlayer {
public void playAudio() { }
public void playVideo() { }
}
Next, let’s add three subclasses corresponding to three specific media players:
public class VlcMediaPlayer extends MediaPlayer {
public void playAudio() {
System.out.println("Vlc: Playing audio...");
}
public void playVideo() {
System.out.println("Vlc: Playing video...");
}
}
public class MediaPlayerClassic extends MediaPlayer {
public void playAudio() {
System.out.println("MPC: Playing audio...");
}
public void playVideo() {
System.out.println("MPC: Playing video...");
}
}
public class WinampMediaPlayer extends MediaPlayer {
public void playAudio() {
System.out.println("Winamp: Playing audio...");
}
public void playVideo() {
System.out.println("Play video is not supported in Winamp player!");
}
}
Let’s test our application:
public class ClientTestProgram {
public static void main(String[] args) {
List<MediaPlayer> allPlayers = new ArrayList<MediaPlayer>();
allPlayers.add(new VlcMediaPlayer());
allPlayers.add(new MediaPlayerClassic());
allPlayers.add(new WinampMediaPlayer());
allPlayers.forEach(MediaPlayer::playVideo);
}
}
After running we will get:
Vlc: Playing video...
MPC: Playing video...
Play video is not supported in Winamp player!
Which is as expected since we know that Winamp can only play music and sounds. There is a phenomenon of poorly thought-out mechanism of inheritance here. So here we have a clear violation of the Liskov substitution principle.
Let’s improve our code:
public class MediaPlayer { }
We will create new classes just for video and audio playback:
public class VideoMediaPlayer extends MediaPlayer {
public void playVideo() { }
}
public class AudioMediaPlayer extends MediaPlayer {
public void playAudio() { }
}
Now, our 3 subclasses are also undergoing slight changes:
public class VlcMediaPlayer extends VideoMediaPlayer { }
public class MediaPlayerClassic extends VideoMediaPlayer { }
public class WinampMediaPlayer extends AudioMediaPlayer { }
Now let’s test our application:
public class ClientTestProgram {
public static void main(String[] args) {
List<VideoMediaPlayer> allPlayers = new ArrayList<VideoMediaPlayer>();
allPlayers.add(new VlcMediaPlayer());
allPlayers.add(new MediaPlayerClassic());
allPlayers.add(new WinampMediaPlayer()); // this line would give us error as can't add audio player in list of video players
allPlayers.forEach(VideoMediaPlayer::playVideo);
}
}
Notice that our list now contains a different data type – ‘VideoMediaPlayer’ instead of ‘MediaPlayer’. In this way, we signal that our list contains programs that can play video files. Trying to add Winamp to our list will immediately give us an error. If we want, we can add a second list in our class containing programs to play music. Then our class may look like this:
public class ClientTestProgram {
public static void main(String[] args) {
List<VideoMediaPlayer> allPlayers = new ArrayList<VideoMediaPlayer>();
allPlayers.add(new VlcMediaPlayer());
allPlayers.add(new MediaPlayerClassic());
allPlayers.forEach(VideoMediaPlayer::playVideo);
// Create list of audio players
List<AudioMediaPlayer> audioPlayers = new ArrayList<AudioMediaPlayer>();
audioPlayers.add(new WinampMediaPlayer());
// Play audio in all players
audioPlayers.forEach(AudioMediaPlayer::playAudio);
}
}
The example above obeys the Liskov substitution principle perfectly.
Interface segregation principle – interfaces should be specific and as small as possible.
Interfaces should be properly defined, and if they are quite extensive, they should be divided into smaller ones. In this way, we ensure that the class implementation will only apply to the methods that the class actually uses. In other words – we shouldn’t force classes to make dependencies on interfaces they don’t use. The purpose of the Interface Segregation Principle is similar to the Single Responsibility Principle.
Wrong code:
Let’s create an interface called ‘Conversion’ that will have three methods:
public interface Conversion {
void intToDouble();
void intToChar();
void charToString();
}
Let’s say we use this interface in twenty other classes of our application. We can safely assume that these classes don’t use all 3 of his methods. This is a clear violation of the Interface Segregation Principle. The solution will be to split our ‘Conversion’ interface into three smaller ones:
public interface ConvertIntToDouble {
void intToDouble();
}
public interface ConvertIntToChar {
void intToChar();
}
public interface ConvertCharToString {
void charToString();
}
Now we can only use the method we actually need. Let’s say one of our classes needs to convert integer to char and then char to String – then we’ll just use the ‘intToChar()’ and ‘charToString()’ methods to implement the appropriate interfaces:
public class DataTypeConversion implements ConvertIntToChar, ConvertCharToString {
@Override
public void charToString() { }
@Override
public void intToChar() { }
}
By splitting our interface into smaller ones, changing one of their methods doesn’t involve changing any of the 20 classes mentioned earlier.
Dependency inversion principle – all dependencies should depend as much as possible on the abstraction and not on the specific type.
Base classes should not depend on derived classes (subclasses), but both should depend on abstractions. Abstractions should not depend on details – details should depend on abstractions. Therefore, we should use abstractions (abstract classes and interfaces) instead of concrete implementations. Thanks to this, we reduce the tight coupling between classes, which is the main purpose of applying this principle.
Wrong code:
Let’s create a class ‘WindowsMachine’. To work on a Windows machine, we need a keyboard and a monitor, so we create the appropriate classes and add their instances to our class constructor. It looks something like this:
public class WindowsMachine {
private StandardKeyboard keyboard;
private Monitor monitor;
public WindowsMachine() {
keyboard = new StandardKeyboard();
monitor = new Monitor();
}
}
public class StandardKeyboard { }
public class Monitor { }
It’s worth noting that the above code will work, however, by declaring ‘StandardKeyboard’ and ‘Monitor’ with the “new” keyword, we’ve tied the three classes tightly together. Which not only makes it harder to test our ‘WindowsMachine’ class, but we also lost the ability to replace our ‘StandardKeyboard’ and ‘Monitor’ classes with others (if we needed to change, for example, a keyboard to some non-standard model). We will now improve our code by adding a more general keyboard interface (this will separate our classes):
public interface Keyboard { }
Our ‘WindowsMachine’ class now looks like this:
public class WindowsMachine {
private Keyboard keyboard;
private Monitor monitor;
public WindowsMachine(Keyboard keyboard, Monitor monitor) {
this.keyboard = keyboard;
this.monitor = monitor;
}
}
In the code above, we used the dependency injection pattern to make it easy to add the ‘Keyboard’ dependency to the ‘WindowsMachine’ class. We will also modify our ‘StandardKeyboard’ class by implementing the newly created keyboard interface so that it is injectable into our ‘WindowsMachine’ class:
public class StandardKeyboard implements Keyboard { }
Now our classes are separated and communicate using keyboard abstractions. If we want, we can easily change the keyboard type on our machine with a different implementation of the interface. We can apply the same principle to the ‘Monitor’ class.
List of useful terms:
- Polymorphism – in other words, multiformity, is saving one function (method) in different forms. This means that with it we can handle different types in different ways without knowing them.
- Inheritance – is a type of relationship between two classes that allows one of them to inherit the code of the other. Thanks to it, you can build a hierarchy between classes. In other words – an object can take over the methods and properties of another object.
- Dependency injection pattern – allows class instances to be automatically injected by the constructor, instead of creating them with the “new” keyword.
- Abstraction – we are not interested in the details of a given implementation, but in its functionality. As an example of abstraction, consider a DVD player. Consumers of the player are usually not interested in how it works from a technical point of view and what is going on inside it. They only care about how to turn it on, off, stop it, etc.
Summary
S.O.L.I.D Principles are a good base for every novice programmer – they allow, among others, to reduce dependencies between code blocks, and our system is scalable and easier to maintain. Of course, you have to remember that there may be situations where breaking some principles makes sense. Especially in complex projects, not all principles can always be perfectly followed, but we should strive to improve code quality and implement S.O.L.I.D. if possible. If only for the obvious reason – every programmer understands what he wrote himself. However, the problem arises when we are forced to move to a project that is not our own, and write in code that we have never seen before. In this case, it helps a lot if the system we are working on adheres to the principles of S.O.L.I.D.