Introduction
Writing clean, scalable, and maintainable object-oriented code is a key challenge in software development. To help guide developers, Robert C. Martin introduced the SOLID principles, five core design principles that lead to better software architecture. When applied properly, these principles make code easier to understand, extend, and refactor, reducing the chances of introducing bugs. In this article, we’ll explore each SOLID principle using simple and original Java examples to illustrate the correct and incorrect ways to implement them.
Single Responsibility Principle
A class should have only one reason to change, meaning it should only have one job or responsibility. This makes the system easier to maintain and extend.
// Violates SRP: Combines data and output logic
class Report {
String content;
Report(String content) {
this.content = content;
}
void printReport() {
System.out.println(content);
}
}
// Follows SRP: Splits responsibilities across classes
class Report {
String content;
Report(String content) {
this.content = content;
}
String getContent() {
return content;
}
}
class ReportPrinter {
void print(Report report) {
System.out.println(report.getContent());
}
}
Open/Closed Principle
Software entities should be open for extension but closed for modification. You should be able to introduce new behavior without altering existing, tested code.
// Violates OCP: Requires modification to support new types
class DiscountCalculator {
double calculate(String type, double amount) {
if (type.equals("student")) {
return amount * 0.85;
} else if (type.equals("senior")) {
return amount * 0.75;
}
return amount;
}
}
// Follows OCP: New behavior added by extending interface
interface DiscountStrategy {
double apply(double amount);
}
class StudentDiscount implements DiscountStrategy {
public double apply(double amount) {
return amount * 0.85;
}
}
class SeniorDiscount implements DiscountStrategy {
public double apply(double amount) {
return amount * 0.75;
}
}
class DiscountCalculator {
double calculate(DiscountStrategy strategy, double amount) {
return strategy.apply(amount);
}
}
Liskov Substitution Principle
Subtypes must be substitutable for their base types without affecting program correctness. If a subclass violates expected behavior, it breaks the design.
// Violates LSP: Subclass breaks expected behavior
class Bird {
void fly() {
System.out.println("Flying");
}
}
class Penguin extends Bird {
void fly() {
throw new UnsupportedOperationException("Penguins can't fly");
}
}
// Follows LSP: Separates flying and non-flying birds via interface
interface Bird {}
interface FlyableBird extends Bird {
void fly();
}
class Eagle implements FlyableBird {
public void fly() {
System.out.println("Eagle flying");
}
}
class Penguin implements Bird {
// No fly method, which respects its natural behavior
}
Interface Segregation Principle
Clients should not be forced to implement interfaces they don’t use. It’s better to have several small, role-specific interfaces than one large, general-purpose one.
// Violates ISP: Forces implementation of unused methods
interface PrintMachine {
void print();
void scan();
void fax();
}
class BasicPrinter implements PrintMachine {
public void print() {
System.out.println("Printing...");
}
public void scan() {
throw new UnsupportedOperationException();
}
public void fax() {
throw new UnsupportedOperationException();
}
}
// Follows ISP: Interfaces separated by responsibility
interface Printer {
void print();
}
interface Scanner {
void scan();
}
class SimplePrinter implements Printer {
public void print() {
System.out.println("Simple printing...");
}
}
class AllInOneMachine implements Printer, Scanner {
public void print() {
System.out.println("Printing...");
}
public void scan() {
System.out.println("Scanning...");
}
}
Dependency Inversion Principle
High-level modules should depend on abstractions, not concrete implementations. This promotes loose coupling and makes systems easier to test and maintain.
// Violates DIP: Tightly coupled to a concrete class
class MySQLDatabase {
void connect() {
System.out.println("Connected to MySQL");
}
}
class UserService {
MySQLDatabase db = new MySQLDatabase();
void register() {
db.connect();
System.out.println("User registered");
}
}
// Follows DIP: Depends on abstraction, not implementation
interface Database {
void connect();
}
class MySQLDatabase implements Database {
public void connect() {
System.out.println("Connected to MySQL");
}
}
class UserService {
private final Database db;
UserService(Database db) {
this.db = db;
}
void register() {
db.connect();
System.out.println("User registered");
}
}
Conclusion
The SOLID principles are not rules enforced by the compiler, but design guidelines that promote better architecture and maintainability. Each principle addresses a specific aspect of object-oriented design, helping you avoid rigidity, fragility, and unnecessary complexity. By understanding and applying these principles thoughtfully in your Java code, you can build systems that are easier to understand, change, test, and scale. While it’s not always necessary to apply all five principles at once, recognizing when they matter most is what sets apart robust software from fragile implementations.
