SOLID Principles: Your Code's Best Friends
Building Strong Foundations for Software Development

I'm a full stack developer from Ghana. I'm passionate about helping others gain their ground in tech, specifically web development.
Picture this: You're six months deep into a project, and your boss asks for "just a small feature addition." You crack open your codebase, and... yikes. What seemed like clean code six months ago now looks like a house of cards built during an earthquake. Sound familiar?
Enter SOLID principles—five design principles that act like your code's personal trainer, keeping it flexible, maintainable, and ready for whatever curveball comes next. These aren't just academic concepts gathering dust in computer science textbooks; they're battle-tested guidelines that separate the "it works" code from the "it works AND I won't hate myself in six months" code.
Let's dive into each principle with examples that'll make you go "Oh, THAT's why my code was such a pain to modify!"
Single Responsibility Principle (SRP): The Swiss Army Knife Problem
The Rule: A class should have one, and only one, reason to change.
Think about a Swiss Army knife. Sure, it has a blade, scissors, screwdriver, and bottle opener all in one tool. But when the blade gets dull, you can't just replace the blade—you've got to replace the whole thing. That's exactly what happens when your classes try to do everything.
The Problem Child
class UserManager {
public function authenticateUser(string $email, string $password): bool {
// Check credentials against database
$user = $this->findUserByEmail($email);
return password_verify($password, $user->getPasswordHash());
}
public function sendWelcomeEmail(User $user): void {
// Email sending logic
mail($user->getEmail(), "Welcome!", "Thanks for joining!");
}
public function generateUserReport(User $user): string {
// Generate PDF report
return "User Report for " . $user->getName();
}
public function logUserActivity(User $user, string $activity): void {
// Write to log file
file_put_contents('activity.log', $user->getId() . ': ' . $activity);
}
}
This UserManager is like that friend who insists on being the DJ, bartender, and party planner all at once. When your email provider changes their API, when you need to switch logging systems, or when report formatting requirements change, you're modifying the same class. Recipe for bugs!
The Clean Solution
class AuthenticationService {
public function authenticate(string $email, string $password): bool {
$user = $this->findUserByEmail($email);
return password_verify($password, $user->getPasswordHash());
}
}
class EmailService {
public function sendWelcomeEmail(User $user): void {
mail($user->getEmail(), "Welcome!", "Thanks for joining!");
}
}
class ReportGenerator {
public function generateUserReport(User $user): string {
return "User Report for " . $user->getName();
}
}
class ActivityLogger {
public function logActivity(User $user, string $activity): void {
file_put_contents('activity.log', $user->getId() . ': ' . $activity);
}
}
Now each class has a single job and does it well. Need to change how emails are sent? Touch only EmailService. Want to switch from file logging to database logging? Only ActivityLogger needs attention.
Pro tip: If you can describe what your class does without using the word "and," you're probably on the right track.
Open/Closed Principle (OCP): Building with Lego Blocks
The Rule: Classes should be open for extension but closed for modification.
Remember playing with Lego blocks? You could build a house, then add a garage without tearing down the original structure. That's the dream of OCP—adding new features by stacking on new pieces, not by ripping apart what already works.
The Modification Nightmare
class ShippingCalculator {
public function calculateShipping(string $type, float $weight): float {
if ($type === 'standard') {
return $weight * 1.5;
} elseif ($type === 'express') {
return $weight * 2.5;
} elseif ($type === 'overnight') { // Oops, had to modify this method!
return $weight * 5.0;
}
throw new InvalidArgumentException('Unknown shipping type');
}
}
Every time marketing dreams up a new shipping option, you're back in here adding another elseif. This method is growing like a teenager—awkwardly and in all directions.
The Extension Paradise
interface ShippingMethod {
public function calculateCost(float $weight): float;
public function getName(): string;
}
class StandardShipping implements ShippingMethod {
public function calculateCost(float $weight): float {
return $weight * 1.5;
}
public function getName(): string {
return 'Standard Shipping';
}
}
class ExpressShipping implements ShippingMethod {
public function calculateCost(float $weight): float {
return $weight * 2.5;
}
public function getName(): string {
return 'Express Shipping';
}
}
class ShippingCalculator {
public function calculateShipping(ShippingMethod $method, float $weight): float {
return $method->calculateCost($weight);
}
}
Want to add drone delivery? Create a DroneShipping class. The calculator doesn't need to know or care—it just calls the interface method. Your existing code stays untouched, and QA loves you for not breaking anything.
Liskov Substitution Principle (LSP): The Perfect Stand-In
The Rule: Objects of a superclass should be replaceable with objects of a subclass without breaking the application.
Think of this like hiring an understudy for a play. If the lead actor calls in sick, the understudy should be able to step in without the audience noticing the play has completely changed genre from drama to comedy.
The Problematic Inheritance
The classic Rectangle/Square example is often confusing, so let's use something more practical:
class Bird {
public function fly(): string {
return "Flying high!";
}
}
class Sparrow extends Bird {
public function fly(): string {
return "Sparrow flying at 20 mph!";
}
}
class Penguin extends Bird {
public function fly(): string {
throw new Exception("Penguins can't fly!"); // Uh oh...
}
}
// This breaks when we use a Penguin
function makeBirdFly(Bird $bird): string {
return $bird->fly(); // Boom! Exception with Penguin
}
Poor penguin breaks our program because it can't live up to the Bird contract.
The Proper Hierarchy
abstract class Bird {
abstract public function move(): string;
abstract public function makeSound(): string;
}
class FlyingBird extends Bird {
public function move(): string {
return $this->fly();
}
protected function fly(): string {
return "Flying!";
}
public function makeSound(): string {
return "Tweet!";
}
}
class Sparrow extends FlyingBird {
protected function fly(): string {
return "Sparrow soaring at 20 mph!";
}
}
class Penguin extends Bird {
public function move(): string {
return "Waddling adorably!";
}
public function makeSound(): string {
return "Squawk!";
}
}
// Now this works with any Bird
function makeBirdMove(Bird $bird): string {
return $bird->move(); // Works for both flying and non-flying birds!
}
Now every bird can move and make sounds in their own way, and we can substitute any bird without breaking our program.
Interface Segregation Principle (ISP): No Bloated Contracts
The Rule: Don't force classes to depend on interfaces they don't use.
Imagine signing a gym membership contract that also requires you to use the pool, attend yoga classes, and buy protein shakes—even if you just want to use the treadmill. That's what fat interfaces do to your code.
The Everything Interface
interface SmartDevice {
public function turnOn(): void;
public function turnOff(): void;
public function connectToWifi(string $network, string $password): void;
public function playMusic(string $song): void;
public function adjustVolume(int $level): void;
public function recordVideo(): void;
public function takePicture(): void;
public function makePhoneCall(string $number): void;
}
class SmartLight implements SmartDevice {
public function turnOn(): void { /* Light-specific logic */ }
public function turnOff(): void { /* Light-specific logic */ }
public function connectToWifi(string $network, string $password): void { /* Makes sense */ }
// These don't make sense for a light!
public function playMusic(string $song): void {
throw new BadMethodCallException("Lights don't play music!");
}
public function adjustVolume(int $level): void {
throw new BadMethodCallException("Lights don't have volume!");
}
public function recordVideo(): void {
throw new BadMethodCallException("Lights don't record video!");
}
// ... more nonsensical methods
}
Our poor smart light is forced to pretend it can do things it physically cannot do.
The Focused Interfaces
interface Controllable {
public function turnOn(): void;
public function turnOff(): void;
}
interface NetworkConnectable {
public function connectToWifi(string $network, string $password): void;
}
interface AudioDevice {
public function playMusic(string $song): void;
public function adjustVolume(int $level): void;
}
interface Camera {
public function recordVideo(): void;
public function takePicture(): void;
}
interface Phone {
public function makePhoneCall(string $number): void;
}
class SmartLight implements Controllable, NetworkConnectable {
public function turnOn(): void { /* Perfect fit */ }
public function turnOff(): void { /* Perfect fit */ }
public function connectToWifi(string $network, string $password): void { /* Makes sense */ }
}
class Smartphone implements Controllable, NetworkConnectable, AudioDevice, Camera, Phone {
// Implements all methods because a smartphone actually does all these things
}
Now each device only implements what it actually can do. Much cleaner!
Dependency Inversion Principle (DIP): Don't Depend on Details
The Rule: High-level modules shouldn't depend on low-level modules. Both should depend on abstractions.
This is like being a movie director who says "I need someone who can act" rather than "I need Brad Pitt specifically." It gives you flexibility and makes testing way easier.
The Tightly Coupled Mess
class OrderProcessor {
private MySQLDatabase $database;
private SMTPEmailer $emailer;
public function __construct() {
$this->database = new MySQLDatabase(); // Hard dependency!
$this->emailer = new SMTPEmailer(); // Another hard dependency!
}
public function processOrder(Order $order): void {
// Process order logic
$this->database->save($order);
$this->emailer->sendConfirmation($order->getCustomerEmail());
}
}
This OrderProcessor is like a diva actor who will only work with specific co-stars. Want to use PostgreSQL instead of MySQL? Tough luck, time to rewrite the class.
The Flexible Approach
interface DatabaseInterface {
public function save(Order $order): void;
}
interface EmailerInterface {
public function sendConfirmation(string $email): void;
}
class OrderProcessor {
private DatabaseInterface $database;
private EmailerInterface $emailer;
public function __construct(DatabaseInterface $database, EmailerInterface $emailer) {
$this->database = $database; // Flexible!
$this->emailer = $emailer; // Adaptable!
}
public function processOrder(Order $order): void {
$this->database->save($order);
$this->emailer->sendConfirmation($order->getCustomerEmail());
}
}
// Now you can inject any implementation
$processor = new OrderProcessor(
new PostgreSQLDatabase(), // Or MySQL, or MongoDB, or...
new SendGridEmailer() // Or SMTP, or SES, or...
);
Testing becomes a breeze too—just inject mock objects instead of dealing with real databases and email services.
Putting It All Together: The SOLID Foundation
SOLID principles aren't just academic exercises—they're your best defense against the chaos of changing requirements, growing teams, and the inevitable "Can we just add one more thing?" requests.
Here's when SOLID really shines:
Legacy code refactoring: When you inherit a codebase that makes you question your career choices
Team collaboration: When multiple developers need to work on the same system without stepping on each other's toes
Testing: When you actually want your unit tests to be unit tests, not integration nightmares
Scaling: When your "simple" app suddenly needs to handle 10x the traffic
Maintenance: When you want to sleep peacefully instead of getting 3 AM production alerts
Remember, these principles are guidelines, not religious commandments. Sometimes you'll break them for good reasons—performance, simplicity, or tight deadlines. The key is making conscious decisions rather than stumbling into unmaintainable code.
Start small. Pick one principle and apply it to your next feature. Your future self (and your teammates) will thank you. After all, we spend way more time reading code than writing it—might as well make it a pleasant read!
Ready to SOLID-ify your codebase? Start with the Single Responsibility Principle—it's the gateway drug to better software design. Trust me, once you start, you won't want to stop.