SOLID Design Principles Demystified
SOLID is an acronym for five design principles aimed at making software designs more understandable, flexible, and maintainable. They were introduced by Robert C. Martin in his paper “Design Principles and Design Patterns”.
According to Wikipedia, the SOLID ideas are:
- Single Responsibility Principle (SRP) — A class should never change for more than one reason.
- Open-Closed Principle (OCP) —Entities should be open for extension, but closed for modification.
- Liskov Substitution Principle (LSP) —Any method that uses pointers or references to base classes must be able to use derived classes or objects without knowing it.
- Interface Segregation Principle(ISP) — It’s better to have many client-specific interfaces than one general-purpose one.
- Dependency Inversion Principle (DIP) — Depend upon abstraction then concrete implementation.
Let’s explore these SOLID principles in further detail.
Single Responsibility Principle
There should be only one reason for a class to change.
It is best to have every class responsible for a single part of the functionality provided by the software and to encapsulate that responsibility within the class.
This principle aims to reduce complexity. You have to change a class every time one of these things changes if it does too many things. In doing so, you risk breaking other parts of the class that you didn’t even intend to change.
In case you have trouble focusing on specific aspects of the program one at a time, remember the single responsibility principle and consider dividing some classes.
Open-Closed Principle
Classes should be open for extension, but closed for modification.
Its main purpose is to prevent existing code from breaking when new features are implemented.
You can extend a class, create a subclass and do whatever you want with it, add methods or fields, override the behavior, etc. At the same time, a class can be both open for extension and closed for modification.
It is risky to modify a class that has already been developed, tested, reviewed, and included in a framework or in other code. Rather than changing the code directly, you can create a subclass and override the parts of the original class that you want to change. You will achieve your goal but also won’t break any existing clients of the original class.
You don’t need to create a subclass to fix a bug in the class. Just fix the bug in the class directly. Child classes should not be responsible for parent classes’ problems.
Liskov Substitution Principle
If you’re extending a class, you should be able to pass an object of the subclass instead of an object of the parent without breaking the client code.
Subclasses should remain compatible with their superclasses. Override a method by extending its behavior rather than replacing it entirely.
Using the substitution principle, you can predict whether a subclass is compatible with code that can work with objects in the superclass.
As opposed to other design principles that are open to interpretation, the substitution principle consists of a set of formal requirements for subclasses and specifically for their methods.
Rule1: The parameter types of a method in a subclass should match or be more abstract than those of the method in the superclass.
Let’s say there’s a class that has a method that feeds dogs
feed(Dog dog);
This method will always be passed a Dog object from the client code.
The subclass we created overrides the method so that it can feed any animal (a superclass of dogs).
feed(Animal animal);
Now, if we pass an object of this subclass to the client code instead of an object of the superclass, everything would still work as expected. The method is capable of feeding all animals, so it can still feed any dog passed by the client.
Then you created another subclass and restricted the feeding method to only accept Husky dogs (a subclass of Dog).
feed(HuskyDog dog);
Because the method only feeds a specific breed of dogs, it won’t serve generic dogs passed by the client, breaking all functionality.
Rule 2: In a subclass method, the return type must match or be a subtype of the return type in the method of the superclass.
Consider a class that has a method
Dog buyDog();
Upon executing this method, the client code expects to receive any dogs.
We have now created a subclass that overrides the method as follows
HuskyDog buyDog();
The client gets a Husky dog, which is still a dog, so all is well.
Now you have created another subclass that overrides the method as follows
Animal buyDog();
Now, the client code breaks since it receives an unknown generic animal that does not fit a structure intended for a dog.
Rule 3: A method in a subclass should not throw exceptions that the base method isn’t supposed to throw.
In the client code, try-catch blocks target specific types of exceptions that the base method might throw. Therefore, an unexpected exception could slip through the defensive lines of the client code and crash the entire application.
Rule 4: Subclasses shouldn’t strengthen preconditions.
Consider a case where the base method has a parameter of type int. A subclass can override the method and require that the value of an argument passed to the method be positive, which strengthens the preconditions.
Client code which used to work fine when passing negative numbers into the method now fails when it starts working with this subclass.
Rule 5: Subclasses shouldn’t weaken post-conditions.
Let’s imagine a class that has a method that interacts with a database. Upon returning a value, a method of the class is supposed to close all open database connections.
We created a subclass that keeps database connections open so we can reuse them. However, the client might not realize what you intend and close the program after calling the method and leaking database connection resources.
Rule 6: A superclass must preserve its invariants.
An invariant is a condition in which an object makes sense. When extending a class, the safest way is to introduce new fields and methods without messing with any existing members of the superclass.
Rule 7: A subclass shouldn’t be able to modify a superclass’s private fields.
Contrary to popular belief, it is possible to access and modify private fields. Some programming languages, such as Java, allow you to access private fields through reflection. A subclass should never touch superclass private fields.
Interface Segregation Principle
Clients shouldn’t be forced to rely on methods they don’t use.
Make your interfaces narrow enough so that clients don’t have to implement behaviors they don’t need. Fat interfaces should be broken down into more granular and specific ones. Only the methods the clients really need should be implemented.
Classes can inherit from just one superclass, but they can implement any number of interfaces at the same time.
Dependency Inversion Principle
Classes at the high level should not be dependent on classes at the low level. Both should rely on abstractions. Abstractions should not be dependent on details (concrete implementation). Rather, abstractions should determine the details.
There are usually two levels of classes when designing software:
- Low-level classes — Using these classes, you can work with storage, move data over a network, connect to a DB, etc.
- High-level classes — Complex business logic is contained in these classes that direct low-level classes to do certain things.
It’s common for people to design low-level classes first and then work on the high-level ones since they are not sure what’s possible at a high level until the low-level design is clear.
According to the dependency inversion principle, this dependency should be reversed.
There should be interfaces for low-level operations that high-level classes rely on, preferably in business terms. High-level classes can now be dependent on those interfaces instead of concrete low-level classes. When low-level classes implement these interfaces, they become dependent on the business logic layer, reversing the direction of the original dependency.
For screwing and unscrewing flat-head type screws, the screwdriver handle (high-level class) uses a fixed flat-head tool (low-level class). Because the screw type is tightly coupled with the screwdriver handle class, if the screw-type changes, it will impact the screwdriver handle class.
Create a high-level interface that describes how any screw-type tool can screw/unscrew any screw, and make the screwdriver handle class use that interface instead of the low-level one. You can then modify or extend the screw-type tool to accommodate the new screw type without affecting screwdriver handle class changes.