Context Link to heading
Before we dive in, let me share some context. This article is part of a continuous series of study notes developed during my internship as a Fullstack & Mobile Developer. To build a habit I’ve wanted for a long time โ rather than just passively watching tutorials โ I decided to do things differently: using these topics as a structured roadmap to build this blog from scratch.
In other words, I’m discovering my writing voice and steadily refining it. As this series progresses, I hope to better distill and share the knowledge I acquire. Today’s post is about SOLID. To understand SOLID well, a solid grasp of Object-Oriented Programming (OOP) is crucial. If you need a refresher, feel free to check out the previous post, read through it, and reflect on the analogies.
SOLID Link to heading
Back in the 1990s, Object-Oriented Programming was already widespread. Leading software engineers of the era had various ideas about what constituted essential principles for building clean, maintainable software. That was when Robert C. Martin (“Uncle Bob”) gathered the core principles that made object-oriented systems cohesive and robust, synthesizing them into 5 foundational guidelines.
From these five principles came the well-known acronym SOLID. Let’s examine what each letter represents and why it matters.
“Alright, but how does this actually change my day-to-day life as a developer?”
In my view, applying SOLID is really about embracing the philosophy of keeping your workspace tidy. If you step into a naturally organized room and something is out of place, you notice it immediately because everything follows a clear standard and has its dedicated spot.
Using SOLID in your daily programming workflow is just like keeping a house in order:
- A closet only does closet things โ you don’t store kitchen trash in the wardrobe.
- A dining table isn’t meant to hold random clutter.
In code, it means preserving consistency: when implementing new features, you adhere to these established principles. Every component stays organized, easy to locate, and predictable to modify.
๐ ## Key Benefits
- Easier Maintenance: Code adapts smoothly to shifting requirements and scope changes.
- Testability: High testability makes unit testing straightforward and painless.
- Extensibility: Classes stay open for extension without requiring risky rewrites.
- High Reusability: Promotes modular, reusable components across your systems.
- Code Longevity: Extends the lifecycle and sustainability of the software you write.
๐คฉ ## Common Problems Avoided
- Headaches when writing automated tests or unit tests
- Messy, unstandardized “spaghetti” codebases
- Tightly coupled logic that makes isolating functions impossible
- Needless code duplication
- Fragility โ where touching one file unexpectedly breaks three others
Single Responsibility Principle Link to heading
- SRP โ Single Responsibility Principle
This principle states that a class should have one, and only one, reason to change.
In practical terms: if you have a class responsible for processing payments, its code should only change when payment rules or workflows change โ not when you modify how transactional emails or push notifications are dispatched.
Payment processing is one responsibility; notification delivery is an entirely separate concern. Mixing both within the same class violates SRP.
Open/Closed Principle Link to heading
- OCP โ Open/Closed Principle
This principle dictates that software entities (classes, modules, functions) should be open for extension, but closed for modification.
“That sounds like a paradox! If a class is closed for modification, does that mean once I write it, I can never touch it again?”
When I first encountered this definition, I had the exact same question. But once you look at real-world implementations, it clicks: it’s all about the class’s core domain.
If you are fixing a bug or refining existing behavior within the class’s established domain, modifying it is normal. However, when you want to introduce new behaviors or variants, you should design the system so you can extend it (e.g., via inheritance, polymorphism, or composition) rather than directly altering tested, stable source code.
Liskov Substitution Principle Link to heading
- LSP โ Liskov Substitution Principle
This principle is notoriously formal in its academic definition, so it took me several readings to internalize. After breaking it down, here is how I explain it to myself:
“Subclasses should be substitutable for their superclasses without altering the correctness of the program.”
A superclass is any parent class; a subclass is any child class that extends it. What Barbara Liskov’s principle highlights is that if you create an abstraction where a child class cannot seamlessly substitute its parent, your abstraction is broken.
Consider the classic duck analogy: if it looks like a duck and quacks like a duck, but needs batteries to operate, your abstraction is flawed. If code expecting a living Duck receives a ToyDuck that requires battery management, the substitution fails and breaks client expectations.
“Can you show me what Superclass and Subclass look like in code?”
class SuperClass { // Parent class
private String name = "Base Class";
}
public class SubClass extends SuperClass { // Child class or subclass
public SubClass(){
super();
}
}
If your code accepts SuperClass, passing SubClass must fulfill the exact same behavioral contract. If doing so throws unexpected exceptions, ignores methods, or breaks assumptions, stop and rethink your design.
โ Technical Pause:
If you’ve made it this far, take a quick break! Stretch, grab some water, and let these ideas settle. Architectural concepts are concise to write down, but take time to visualize intuitively.
Interface Segregation Principle Link to heading
- ISP โ Interface Segregation Principle
This principle addresses contracts:
“Clients should never be forced to depend upon interfaces that they do not use.”
Here, “client” refers to the class that implements the interface. If an interface contract forces a class to implement “dummy” methods or throw NotImplementedException, that interface is too bloated. The solution is to segregate large interfaces into smaller, more specialized contracts.
Let’s look at an example based on a great tutorial by DigitalOcean. Imagine a ShapeInterface representing 2D shapes. Later, someone decides we also need to support 3D shapes with volumes:
interface ShapeInterface {
public function area();
// Adding volume() to the existing interface:
public function volume();
}
Now, every 2D shape (like Square or Circle) is forced to implement volume(), which makes no geometric sense!
To adhere to ISP, we split the contract into specialized interfaces:
interface ShapeInterface
{
public function area();
}
interface ThreeDimensionalShapeInterface
{
public function volume();
}
The original ShapeInterface stays clean and focused. Classes implement only what they actually need:
class Square implements ShapeInterface {
public $length;
public function __construct($length) {
$this->length = $length;
}
public function area() {
return pow($this->length, 2);
}
}
And when a 3D shape comes along, it simply implements both contracts:
class Cuboid implements ShapeInterface, ThreeDimensionalShapeInterface
{
public function area()
{
// Calculate the surface area of the cuboid
}
public function volume()
{
// Calculate the volume of the cuboid
}
}
Cuboid implements both interfaces peacefully, while Square remains lean and unburdened.
Dependency Inversion Principle Link to heading
- DIP โ Dependency Inversion Principle
Dependency Inversion encourages loose coupling. High-level policies should not depend on low-level implementation details; both should depend on abstractions.
“Entities must depend on abstractions, not on concretions. High-level modules should not depend on low-level modules; both should depend on abstractions.”
Let’s see this through a database connection example:
class MySQLConnection
{
public function connect()
{
return 'Database connection established';
}
}
class PasswordReminder
{
private $dbConnection;
// Direct dependency on a concrete MySQLConnection:
public function __construct(MySQLConnection $dbConnection)
{
$this->dbConnection = $dbConnection;
}
}
Here, PasswordReminder (high-level business logic) is tightly bound to MySQLConnection (low-level infrastructure). If you ever migrate to PostgreSQL or want to run tests with an in-memory SQLite database, you can’t do so without rewriting PasswordReminder.
Now let’s invert the dependency using an abstraction:
interface DBConnectionInterface
{
public function connect();
}
class MySQLConnection implements DBConnectionInterface
{
public function connect()
{
return 'Database connection established';
}
}
class PasswordReminder
{
private $dbConnection;
// Depends on the abstraction (interface), not the concrete driver:
public function __construct(DBConnectionInterface $dbConnection)
{
$this->dbConnection = $dbConnection;
}
public function remind()
{
$connectionStatus = $this->dbConnection->connect();
return "Password reminder initiated. Connection status: " . $connectionStatus;
}
}
By introducing DBConnectionInterface, PasswordReminder no longer cares which specific database engine is running under the hood. Any class that fulfills the contract can be passed in.
Wrapping Up Link to heading
At first glance, SOLID principles can feel abstract or purely theoretical. But as you build larger systems, their practical value becomes crystal clear.
To me, SOLID is synonymous with organization, cleanliness, and standardization. Its ultimate purpose is transforming the natural chaos of a codebase into a welcoming, structured environment where changes are predictable and safe.
Thank you for reading! In the next post of this series, we will dive deeper into Dependency Injection, exploring IoC containers and object lifecycles. See you there!