SOLID Principles Explained
The SOLID principles are often quoted, rarely practiced, and even less frequently understood. For me, they belong in the same category as ACID database transactions or enterprise design patterns. I kind of know what they are about, but I rarely find myself applying them. Instead, they linger in the back of my mind as vague guidelines, contributing to my always increasing impostor syndrome.
Understanding SOLID Principles
First of all, let’s get the acronyms out of the way. As you’ll see, the principles appear straight forward at first, but each of them come with caveats we’ll look at in detail in a second.
”S” stands for Single Responsibility Principle meaning every class should have one, and only one, reason to change. Sounds easy, right? But then you realize your “User Service” class is doing everything from authenticating users to sending welcome emails.
”O” is for the Open / Closed Principle. Classes should be open for extension but closed for modification. In theory, this prevents breaking existing code when adding new features. In practice, this is where you could run into the pain points of inheritance.
”L” means Liskov Substitution Principle. In other words, objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program. So if your subclass breaks the behavior expected by the parent class, you’re most likely doing things wrong.
”I” stands for Interface Segregation Principle, so you shouldn’t make clients implement interfaces they don’t use.
”D” is for Dependency Inversion Principle, or, in other words, you should depend on abstractions rather than concrete implementations. This sounds great on paper, but if you’ve ever stared at a tangle of service containers and factories, you might wonder if the cure is worse than the disease.
Single Responsibility Principle
Single Responsibility is one of the simpler principles of SOLID. The name is pretty self explanatory and it all boils down to designing your entities in a way that each one focuses on a single, well-defined responsibility. The idea is that if something changes, there’s only one reason it should impact a particular class. However, I agree, defining the “responsibility” is the tricky part. In a practical example, UserService is a place where people tend to add all the business logic associated with the user entity. In a real word project this will contain only CRUD operations at first, but then it will slowly grow to contain all kinds of related business logic. It is easy to dump everything User related in this service but, to make things more practical, think of it like this. If you find yourself constantly changing the same class for completely unrelated reasons, it’s a sign that it might be taking on too much. So break it down into smaller, more focused pieces. For example, you could split the User Service into smaller more specific services. Sure, you’ll have more classes, but each will be easier to understand, test, and modify without unintended side effects.
Open/Closed Principle
The Open / Closed principle simply states that whenever new functionality is added in a software system you shouldn’t edit the existing classes or methods, but instead create new ones. This sounds great on paper. Your codebase remains untouched, and new features seamlessly plug in without breaking anything. But in reality, it often leads to tough design decisions because, again, you risk turning your code into a collection of tiny scattered classes. A common way to implement this principle is by using abstractions like interfaces or abstract base classes. For example, imagine you’re building a payment system that initially supports only credit cards. Instead of hardcoding the payment logic into a single “Payment Processor”class, you could create a “Payment Method”interface. Then, when you need to add PayPal or CryptoCurrency payments, you simply create new classes that implement the “PaymentMethod” interface.
Liskov Substitution Principle
This principle enforces that derived classes can seamlessly replace their parent classes without breaking your code. This seems like common sense—of course a subclass should behave like its parent. But it’s actually really easy to unintentionally violate this constraint. For instance, let’s assume you have defined a “Bird” class with a fly method. Then you create a “Penguin” subclass. Penguins are birds, but they can’t fly. If you override the fly() method to throw an exception, congratulations—you’ve just broken the Liskov Substitution Principle. The solution is to design your hierarchies more carefully. Instead of having a general “Bird” class with flying behavior, you could create separate classes like “FlyingBird” and “NonFlyingBird”. This way, you don’t force subclasses into behaviors they can’t support, and your code becomes more robust.
Interface Segregation Principle
The Interface Segregation Principle is also pretty straightforward. Don’t make clients implement interfaces they don’t actually use. Again, this sounds great on paper, but it is very easy to break the constraint. Imagine you have an interface called “Animal” with methods like eat(), sleep(), and fly(). A dog class implementing this interface would have no use for the fly() method, but it’s still forced to include it because of the interface. This leads to awkward workarounds, like having empty or stubbed methods, which defeats the whole purpose of clean design.
Dependency Inversion Principle
Finally, high-level modules should not depend on low-level modules. They should both depend on abstractions. In other words, the way your code is structured should prioritize flexibility and testability. A classic example is logging. Instead of a class directly calling a concrete “FileLogger” implementation, you’d depend on an abstraction, like a “Logger” interface. This allows you to easily swap out the logging implementation, for instance to a “DatabaseLogger” without changing your high-level business logic. Dependency injection frameworks are often used to facilitate this by managing the creation and binding of these abstractions to their concrete implementations. With dependency injection, your high-level module doesn’t concern itself with how the Logger is instantiated—it simply knows it has a Logger to use. This decoupling not only improves flexibility but also makes unit testing much easier.
If you want to explore other software best practices topics you can check some of the other videos on my channel.
Until next time, thank you for reading!