Skip to main content
Java advanced Lesson 24 of 58

SOLID Principles in Java

Master the five SOLID design principles with concrete Java examples — the foundation of maintainable, extensible object-oriented code.

SOLID is an acronym for five design principles that make OOP code easier to maintain, extend, and test. They were formalised by Robert C. Martin and are foundational to professional Java engineering. Each principle addresses a specific way that code becomes hard to change over time, and each provides a concrete rule for avoiding that problem.

S — Single Responsibility Principle

A class should have one, and only one, reason to change.

A class that handles persistence, email sending, and PDF generation will need to change whenever any of those three concerns changes. Those changes are unrelated to each other, so they shouldn’t be in the same place. Classes with a single, well-defined responsibility are easier to test in isolation, easier to understand, and far less likely to break unrelated functionality when they change.

// BAD — three unrelated reasons to change: data model, persistence, and notification
public class Order {
    private List<String> items;
    private double total;

    public void addItem(String item, double price) { /* ... */ }

    public void saveToDatabase() { /* SQL INSERT — changes when DB schema changes */ }

    public void sendConfirmationEmail() { /* SMTP logic — changes when email provider changes */ }

    public String generateInvoicePdf() { /* PDF rendering — changes when invoice format changes */ }
}

Split along responsibility boundaries so each class has exactly one reason to change:

// GOOD — each class has one responsibility and one reason to change

// Order: models the domain concept
public class Order {
    private final String id;
    private final List<OrderItem> items = new ArrayList<>();

    public Order(String id) { this.id = id; }

    public void addItem(String name, double price, int qty) {
        items.add(new OrderItem(name, price, qty));
    }

    public double getTotal() {
        return items.stream().mapToDouble(OrderItem::subtotal).sum();
    }

    public String getId()             { return id; }
    public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
}

// OrderRepository: owns all persistence logic
public class OrderRepository {
    private final DataSource dataSource;
    public OrderRepository(DataSource dataSource) { this.dataSource = dataSource; }

    public void save(Order order) {
        // all SQL logic lives here — Order never knows about databases
    }
}

// OrderNotifier: owns all email logic
public class OrderNotifier {
    private final EmailService emailService;
    public OrderNotifier(EmailService emailService) { this.emailService = emailService; }

    public void sendConfirmation(Order order, String customerEmail) {
        // all email logic lives here
    }
}

// InvoiceGenerator: owns all PDF rendering logic
public class InvoiceGenerator {
    public byte[] generatePdf(Order order) {
        // all PDF rendering logic lives here
        return new byte[0];
    }
}

O — Open/Closed Principle

Software entities should be open for extension but closed for modification.

Adding a new feature should not require editing existing, working, tested code. Every edit to existing code is a risk — you might introduce a regression. The solution is to design extension points up front (interfaces, abstract methods) so new behavior is added by creating new classes rather than modifying old ones.

// BAD — adding a new discount type requires editing this tested method every time
public class PriceCalculator {
    public double calculate(Order order, String discountType) {
        double base = order.getTotal();
        if (discountType.equals("PERCENTAGE")) return base * 0.9;
        if (discountType.equals("FLAT"))       return base - 10;
        if (discountType.equals("BOGO"))       return base / 2; // ← every new type forces an edit
        return base;
    }
}

Use a polymorphic abstraction instead — new discount types extend the system without modifying it:

// GOOD — new discount types are added as new classes; nothing existing is touched

public interface DiscountStrategy {
    double apply(double originalPrice);
    String description();
}

public class PercentageDiscount implements DiscountStrategy {
    private final double percentage;
    public PercentageDiscount(double percentage) { this.percentage = percentage; }

    @Override public double apply(double price) { return price * (1 - percentage / 100); }
    @Override public String description()       { return percentage + "% off"; }
}

public class FlatDiscount implements DiscountStrategy {
    private final double amount;
    public FlatDiscount(double amount) { this.amount = amount; }

    @Override public double apply(double price) { return Math.max(0, price - amount); }
    @Override public String description()       { return "$" + amount + " off"; }
}

// Adding BOGO: create a new class — zero changes to existing code
public class BogoDiscount implements DiscountStrategy {
    @Override public double apply(double price) { return price / 2; }
    @Override public String description()       { return "Buy one get one free"; }
}

// PriceCalculator never needs to change, no matter how many discount types are added
public class PriceCalculator {
    public double calculate(Order order, DiscountStrategy discount) {
        return discount.apply(order.getTotal());
    }
}

L — Liskov Substitution Principle

Objects of a subclass should be replaceable with objects of the parent class without altering program correctness.

If a subclass changes the behavior that callers rely on from the parent, code written for the parent breaks when given the subclass — the inheritance hierarchy is lying. The LSP says a subclass must honor the behavioral contract of its parent, not just its method signatures.

// BAD — Square violates Rectangle's invariant: setting width and height independently
public class Rectangle {
    protected double width, height;

    public void setWidth(double width)   { this.width = width; }
    public void setHeight(double height) { this.height = height; }
    public double area()                 { return width * height; }
}

public class Square extends Rectangle {
    // Square forces both dimensions to match — breaking the Rectangle contract
    @Override public void setWidth(double side)  { width = side; height = side; }
    @Override public void setHeight(double side) { width = side; height = side; }
}

// Code written for Rectangle breaks silently with a Square:
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(3);
System.out.println(r.area()); // Expected 15, got 9 — LSP violation

Fix by modelling the hierarchy correctly — don’t force a “is-a” relationship that isn’t behaviorally true:

// GOOD — independent classes sharing an interface; neither violates the other's contract
public interface Shape {
    double area();
}

public final class Rectangle implements Shape {
    private final double width, height;
    public Rectangle(double width, double height) { this.width = width; this.height = height; }
    @Override public double area() { return width * height; }
}

public final class Square implements Shape {
    private final double side;
    public Square(double side) { this.side = side; }
    @Override public double area() { return side * side; }
}

I — Interface Segregation Principle

Clients should not be forced to depend on interfaces they do not use.

A large “god interface” forces every implementor to provide stubs for methods they don’t support. Those stubs are dead code that misleads readers and throws UnsupportedOperationException at runtime — the worst kind of surprise. Small, focused interfaces let each class implement exactly what it needs and nothing more.

// BAD — BasicPrinter must stub three methods it cannot support
public interface AllInOnePrinter {
    void print(byte[] document);
    void scan(String destination);
    void fax(String phoneNumber, byte[] document);
    void staple();
}

public class BasicPrinter implements AllInOnePrinter {
    @Override public void print(byte[] document)             { /* real impl */ }
    @Override public void scan(String destination)           { throw new UnsupportedOperationException(); }
    @Override public void fax(String phone, byte[] document) { throw new UnsupportedOperationException(); }
    @Override public void staple()                           { throw new UnsupportedOperationException(); }
}

Segregate into small capability interfaces so each class implements only what it actually supports:

// GOOD — each interface is a single capability; classes compose what they need
public interface Printable  { void print(byte[] document); }
public interface Scannable  { void scan(String destination); }
public interface Faxable    { void fax(String phoneNumber, byte[] document); }
public interface Stapleable { void staple(); }

// BasicPrinter implements only what it supports — no stubs, no surprises
public class BasicPrinter implements Printable {
    @Override public void print(byte[] document) { /* real implementation */ }
}

// OfficeAllInOne genuinely supports all capabilities
public class OfficeAllInOne implements Printable, Scannable, Faxable, Stapleable {
    @Override public void print(byte[] document)             { /* ... */ }
    @Override public void scan(String destination)           { /* ... */ }
    @Override public void fax(String phone, byte[] document) { /* ... */ }
    @Override public void staple()                           { /* ... */ }
}

D — Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions.

When a high-level class directly instantiates a low-level class (new MySqlUserRepository()), the two are tightly coupled — you cannot change or test one without the other. Dependency Inversion breaks this by making both sides depend on an interface. The concrete implementation is injected from the outside, which makes the high-level class trivially testable (inject a mock) and flexible (swap implementations without editing the class).

// BAD — UserService is hardwired to MySql; you cannot test or swap it
public class UserService {
    private final MySqlUserRepository repository = new MySqlUserRepository(); // hardwired!

    public Optional<User> findUser(long id) {
        return repository.findById(id);
    }
}

Invert the dependency through an interface — both the service and the repository depend on the abstraction:

// GOOD — both layers depend on the UserRepository interface, not a concrete class

public interface UserRepository {
    Optional<User> findById(long id);
    void save(User user);
}

// Low-level module depends on the interface
public class MySqlUserRepository implements UserRepository {
    @Override public Optional<User> findById(long id) { /* MySQL */ return Optional.empty(); }
    @Override public void save(User user)              { /* MySQL */ }
}

public class PostgresUserRepository implements UserRepository {
    @Override public Optional<User> findById(long id) { /* Postgres */ return Optional.empty(); }
    @Override public void save(User user)              { /* Postgres */ }
}

// High-level module depends on the interface — the concrete class is injected
public class UserService {
    private final UserRepository repository;

    public UserService(UserRepository repository) { // injected, not hardwired
        this.repository = repository;
    }

    public Optional<User> findUser(long id) {
        return repository.findById(id);
    }
}

// In production: wire the real implementation
UserService service = new UserService(new MySqlUserRepository());

// In tests: inject a lightweight lambda — no database needed
UserRepository mockRepo = id -> Optional.of(new User(id, "Test User"));
UserService testService = new UserService(mockRepo);

Applying All Five Together

A well-designed service class naturally satisfies all five principles. Notice how the design decisions reinforce each other: injecting the repository (DIP) is only possible because it’s an interface (ISP), which makes the class easy to test and swap (OCP), and the class itself only handles user lookup (SRP), with each implementation being substitutable (LSP).

PrincipleSeen in the example
SRPUserService only handles user lookup/storage — no email, no PDF
OCPSwap database without touching UserService
LSPAny UserRepository implementation is substitutable
ISPUserRepository only declares what UserService needs
DIPUserService depends on the UserRepository interface, not a class

Frequently Asked Questions

Do I have to follow all five SOLID principles all the time?
No. They are guidelines, not laws. Apply them where complexity warrants it. A 50-line utility class does not need to be split for SRP. Use SOLID as a diagnostic tool when you notice your code becoming hard to change or test.
What is the most important SOLID principle?
The Open/Closed Principle has the biggest long-term impact. Code that is open for extension but closed for modification lets you add features without risking existing behaviour — this is what makes large codebases stable.
Is SOLID only for OOP?
SOLID was formulated for OOP, but the underlying ideas apply broadly. Single Responsibility applies to functions and modules. Dependency Inversion applies to any modular system. The vocabulary is OOP-centric but the thinking is universal.