Design Principles I wish I had known earlier

1. Single Responsibility Principle (SRP)

Ensures that a class or module has only one job or responsibility. This makes the code easier to understand and modify, as changes to one responsibility do not affect others.

Example:

class Employee {
    public void calculatePay() { /*...*/ }
    public void saveToDatabase() { /*...*/ }
}

// Adheres to SRP: Separate classes for data and persistence
class Employee {
    public void calculatePay() { /*...*/ }
}

class EmployeeRepository {
    public void save(Employee employee) { /*...*/ }
}

2. Open/Closed Principle (OCP)

Classes should be open for extension but closed for modification. This means you can add new functionality through inheritance or composition without changing existing code, reducing the risk of introducing bugs.

Example:

class PaymentProcessor {
    public void process(Payment payment) {
        if (payment.type == "credit") { /*...*/ }
        else if (payment.type == "paypal") { /*...*/ }
    }
}

// Adheres to OCP: New payment methods can be added without modifying existing code
interface Payment {
    void process();
}

class CreditPayment implements Payment {
    public void process() { /*...*/ }
}

class PayPalPayment implements Payment {
    public void process() { /*...*/ }
}




3. Liskov Substitution Principle (LSP)

Subclasses should be able to replace their base classes without altering the correctness of the program. This ensures that derived classes extend the base class’s behavior without changing its fundamental behavior.

Example:

class Rectangle {
    private int width;
    private int height;
    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }
}

class Square extends Rectangle {
    public void setWidth(int width) { super.setWidth(width); super.setHeight(width); }
    public void setHeight(int height) { super.setWidth(height); super.setHeight(height); }
}

// Adheres to LSP: Square is not a subtype of Rectangle
interface Shape {
    int area();
}

class Rectangle implements Shape {
    private int width;
    private int height;
    public int area() { return width * height; }
}

class Square implements Shape {
    private int side;
    public int area() { return side * side; }
}

4. Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they do not use. This promotes the creation of smaller, more specific interfaces so that implementing classes only need to concern themselves with the methods that are of interest to them.

Example:

interface Worker {
    void work();
    void eat();
}

class Robot implements Worker {
    public void work() { /*...*/ }
    public void eat() { /* not applicable */ }
}

// Adheres to ISP: Separate interfaces for different responsibilities
interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

class Robot implements Workable {
    public void work() { /*...*/ }
}




5. Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions. This principle reduces the coupling between classes by relying on interfaces or abstract classes rather than concrete implementations.

Example:

class LightBulb {
    public void turnOn() { /*...*/ }
    public void turnOff() { /*...*/ }
}

class Switch {
    private LightBulb bulb;
    public void operate() { bulb.turnOn(); }
}

// Adheres to DIP: High-level module depends on abstraction
interface Switchable {
    void turnOn();
    void turnOff();
}

class LightBulb implements Switchable {
    public void turnOn() { /*...*/ }
    public void turnOff() { /*...*/ }
}

class Switch {
    private Switchable device;
    public void operate() { device.turnOn(); }
}




6. Don’t Repeat Yourself (DRY)

Avoid duplicating code by abstracting out common functionality into a single location. This reduces maintenance effort and the risk of inconsistencies.

Example:

class Report {
    public void generateReport() {
        // Open file
        // Write data
        // Close file
    }
}

class Invoice {
    public void generateInvoice() {
        // Open file
        // Write data
        // Close file
    }
}

// Adheres to DRY: Common functionality abstracted into a method
class FileWriter {
    public void writeToFile(String data) {
        // Open file
        // Write data
        // Close file
    }
}

class Report {
    private FileWriter fileWriter = new FileWriter();
    public void generateReport() {
        fileWriter.writeToFile("report data");
    }
}

class Invoice {
    private FileWriter fileWriter = new FileWriter();
    public void generateInvoice() {
        fileWriter.writeToFile("invoice data");
    }
}




7. Keep It Simple, Stupid (KISS)

Strive for simplicity in your design and implementation. Simple code is easier to understand, maintain, and less prone to errors.

Example:

class ComplexCalculator {
    public double calculate(double a, double b) {
        if (a > b) {
            return a - b;
        } else {
            return b - a;
        }
    }
}

// Adheres to KISS: Simple and straightforward solution
class SimpleCalculator {
    public double subtract(double a, double b) {
        return a - b;
    }
}

8. You Aren’t Gonna Need It (YAGNI)

Don’t add functionality until it is necessary. This prevents over-engineering and keeps the codebase lean and focused.

Example:

class Car {
    public void drive() { /*...*/ }
    public void fly() { /* not needed */ }
}

// Adheres to YAGNI: Only necessary functionality implemented
class Car {
    public void drive() { /*...*/ }
}

9. Law of Demeter (LoD)

An object should only talk to its immediate friends (objects it directly interacts with). This minimizes dependencies and reduces the impact of changes in other parts of the system.

Example:

class Customer {
    public Wallet getWallet() { /*...*/ }
}

class Wallet {
    public CreditCard getCreditCard() { /*...*/ }
}

class CreditCard {
    public void charge(double amount) { /*...*/ }
}

class PaymentProcessor {
    public void processPayment(Customer customer, double amount) {
        customer.getWallet().getCreditCard().charge(amount);
    }
}

// Adheres to LoD: Reduced dependencies
class Customer {
    private Wallet wallet;
    public void makePayment(double amount) {
        wallet.charge(amount);
    }
}

class Wallet {
    private CreditCard creditCard;
    public void charge(double amount) {
        creditCard.charge(amount);
    }
}

class PaymentProcessor {
    public void processPayment(Customer customer, double amount) {
        customer.makePayment(amount);
    }
}

10. Composition Over Inheritance

Favor composition (having classes contain instances of other classes that implement desired behavior) over inheritance (extending classes). This provides greater flexibility and avoids some of the pitfalls of deep inheritance hierarchies.

Example:

// Using Inheritance
class Bird {
    public void fly() { /*...*/ }
}

class Penguin extends Bird {
    // Penguins can't fly
}

// Using Composition
interface Flyable {
    void fly();
}

class CanFly implements Flyable {
    public void fly() { /*...*/ }
}

class CannotFly implements Flyable {
    public void fly() {
        throw new UnsupportedOperationException("Cannot fly");
    }
}

class Bird {
    private Flyable flyable;

    public Bird(Flyable flyable) {
        this.flyable = flyable;
    }

    public void tryToFly() {
        flyable.fly();
    }
}

class Penguin extends Bird {
    public Penguin() {
        super(new CannotFly());
    }
}




11. Separation of Concerns (SoC)

Different concerns or aspects of a program should be separated into distinct sections. This modular approach makes the system easier to manage and evolve.

Example:

// Without SoC
class MonolithicApplication {
    public void handleRequest() {
        // Handle user input
        // Validate input
        // Process business logic
        // Update database
        // Generate response
    }
}

// With SoC
class InputHandler {
    public void handleInput() { /*...*/ }
}

class Validator {
    public void validate() { /*...*/ }
}

class BusinessLogic {
    public void process() { /*...*/ }
}

class DatabaseUpdater {
    public void update() { /*...*/ }
}

class ResponseGenerator {
    public void generate() { /*...*/ }
}

class ModularApplication {
    private InputHandler inputHandler = new InputHandler();
    private Validator validator = new Validator();
    private BusinessLogic businessLogic = new BusinessLogic();
    private DatabaseUpdater databaseUpdater = new DatabaseUpdater();
    private ResponseGenerator responseGenerator = new ResponseGenerator();

    public void handleRequest() {
        inputHandler.handleInput();
        validator.validate();
        businessLogic.process();
        databaseUpdater.update();
        responseGenerator.generate();
    }
}

12. Encapsulation

Hide the internal state and implementation details of an object and expose only what is necessary through public methods. This protects the integrity of the object’s state and allows changes to be made without affecting outside code.

Example:

// Without Encapsulation
class Person {
    public String name;
    public int age;
}

Person person = new Person();
person.age = -1; // Invalid age

// With Encapsulation
class Person {
    private String name;
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        if (age >= 0) {
            this.age = age;
        } else {
            throw new IllegalArgumentException("Age cannot be negative");
        }
    }
}

Person person = new Person();
person.setAge(25); // Valid age
person.setAge(-1); // Throws exception

13. Modularity

Design your system as a set of distinct modules that can be developed, tested, and maintained independently. This promotes reusability and simplifies the management of large codebases.

Example:

// Without Modularity
class Application {
    public void start() {
        // Load configuration
        // Initialize database
        // Start web server
        // Handle requests
    }
}

// With Modularity
class ConfigurationModule {
    public void load() { /*...*/ }
}

class DatabaseModule {
    public void initialize() { /*...*/ }
}

class WebServerModule {
    public void start() { /*...*/ }
}

class Application {
    private ConfigurationModule configModule = new ConfigurationModule();
    private DatabaseModule dbModule = new DatabaseModule();
    private WebServerModule webServerModule = new WebServerModule();

    public void start() {
        configModule.load();
        dbModule.initialize();
        webServerModule.start();
    }
}

14. Fail Fast

Design systems to detect and report errors as soon as they occur, rather than allowing them to propagate. This makes debugging easier and increases reliability.

Example:

// Without Fail Fast
class Order {
    public void placeOrder(int quantity) {
        if (quantity > 0) {
            // Place order
        } else {
            // Log error and continue
        }
    }
}

// With Fail Fast
class Order {
    public void placeOrder(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
        // Place order
    }
}

15. Test-Driven Development (TDD)

Write tests before writing the actual code. This ensures that the code is testable and helps catch bugs early in the development process.

Example:

// Test before code
class CalculatorTest {
    @Test
    public void testAdd() {
        Calculator calc = new Calculator();
        assertEquals(5, calc.add(2, 3));
    }
}

// Implementation after test
class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}

16. Immutable Objects

Prefer using objects that cannot be modified after they are created. This avoids unintended side effects and makes concurrent programming easier.

Example:

// Mutable Object
class MutablePoint {
    public int x;
    public int y;
}

MutablePoint point = new MutablePoint();
point.x = 1;
point.y = 2;

// Immutable Object
class ImmutablePoint {
    private final int x;
    private final int y;

    public ImmutablePoint(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }
}

ImmutablePoint point = new ImmutablePoint(1, 2);

17. Design Patterns

Use common design patterns (e.g., Singleton, Factory, Observer) as they provide proven solutions to recurring design problems and improve code readability and maintainability.

Example:

// Singleton Pattern
class Singleton {
    private static Singleton instance;

    private Singleton() { }

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

// Factory Pattern
interface Shape {
    void draw();
}

class Circle implements Shape {
    public void draw() { /*...*/ }
}

class Rectangle implements Shape {
    public void draw() { /*...*/ }
}

class ShapeFactory {
    public Shape getShape(String shapeType) {
        if (shapeType.equals("CIRCLE")) {
            return new Circle();
        } else if (shapeType.equals("RECTANGLE")) {
            return new Rectangle();
        }
        return null;
    }
}

Follow my work on other platforms:

LinkedIn | Youtube 

I hope you have a lovely day!

See you soon,

Arun

Leave a Reply