-
Notifications
You must be signed in to change notification settings - Fork 134
SE 04 SOLID Principles
Part of the Software Engineering Principles series
SOLID is a set of five design principles introduced by Robert C. Martin ("Uncle Bob") that guide object-oriented class design. Applied together, they produce code that is easier to understand, test, extend, and maintain.
Each letter stands for one principle:
| Letter | Principle | Core idea |
|---|---|---|
| S | Single Responsibility | A class should have one reason to change |
| O | Open-Closed | Open for extension, closed for modification |
| L | Liskov Substitution | Subtypes must be substitutable for their base types |
| I | Interface Segregation | Clients should not depend on methods they do not use |
| D | Dependency Inversion | Depend on abstractions, not concretions |
A class should have only one reason to change.
A "reason to change" corresponds to a distinct area of responsibility. If a class is responsible for both business logic and data access and formatting, it will need to change whenever any of those three concerns change — even if the others are stable.
public class PatientReport {
// Responsibility 1: fetch data from database
public List<Patient> getAdmittedPatients() {
// database query here
}
// Responsibility 2: format the report
public String formatAsHtml(List<Patient> patients) {
// HTML generation here
}
// Responsibility 3: send by email
public void sendEmail(String recipient, String content) {
// SMTP code here
}
}This class changes for three reasons: when the query changes, when the HTML format changes, and when the email server configuration changes.
public class PatientRepository {
public List<Patient> getAdmittedPatients() { ... }
}
public class PatientReportFormatter {
public String formatAsHtml(List<Patient> patients) { ... }
}
public class EmailService {
public void send(String recipient, String content) { ... }
}Each class now has exactly one reason to change.
Software entities should be open for extension, but closed for modification.
You should be able to add new behaviour without modifying existing, tested code. This is achieved by depending on abstractions (interfaces or abstract classes) so new implementations can be introduced without touching existing ones.
public class DiscountCalculator {
public double calculate(String customerType, double amount) {
if (customerType.equals("STAFF")) return amount * 0.8;
if (customerType.equals("PENSIONER")) return amount * 0.85;
// Adding "STUDENT" requires modifying this class
return amount;
}
}Every new discount type requires editing this class, risking regressions in the existing logic.
public interface DiscountPolicy {
boolean appliesTo(Customer customer);
double apply(double amount);
}
public class StaffDiscount implements DiscountPolicy {
public boolean appliesTo(Customer c) { return c.isStaff(); }
public double apply(double amount) { return amount * 0.8; }
}
public class PensionerDiscount implements DiscountPolicy {
public boolean appliesTo(Customer c) { return c.isPensioner(); }
public double apply(double amount) { return amount * 0.85; }
}
public class DiscountCalculator {
private List<DiscountPolicy> policies;
public double calculate(Customer customer, double amount) {
return policies.stream()
.filter(p -> p.appliesTo(customer))
.mapToDouble(p -> p.apply(amount))
.findFirst()
.orElse(amount);
}
}Adding a student discount now means writing a new StudentDiscount class — the DiscountCalculator is never touched.
Subtypes must be substitutable for their base types without altering the correctness of the program.
Named after Barbara Liskov, this principle ensures that inheritance is used correctly. If class B extends class A, you should be able to use B anywhere A is expected and the program must still work correctly.
public class Rectangle {
protected int width, height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area() { return width * height; }
}
public class Square extends Rectangle {
@Override
public void setWidth(int w) { this.width = w; this.height = w; }
@Override
public void setHeight(int h) { this.width = h; this.height = h; }
}
// This behaves incorrectly when given a Square
void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
assert r.area() == 50; // Fails for Square — area is 100
}Square violates LSP because it changes the postconditions of setWidth and setHeight.
Recognise that a square is not a rectangle in the behavioural sense needed here. Either do not use inheritance, or redesign the hierarchy around an immutable Shape abstraction.
Ask: "If I replace every instance of the base class with this subclass, will anything break?" If the answer is yes, the subclass violates LSP.
Clients should not be forced to depend upon interfaces they do not use.
A large, general-purpose interface forces every implementer to provide methods it may not need, leading to empty or throwing implementations. Prefer many small, focused interfaces.
public interface StaffMember {
void prescribeMedication(); // only doctors do this
void dispenseMedication(); // only pharmacists do this
void administerInjection(); // only nurses do this
void processPayment(); // only cashiers do this
String getName();
}A cashier implementing this interface must stub out prescribeMedication() and dispenseMedication().
public interface Prescriber {
void prescribeMedication();
}
public interface Dispenser {
void dispenseMedication();
}
public interface Cashier {
void processPayment();
}
public interface StaffIdentity {
String getName();
}
// Roles compose the interfaces they actually need
public class Doctor implements Prescriber, StaffIdentity { ... }
public class Pharmacist implements Dispenser, StaffIdentity { ... }
public class CashierStaff implements Cashier, StaffIdentity { ... }High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.
Without DIP, a business-logic class directly references a database class. The business logic becomes impossible to test without the database, and changing the database technology forces changes to the business logic.
public class BillingService {
// Direct dependency on a concrete class
private MySqlBillingRepository repository = new MySqlBillingRepository();
public Bill createBill(Patient patient, double amount) {
Bill bill = new Bill(patient, amount);
repository.save(bill);
return bill;
}
}BillingService is tightly bound to MySQL. It cannot be unit-tested without a real database connection.
// Abstraction — both sides depend on this
public interface BillingRepository {
void save(Bill bill);
Optional<Bill> findById(long id);
}
// High-level module depends on the abstraction
public class BillingService {
private BillingRepository repository;
public BillingService(BillingRepository repository) {
this.repository = repository;
}
public Bill createBill(Patient patient, double amount) {
Bill bill = new Bill(patient, amount);
repository.save(bill);
return bill;
}
}
// Low-level module implements the abstraction
public class JpaBillingRepository implements BillingRepository {
@PersistenceContext EntityManager em;
@Override
public void save(Bill bill) { em.persist(bill); }
}
// In tests — inject a simple in-memory implementation
public class InMemoryBillingRepository implements BillingRepository {
private List<Bill> store = new ArrayList<>();
@Override public void save(Bill bill) { store.add(bill); }
}In Java EE, CDI handles this injection automatically:
@Stateless
public class BillingService {
@Inject
private BillingRepository repository; // CDI injects the appropriate implementation
}The SOLID principles are not independent rules — they reinforce each other:
- SRP gives each class a clear purpose, making it easier to apply OCP (you know what you are extending).
- LSP ensures that the abstractions used by OCP and DIP behave consistently.
- ISP keeps interfaces focused, supporting DIP by preventing dependencies on unnecessary behaviour.
- DIP is what makes OCP practical: you can only be open for extension if you depend on abstractions.
SOLID is guidance, not dogma. Applied without judgement, it produces:
- Dozens of one-method interfaces for trivial classes
- Deep inheritance hierarchies no one can follow
- Abstraction layers that add complexity without adding flexibility
Apply SOLID where complexity warrants it. A simple utility method does not need an interface and three implementations.
Previous: SE-03: Object-Oriented Programming
Next: SE-05: Design Patterns