Hi all..👋
I studied SOLID principles today. I'll try to explain SOLID principles in the simplest way possible.
What are SOLID principles?
Solid is a set of 5 design principles that help us write cleaner and maintainable code.
SOLID stands for :
- S - Single Responsibility
- O - Open/Closed Principle
- L - Liskov Substitution
- I - Interface Segregation
- D - Dependency Inversion
S - Single Responsibility Principle
Single Responsibility Principle states that a class should have only one responsibility to handle.
Analogy :
A chef should focus on cooking, not taking orders, managing inventory, and handling payments. Everyone in a restaurant has a specific role.
The same idea applies to our code.
❌Bad Example:
In the example below, the EmployeeService class is doing too many things.
class EmployeeService {
public void saveEmployee(Employee employee) {
// save employee
}
public void generateReport() {
// generate report
}
public void sendEmail(Employee employee) {
// send email
}
}
As per the above mentioned example, the EmployeeService class handles too many responsibilites like managing employees. generate report and sending email.
✅Good Example:
class EmployeeService {
public void saveEmployee(Employee employee) {
// save employee }
}
class ReportService {
public void generateReport() {
// generate report
}
}
class EmailService {
public void sendEmail(Employee employee) {
// send email
}
}
Now each class has a clear purpose.
Why is this Better?
Easier to understand
Changes in one feature are less likely to affect others
For example: if email functionality changes, we only need to modify the EmailService class without touching employee or reporting logic.
O - Open/Closed Principle
We should be able to add new features without changing code that already works.
Analogy:
A library can add new books without rebuilding the entire library.
❌Bad example:
Suppose we have a notification service.
class NotificationService {
public void sendNotification(String type) {
if (type.equals("EMAIL")) {
System.out.println("Sending Email");
} else if (type.equals("SMS")) {
System.out.println("Sending SMS");
}
}
}
Now if we need to add WhatsApp notifications support we will have to modify the existing class and add another condition.
else if (type.equals("WHATSAPP")) {
System.out.println("Sending WhatsApp Message");
}
Every new notification type requires changing existing code.
✅Good example:
Create an interface that all notification types can follow.
interface Notification {
void send();
}
class EmailNotification implements Notification {
public void send() {
System.out.println("Sending Email");
}
}
class SmsNotification implements Notification {
public void send() {
System.out.println("Sending SMS");
}
}
Adding WhatsApp support becomes easy.
class WhatsAppNotification implements Notification {
public void send() {
System.out.println("Sending WhatsApp Message");
}
}
No existing classes need to be modified.
Why is this Better?
Existing code remains stable.
New features can be added easily.
Makes the application easier to extend.
L- Liskov Substitution
We should be able to replace the object of a subclass with the object of a parent class without changing anything in the program.
Analogy:
A hotel room accepts a key card. Replacing one valid key card with another should still open the door. A replacement should not change the expected behavior.
❌Bad Example:
class Bird {
void fly() {
System.out.println("Flying");
}
}
class Sparrow extends Bird {
void fly() {
System.out.println("Sparrow flying");
}
}
class Penguin extends Bird {
void fly() {
throw new RuntimeException("Penguin can't fly");
}
}
Penguin cannot fly.
Sparrow can be used in place of bird but Penguin cannot fly and this creates problems.
✅Good example:
class Bird {
void fly() {
System.out.println("Sparrow flying");
}
}
class NonFlyingBird extends Bird {
void Swim() {
System.out.println("Swim");
}
}
class Sparrow extends Bird {
}
class Penguin extends NonFlyingBird {
}
Now:
Sparrow is a flying bird.
Penguin is a bird but not a flying bird.
No broken expectations.
I- Interface Segregation
Don't force a class to implement methods it doesn't need.
Analogy:
A person borrowing books should not be forced to borrow magazines as well.
One big interface forcing everything:
interface Restaurant {
void takeOrder();
void cookFood();
void serveFood();
void manageBilling();
}
Now different staff are forced to implement things they don’t need:
class Waiter implements Restaurant {
public void takeOrder() {
System.out.println("Taking order");
}
public void cookFood() {
// ❌ Not my job
}
public void serveFood() {
System.out.println("Serving food");
}
public void manageBilling() {
// ❌ Not my job
}
}
class Chef implements Restaurant {
public void takeOrder() {
// ❌ Not my job
}
public void cookFood() {
System.out.println("Cooking food");
}
public void serveFood() {
// ❌ Not my job
}
public void manageBilling() {
// ❌ Not my job
}
}
Problem:
Classes depend on methods they don’t use
Forced implementations
confusing design
✅Good Example:
Split into focused interfaces:
interface OrderTaker {
void takeOrder();
}
interface Cook {
void cookFood();
}
interface Server {
void serveFood();
}
interface BillManager {
void manageBilling();
}
Now each role implements only what it needs:
class Waiter implements OrderTaker, Server {
public void takeOrder() {
System.out.println("Taking order");
}
public void serveFood() {
System.out.println("Serving food");
}
}
class Chef implements Cook {
public void cookFood() {
System.out.println("Cooking food");
}
}
class Cashier implements BillManager {
public void manageBilling() {
System.out.println("Handling billing");
}
}
result: No unnecessary methods and clean responsibilities.
D- Dependency Inversion
High-level modules should NOT depend on low-level modules.
Both should depend on abstractions (interfaces).
Analogy: A phone depends on USB-C port instead of a specific charger brand.
❌Bad example:
class DebitCard{
void pay(){
}
}
class CreditCard{
void pay(){
}
}
class Shop{
DebitCard card;
public Shop(DebitCard card) {
this.card = card;
}
void payment(DebitCard card){
card.pay();
}
}
Here Debit card is tightly coupled.
✅Good example:
interface BankCard{
void pay();
}
class DebitCard implements BankCard{
public void pay(){
}
}
class CreditCard implements BankCard{
public void pay(){
}
}
class Shop{
BankCard card;
public Shop(BankCard card) {
this.card = card;
}
void payment(DebitCard card){
card.pay();
}
}
Why is this better?
Shop does not depend on concrete classes
We can add new cards without changing Shop
Easy to extend, easy to test
Loosely coupled design