Building Maintainable Software: The Real World Guide to SOLID Principles (That Saved My Career)
There was a time early in my career when every new feature felt like defusing a bomb, one wrong move and the whole application would break.
If you’ve ever opened a legacy codebase and instantly wanted to close your laptop, this article is for you.
The SOLID principles quite
literally saved my career.
They turned my spaghetti code into structured, testable, and
actually enjoyable software.
{getToc} $title={Table of Contents} $count={true} $expanded={false}
What Are SOLID Principles?
SOLID is an acronym for five design principles that help developers build maintainable, scalable, and flexible software.
| Letter | Principle | Meaning |
|---|---|---|
| S | Single Responsibility Principle | Every class should have one and only one reason to change. |
| O | Open/Closed Principle | Classes should be open for extension, but closed for modification. |
| L | Liskov Substitution Principle | Subclasses should be replaceable for their parent classes. |
| I | Interface Segregation Principle | Don’t force classes to implement what they don’t need. |
| D | Dependency Inversion Principle | Depend on abstractions, not on concrete implementations. |
Why SOLID Matters in the Real World
You will rarely get fired for missing a semicolon.
But you will lose credibility
when your team can’t maintain or extend your code.
The SOLID principles solve this by:
- Making your code easier to change without breaking things.
- Helping teams collaborate without stepping on each other’s toes.
- Allowing you to add features faster with fewer bugs.
1. Single Responsibility Principle (SRP)
“A class should have only one reason to change.”
Imagine a restaurant where the chef also handles deliveries,
accounting, and social media.
If anything changes in marketing or finances, the poor chef has to
change too. That’s a mess.
The same goes for code.
Before (Violation): Bad code
public class OrderService {
public void placeOrder(Order order) {
// Save order to DB
System.out.println("Saving order...");
// Send confirmation email
System.out.println("Sending confirmation email...");
}
}
This class has two responsibilities, managing orders and sending emails.
After (Following SRP): Fixed code
public class OrderService {
private final EmailService emailService;
public OrderService(EmailService emailService) {
this.emailService = emailService;
}
public void placeOrder(Order order) {
System.out.println("Saving order...");
emailService.sendConfirmation(order);
}
}
public class EmailService {
public void sendConfirmation(Order order) {
System.out.println("Sending email to customer...");
}
}
Now, OrderService only handles orders, while EmailService takes care of emails. Both can change independently, that’s maintainability.
SRP makes each class focused on one responsibility.
2. Open/Closed Principle (OCP)
You shouldn’t need to rebuild a car’s engine just to add Bluetooth.“Software entities should be open for extension, but closed for modification.”
Likewise, your class should be extendable without altering the existing code.
public class PaymentProcessor {
public void process(String type) {
if (type.equals("CREDIT")) {
System.out.println("Processing credit card payment...");
} else if (type.equals("PAYPAL")) {
System.out.println("Processing PayPal payment...");
}
}
}
If a new payment type comes in say “CRYPTO”, you must modify this class. That’s risky and prone to errors
After (Following OCP) Fixed code
public interface Payment {
void process();
}
public class CreditCardPayment implements Payment {
public void process() {
System.out.println("Processing credit card payment...");
}
}
public class PayPalPayment implements Payment {
public void process() {
System.out.println("Processing PayPal payment...");
}
}
public class PaymentProcessor {
public void processPayment(Payment payment) {
payment.process();
}
}
Now you can simply create a CryptoPayment class without touching the existing code!
| Criteria | Before (Bad) | After (Good) |
|---|---|---|
| New payment type | Modify existing class | Add new class |
| Risk of breaking code | High | Low |
| Testability | Poor | Excellent |
3. Liskov Substitution Principle (LSP)
“Subclasses should be replaceable for their base classes without breaking functionality.”
If your boss sends a dog to a “Pet” daycare, the daycare should still work fine because a dog is a type of pet.
Before (Violation): Bad code
public class Bird {
public void fly() {
System.out.println("Flying...");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can’t fly!");
}
}
When code expects a Bird, it might call .fly(), but the Penguin breaks this contract.
After (Following LSP): Fixed code
public class Bird {
public void move() {
System.out.println("Moving...");
}
}
public interface Flyable {
void fly();
}
public class Eagle extends Bird implements Flyable {
public void fly() {
System.out.println("Eagle flying high...");
}
}
public class Penguin extends Bird {
@Override
public void move() {
System.out.println("Penguin waddling on ice...");
}
}
Now both classes respect the behavior expected of their type, no broken promises.
4. Interface Segregation Principle (ISP)
Imagine being hired as a chef, but your job description includes plumbing, accounting, and babysitting. Ridiculous, right?“Don’t force clients to depend on interfaces they don’t use.”
Before (Violation): Bad code
public interface Worker {
void cook();
void serve();
void fixPlumbing();
}
public class Chef implements Worker {
public void cook() { System.out.println("Cooking..."); }
public void serve() { System.out.println("Serving food..."); }
public void fixPlumbing() {
throw new UnsupportedOperationException("I’m not a plumber!");
}
}
After (Following ISP): Fixed code
public interface Cook {
void cook();
}
public interface Server {
void serve();
}
public class Chef implements Cook {
public void cook() {
System.out.println("Cooking delicious meals...");
}
}
Now each role does only what’s relevant. Smaller, focused interfaces are easier to maintain.
| Concept | Before | After |
|---|---|---|
| Interface size | Big and messy | Small and precise |
| Unused methods | Many | None |
| Flexibility | Low | High |
5. Dependency Inversion Principle (DIP)
You plug your phone charger into a socket, not directly into the power grid.“High level modules should not depend on low level modules. Both should depend on abstractions.”
The socket (abstraction) makes your system flexible and replaceable.
public class NotificationService {
private final EmailSender emailSender = new EmailSender();
public void notifyUser(String message) {
emailSender.send(message);
}
}
If tomorrow you need to send SMS, you have to modify this class which is not great.
After (Following DIP: Fixed code)
public interface MessageSender {
void send(String message);
}
public class EmailSender implements MessageSender {
public void send(String message) {
System.out.println("Sending Email: " + message);
}
}
public class SMSSender implements MessageSender {
public void send(String message) {
System.out.println("Sending SMS: " + message);
}
}
public class NotificationService {
private final MessageSender sender;
public NotificationService(MessageSender sender) {
this.sender = sender;
}
public void notifyUser(String message) {
sender.send(message);
}
}
Now NotificationService depends only on the abstraction, not the concrete class. You can easily switch from email to SMS, or even WhatsApp, without touching existing logic.
How the principles work together
| Principle | Core Idea | Benefit |
|---|---|---|
| SRP | One class, one responsibility | Easier to test & maintain |
| OCP | Extend without modifying | Adds flexibility |
| LSP | Subtypes behave like base types | Prevents broken inheritance |
| ISP | Split big interfaces | Avoids unnecessary dependencies |
| DIP | Depend on abstractions | Decouples high & low level code |