The Single Responsibility Principle states that a class should have only one reason to change. This means each class or interface should focus on a single functionality.
Problem Scenario: If a class handles multiple responsibilities, modifying one responsibility might inadvertently affect the other, increasing the risk of bugs.
Key Benefits:
- Reduced class complexity with clear definitions
- Improved readability and maintainability
- Lower risk when implementing changes
Code Example - Violation:
// Bad design: One class handling multiple user types
public class User {
public void displayInfo(String userType) {
System.out.println("User type: " + userType);
}
}
// Usage
public class Application {
public static void main(String[] args) {
User user = new User();
user.displayInfo("Admin");
user.displayInfo("Guest");
}
}
If we need to add specific behavior for "Admin", modifying the User class affects all user types.
Better Approach:
// Separate classes for different responsibilities
public class AdminUser {
public void displayInfo() {
System.out.println("Admin user logged in");
}
}
public class GuestUser {
public void displayInfo() {
System.out.println("Guest user logged in");
}
}
Practical Guidelines:
- Interfaces should always follow SRP
- For simple logic with few methods, SRP can be relaxed at code level
- In complex systems, always adhere to SRP when responsibilities expand
- Liskov Substitution Principle (LSP)
Objects of a superclass should be replaceable with objects of its subclasses without breaking the application. Subclasses must completely implement parent class behaviors.
Four Key Rules:
- Complete Implementation: Subclasses must fully implement all parent methods. If a subclass cannot implement certain parent methods, consider using composition instead of inheritance.
- Subclass Uniqueness: Subclasses can have their own methods and properties. How ever, avoid using parent references when subclass-specific features are needed (downcasting is unsafe).
- Parameter Constraints: When overriding methods, input parameters must be same or more permissive (contravariance). If child method parameters are stricter, LSP is violated.
- Return Type Constraints: Return types in overridden methods must be same or more specific (covariance).
Code Example:
public class Parent {
public Collection processData(HashMap data) {
System.out.println("Parent processing...");
return data.values();
}
}
public class Child extends Parent {
// Parameter type widened from HashMap to Map (valid LSP)
public Collection processData(Map data) {
System.out.println("Child processing...");
return data.values();
}
}
// Client code
public class Client {
public static void main(String[] args) {
Parent processor = new Parent();
HashMap<string string=""> data = new HashMap<>();
processor.processData(data); // Parent method executes
Child childProcessor = new Child();
childProcessor.processData(data); // Child method executes
}
}</string>
- Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. This is the foundation of "programming to interfaces."
Three Rules:
- Module dependencies should occur through abstractions
- Abstractions should not depend on concrete implementations
- Concrete implementations depend on abstractions
Three Dependency Injection Methods:
1. Constructor Injection:
public interface IVehicle {
void move();
}
public class Driver {
private IVehicle vehicle;
public Driver(IVehicle vehicle) {
this.vehicle = vehicle;
}
public void travel() {
vehicle.move();
}
}
2. Setter Injection:
public interface IVehicle {
void move();
}
public class Driver {
private IVehicle vehicle;
public void setVehicle(IVehicle vehicle) {
this.vehicle = vehicle;
}
public void travel() {
vehicle.move();
}
}
3. Interface Injection:
public interface IVehicle {
void move();
}
public interface ITraveler {
void travel(IVehicle vehicle);
}
public class Driver implements ITraveler {
public void travel(IVehicle vehicle) {
vehicle.move();
}
}
Best Practices:
- Every class should have an interface or abstract base class
- Variable types should be interfaces or abstract classes
- Avoid deriving from concrete classes (limit to 2 levels if necessary)
- Avoid overriding implemented methods in abstract base classes
- Combine with LSP for maximum effectiveness
- Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they don't use. Keep interfaces focused and minimal.
Guidelines:
- Keep interfaces small: Split large interfaces into smaller, specific ones
- High cohesion: Interfaces should expose minimal public methods
- Customized services: Design specific interfaces for different client needs
Code Example:
// Segregated interfaces
public interface IPhysicalAppearance {
void displayLooks();
}
public interface IPersonalityTraits {
void displayTemperament();
}
// Implementation can choose which interfaces to implement
public class ElegantPerson implements IPersonalityTraits {
public void displayTemperament() {
System.out.println("Shows elegant temperament");
}
}
Design Rules:
- One interface per submodule or business logic
- Regularly review and compress public methods in interfaces
- Use adapter pattern for polluted interfaces when modification is risky
- Law of Demeter (LoD)
An object should only communicate with its immediate friends. A method should only call methods belonging to: itself, its fields, its parameters, or objects it creates.
Key Concepts:
- Talk only to friends: Don't chain method calls across multiple objects
- Friend distance: Limit exposure of public methods and properties
- Encapsulation: Keep internal logic private
Code Example:
// Teacher commands GroupLeader to count Girls
public class Teacher {
public void issueCommand(GroupLeader leader) {
leader.countMembers();
}
}
public class GroupLeader {
private List<member> members;
public GroupLeader(List<member> members) {
this.members = members;
}
public void countMembers() {
System.out.println("Member count: " + members.size());
}
}</member></member>
Teacher doesn't directly interact with Member class - it only knows GroupLeader.
Warning: Over-application can create many intermediary classes. Limit chaining to 2 hops maximum.
- Open-Closed Principle (OCP)
Software entities should be open for extension but closed for modification. Add new functionality through new code, not by changing existing code.
Types of Changes:
- Logic changes within a module
- Submodule changes affecting other modules
- UI/view changes causing ripple effects
Three Approaches to Change:
- Modify interface: Bad - breaks all implementation
- Modify implementation: Risky - affects dependent modules
- Extension via subclassing: Recommended - preserves existing code
Code Example:
public interface IProduct {
String getName();
double getBasePrice();
String getAuthor();
}
public class Book implements IProduct {
private String name;
private double basePrice;
private String author;
public Book(String name, double basePrice, String author) {
this.name = name;
this.basePrice = basePrice;
this.author = author;
}
public String getName() { return name; }
public double getBasePrice() { return basePrice; }
public String getAuthor() { return author; }
}
// Extension for discount pricing without modifying original class
public class DiscountedBook extends Book {
public DiscountedBook(String name, double basePrice, String author) {
super(name, basePrice, author);
}
@Override
public double getBasePrice() {
double original = super.getBasePrice();
return original > 40 ? original * 0.9 : original * 0.8;
}
}
- Comprehensive Example: Calculator with Factory Pattern
This calculator design demonstrates all six principles working together.
// Abstract base class (DIP, OCP)
public abstract class Calculator {
protected double operandA;
protected double operandB;
public void setOperandA(double value) { this.operandA = value; }
public void setOperandB(double value) { this.operandB = value; }
public abstract double compute();
}
// Concrete implementations (SRP - each handles one operation)
public class AdditionCalculator extends Calculator {
public double compute() {
return operandA + operandB;
}
}
public class SubtractionCalculator extends Calculator {
public double compute() {
return operandA - operandB;
}
}
public class MultiplicationCalculator extends Calculator {
public double compute() {
return operandA * operandB;
}
}
public class DivisionCalculator extends Calculator {
public double compute() {
if (operandB == 0) {
throw new ArithmeticException("Division by zero");
}
return operandA / operandB;
}
}
// Factory class (LoD - decouples client from concrete classes)
public class CalculatorFactory {
public static Calculator create(String operator) {
switch (operator) {
case "+": return new AdditionCalculator();
case "-": return new SubtractionCalculator();
case "*": return new MultiplicationCalculator();
case "/": return new DivisionCalculator();
default: throw new IllegalArgumentException("Unknown operator");
}
}
}
// Client code
public class Client {
public static void main(String[] args) {
Calculator calc = CalculatorFactory.create("+");
calc.setOperandA(10);
calc.setOperandB(5);
double result = calc.compute();
System.out.println("Result: " + result);
}
}
Principle Analysis:
- SRP: Each calculator class handles one operation
- LSP: All subclasses can substitute the base Calculator
- DIP: Client depends on Calculator abstraction
- ISP: Clean, minimal interface
- LoD: Factory mediates between client and concrete calculators
- OCP: New operations added by creating new subclasses without modifying existing code