Core Software Design Principles: A Practical Guide

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
  1. 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:

  1. Complete Implementation: Subclasses must fully implement all parent methods. If a subclass cannot implement certain parent methods, consider using composition instead of inheritance.
  2. 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).
  3. Parameter Constraints: When overriding methods, input parameters must be same or more permissive (contravariance). If child method parameters are stricter, LSP is violated.
  4. 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>
  1. 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:

  1. Module dependencies should occur through abstractions
  2. Abstractions should not depend on concrete implementations
  3. 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
  1. Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they don't use. Keep interfaces focused and minimal.

Guidelines:

  1. Keep interfaces small: Split large interfaces into smaller, specific ones
  2. High cohesion: Interfaces should expose minimal public methods
  3. 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
  1. 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.

  1. 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:

  1. Modify interface: Bad - breaks all implementation
  2. Modify implementation: Risky - affects dependent modules
  3. 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;
    }
}
  1. 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

Tags: Design Patterns SOLID Principles Software Architecture Object-Oriented Design java

Posted on Sat, 27 Jun 2026 16:49:05 +0000 by musclehead