SOLID is a set of five simple rules for designing object-oriented code that’s easier to change and test.
- S: Single Responsibility Principle (SRP) : one responsibility per class.
- O: Open/Closed Principle (OCP) : add new behavior by extending, avoid changing tested code.
- L: Liskov Substitution Principle (LSP) : subclasses must behave like their base types.
- I: Interface Segregation Principle (ISP) : prefer small, focused interfaces.
- D: Dependency Inversion Principle (DIP) : depend on abstractions, not concretes.
1 - Single Responsibility Principle (SRP)
Keep each class focused on one job. If a class has two reasons to change, split it.
Bad example: a class that handles both payments and refunds.
public class PaymentService {
private final String sender;
private final String receiver;
public PaymentService(String sender, String receiver) {
this.sender = sender;
this.receiver = receiver;
}
public String processPayment(double amount) {
return "Payment of $" + amount + " processed successfully.";
}
public String refundPayment(double amount) {
return "Refund of $" + amount + " processed successfully.";
}
}
Better: separate responsibilities
public class Payment {
private final String sender;
private final String receiver;
public Payment(String sender, String receiver) {
this.sender = sender;
this.receiver = receiver;
}
}
public class PaymentProcessor {
public String processPayment(Payment payment, double amount) {
return "Payment of $" + amount + " processed successfully.";
}
}
public class RefundProcessor {
public String refundPayment(Payment payment, double amount) {
return "Refund of $" + amount + " processed successfully.";
}
}
Usage:
Payment payment = new Payment("Alice", "Bob");
PaymentProcessor paymentProcessor = new PaymentProcessor();
System.out.println(paymentProcessor.processPayment(payment, 100.0));
RefundProcessor refundProcessor = new RefundProcessor();
System.out.println(refundProcessor.refundPayment(payment, 50.0));
2 - Open/Closed Principle (OCP)
Open for extension, closed for modification. Add new behavior by adding types, not by changing existing code.
Bad example: an Account class that calculates interest differently based on account type using conditionals.
public class Account {
public enum AccountType { SAVINGS, CHECKING }
private AccountType accountType;
public Account(AccountType accountType) {
this.accountType = accountType;
}
public double calculateInterest(double balance) {
if (accountType == AccountType.SAVINGS) {
return balance * 0.04;
} else if (accountType == AccountType.CHECKING) {
return balance * 0.02;
}
return 0;
}
}
Better: use polymorphism
public abstract class Account {
public abstract double calculateInterest(double balance);
}
public class SavingsAccount extends Account {
@Override
public double calculateInterest(double balance) {
return balance * 0.04;
}
}
public class CheckingAccount extends Account {
@Override
public double calculateInterest(double balance) {
return balance * 0.02;
}
}
Usage:
Account savings = new SavingsAccount();
System.out.println("Savings Interest: " + savings.calculateInterest(1000.0));
Account checking = new CheckingAccount();
System.out.println("Checking Interest: " + checking.calculateInterest(1000.0));
3 - Liskov Substitution Principle (LSP)
Subtypes should work anywhere their base type is expected. Don’t break base class contracts. If subclass changes behavior in a way that clients relying on the base class break, LSP is violated.
Bad example: a base Payment type exposes a refund() operation that callers expect to work. A subclass NonRefundablePayment overrides refund() to throw an exception, breaking the base contract — this violates LSP.
public class Payment {
protected final String id;
protected final double amount;
public Payment(String id, double amount) {
this.id = id;
this.amount = amount;
}
public void refund() {
// default refund behavior
System.out.println("Refund processed for payment " + id);
}
}
public class NonRefundablePayment extends Payment {
public NonRefundablePayment(String id, double amount) {
super(id, amount);
}
@Override
public void refund() {
throw new UnsupportedOperationException("This payment type cannot be refunded.");
}
}
Better: introduce a Refundable interface. Only classes that truly support refunds implement it. Clients work with Refundable when they need refund capability.
public interface Refundable {
void refund();
}
public class StandardPayment extends Payment implements Refundable {
public StandardPayment(String id, double amount) {
super(id, amount);
}
@Override
public void refund() {
// perform refund logic
System.out.println("Refund processed for payment " + getId());
}
}
public class GiftCardPayment extends Payment {
public GiftCardPayment(String id, double amount) {
super(id, amount);
}
// no refund method — does not implement Refundable
}
Usage:
Refundable payment = new StandardPayment("123", 100.0);
payment.refund(); // works fine
Payment nonRefundable = new GiftCardPayment("456", 50.0);
// nonRefundable.refund(); // compile-time error — no refund method
4 - Interface Segregation Principle (ISP)
Don’t force classes to implement interfaces they don’t use. Split large interfaces into smaller, specific ones.
Bad example: forcing a Printer class to implement methods it doesn’t need.
public interface Printer {
void print(Document document);
void scan(Document document);
void fax(Document document);
}
public class SimplePrinter implements Printer {
@Override
public void print(Document document) {
// printing logic
}
@Override
public void scan(Document document) {
throw new UnsupportedOperationException("Scan not supported.");
}
@Override
public void fax(Document document) {
throw new UnsupportedOperationException("Fax not supported.");
}
}
Better: split into smaller interfaces
public interface Printer {
void print(Document document);
}
public interface Scanner {
void scan(Document document);
}
public interface Fax {
void fax(Document document);
}
public class SimplePrinter implements Printer {
@Override
public void print(Document document) {
// printing logic
}
}
public class MultiFunctionPrinter implements Printer, Scanner, Fax {
@Override
public void print(Document document) {
// printing logic
}
@Override
public void scan(Document document) {
// scanning logic
}
@Override
public void fax(Document document) {
// faxing logic
}
}
Usage:
Document doc = new Document("Sample");
Printer p = new SimplePrinter();
p.print(doc);
MultiFunctionPrinter mfp = new MultiFunctionPrinter();
mfp.print(doc);
mfp.scan(doc);
mfp.fax(doc);
5 - Dependency Inversion Principle (DIP)
High-level modules should depend on abstractions, not concrete implementations. Inject dependencies so implementations can be swapped and tested easily.
Bad: high-level class creates concrete dependency.
public class MySQLDatabase {
public void saveUser(User user) {
// save logic
}
}
public class UserService {
private MySQLDatabase database;
public UserService() {
this.database = new MySQLDatabase();
}
public void registerUser(User user) {
database.saveUser(user);
}
}
Better: depend on an interface.
public interface Database {
void saveUser(User user);
}
public class MySQLDatabase implements Database {
@Override
public void saveUser(User user) {
// save logic
}
}
public class UserService {
private final Database database;
public UserService(Database database) {
this.database = database;
}
public void registerUser(User user) {
database.saveUser(user);
}
}
Usage:
Database db = new MySQLDatabase();
UserService userService = new UserService(db);
userService.registerUser(new User("john_doe", "password123"));
Conclusion
Following SOLID makes code easier to maintain, extend, and test by encouraging small, focused types, clear abstractions, and loose coupling — ideal for medium-to-large systems and teams. It improves reasoning about behavior and simplifies unit testing, but it’s not free.
Trade-offs
- More boilerplate and indirection — expect extra classes, interfaces, and files; initial implementation and navigation cost increases.
- Risk of over‑engineering — for small or throwaway projects, strict SOLID can add unnecessary complexity.
- Higher cognitive overhead for newcomers — abstractions and layers make call paths longer and require discipline to keep designs simple.