SOLID is an acronym for five design principles that make object-oriented code more maintainable, flexible, and scalable. These are not academic theories — they are practical guidelines that experienced engineers apply daily to keep codebases from turning into unmaintainable messes.
A class should have one, and only one, reason to change.
Every class should own a single piece of functionality. If you need the word “and” to describe what a class does, it probably does too much. This makes classes easier to test, understand, and modify without cascading side effects.
// Good: Each class has one job
public interface UserService {
User updateEmail(Long userId, String newEmail);
}
public interface AuthService {
User login(String email, String password);
User signUp(User user);
}
// Bad: One class doing everything
public class UserManager {
public User updateEmail(...) { /* user logic */ }
public User login(...) { /* auth logic */ }
public void sendWelcomeEmail(...) { /* notification logic */ }
}
# Good: Separate responsibilities
class UserService:
def update_email(self, user_id: int, new_email: str) -> User:
...
class AuthService:
def login(self, email: str, password: str) -> User:
...
# Bad: God class
class UserManager:
def update_email(self, ...): ...
def login(self, ...): ...
def send_welcome_email(self, ...): ...
A quick test: if a class changes for more than one reason (database schema change vs. business rule change), split it.
Classes should be open for extension but closed for modification.
When requirements change, you should extend behavior by adding new code — not by editing existing, tested code. Interfaces and polymorphism are your primary tools here. This prevents regressions in stable code.
# Open for extension: add new notification types without modifying existing ones
from abc import ABC, abstractmethod
class NotificationSender(ABC):
@abstractmethod
def send(self, user: dict, message: str) -> bool:
pass
class EmailSender(NotificationSender):
def send(self, user: dict, message: str) -> bool:
# send via SMTP
return True
class SmsSender(NotificationSender):
def send(self, user: dict, message: str) -> bool:
# send via Twilio
return True
# Adding Slack notifications? Just add a new class. No existing code changes.
class SlackSender(NotificationSender):
def send(self, user: dict, message: str) -> bool:
# send via Slack API
return True
Avoid if-else chains that check types — use polymorphism instead.
Subtypes must be substitutable for their base types without breaking the program.
If code works with a parent class, it must also work with any child class without surprises. A subclass should honor the contract of its parent — same inputs, compatible outputs, no unexpected exceptions. Violations force instanceof checks throughout the codebase, defeating the purpose of abstraction.
public interface Shape {
double area();
}
public class Rectangle implements Shape {
protected double width, height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() { return width * height; }
}
public class Square implements Shape {
private double side;
public Square(double side) { this.side = side; }
@Override
public double area() { return side * side; }
}
// Any Shape works here — no special cases needed
public double totalArea(List<Shape> shapes) {
return shapes.stream()
.mapToDouble(Shape::area)
.sum();
}
Notice Square is not a subclass of Rectangle. Making Square extend Rectangle violates LSP because setting width independently of height breaks Square’s invariant. Use composition or separate types instead.
No client should be forced to depend on methods it does not use.
Prefer many small, focused interfaces over one large general-purpose interface. When a class implements an interface it does not fully need, it ends up with dead methods — empty stubs or thrown exceptions that signal a design flaw.
# Bad: Fat interface forces unnecessary implementation
class Worker(ABC):
@abstractmethod
def write_code(self): pass
@abstractmethod
def review_code(self): pass
@abstractmethod
def manage_team(self): pass # Not every worker manages
# Good: Segregated interfaces
class Coder(ABC):
@abstractmethod
def write_code(self): pass
class Reviewer(ABC):
@abstractmethod
def review_code(self): pass
class Manager(ABC):
@abstractmethod
def manage_team(self): pass
# A tech lead implements what they actually do
class TechLead(Coder, Reviewer, Manager):
def write_code(self): ...
def review_code(self): ...
def manage_team(self): ...
# A junior dev only codes
class JuniorDev(Coder):
def write_code(self): ...
If a class has to implement a method with raise NotImplementedError, your interface is too broad.
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Business logic should never directly instantiate its dependencies. Instead, depend on interfaces and let the framework (Spring, Django, etc.) inject the concrete implementation. This keeps your code testable and decoupled — swapping a database or external service becomes a configuration change, not a rewrite.
// Abstraction
public interface PaymentGateway {
boolean charge(String customerId, double amount);
}
// Low-level implementation
@Service
public class StripeGateway implements PaymentGateway {
@Override
public boolean charge(String customerId, double amount) {
// Stripe API call
return true;
}
}
// High-level module depends on abstraction, not Stripe directly
@Service
public class OrderService {
private final PaymentGateway paymentGateway;
@Autowired
public OrderService(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
public boolean checkout(String customerId, double total) {
return paymentGateway.charge(customerId, total);
}
}
Switching from Stripe to PayPal? Create a new PayPalGateway class and update your DI configuration. OrderService never changes.
SOLID principles work together. SRP keeps classes focused. OCP lets you extend without risk. LSP ensures substitutability. ISP keeps interfaces lean. DIP decouples your architecture. Apply them pragmatically — they are guidelines, not dogma. Start with SRP and DIP; the rest follow naturally as your design matures.