SOLID Principles - with Examples

Bhuwan Prasad Upadhyay | Jan 24, 2026 min read

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.