Object-Oriented Design Principles in Java

Introduction

Object-Oriented Design (OOD) principles are the foundation of building robust, scalable, and maintainable software. These principles guide the design and implementation of object-oriented systems, ensuring that the software is modular, flexible, and easy to understand. These principles are particularly important in Java as it is an object-oriented programming language.

Table of Contents

  1. Single Responsibility Principle (SRP)
  2. Open/Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)
  6. Encapsulate What Varies
  7. DRY (Don't Repeat Yourself)
  8. YAGNI (You Aren't Gonna Need It)
  9. KISS (Keep It Simple, Stupid)
  10. Composition over Inheritance
  11. Dependency Injection
  12. Conclusion

1. Single Responsibility Principle (SRP)

Definition: A class should have only one reason to change, meaning it should have only one job or responsibility.

Example:

// A class with a single responsibility: handling user authentication
public class AuthService {
    public boolean authenticate(String username, String password) {
        // Authentication logic
        return true;
    }
}

// A class with a single responsibility: managing user profiles
public class UserProfileService {
    public void updateProfile(String userId, String newProfileData) {
        // Update profile logic
    }
}

Explanation:

  • AuthService: Handles only authentication-related tasks.
  • UserProfileService: Manages user profile-related tasks separately.

2. Open/Closed Principle (OCP)

Definition: Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

The benefit of this Object-oriented design principle is that it prevents someone from changing already tried and tested code.

Example:

// Base class
public abstract class Shape {
    abstract void draw();
}

// Extension 1
public class Circle extends Shape {
    @Override
    void draw() {
        System.out.println("Drawing Circle");
    }
}

// Extension 2
public class Rectangle extends Shape {
    @Override
    void draw() {
        System.out.println("Drawing Rectangle");
    }
}

// Usage
public class Drawing {
    public void drawShape(Shape shape) {
        shape.draw();
    }
}

Explanation:

  • Shape: Base class that is closed for modification.
  • Circle and Rectangle: Classes that extend the base class, open for extension.

3. Liskov Substitution Principle (LSP)

Definition: Subtypes must be substitutable for their base types without altering the correctness of the program.

Example:

// Base class
public class Bird {
    public void fly() {
        System.out.println("Bird is flying");
    }
}

// Subclass adhering to LSP
public class Sparrow extends Bird {
}

// Subclass violating LSP (if Penguin were to override fly with an exception or no-op)
public class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins can't fly");
    }
}

Explanation:

  • Sparrow: Can be used in place of Bird without any issues.
  • Penguin: Violates LSP as it cannot perform the fly operation expected of a Bird.

4. Interface Segregation Principle (ISP)

Definition: Clients should not be forced to depend on interfaces they do not use. Instead, interfaces should be segregated into smaller, more specific ones.

Example:

// Segregated interfaces
public interface Printer {
    void print();
}

public interface Scanner {
    void scan();
}

// Class implementing only the required interfaces
public class MultiFunctionPrinter implements Printer, Scanner {
    @Override
    public void print() {
        System.out.println("Printing...");
    }

    @Override
    public void scan() {
        System.out.println("Scanning...");
    }
}

public class SimplePrinter implements Printer {
    @Override
    public void print() {
        System.out.println("Printing...");
    }
}

Explanation:

  • Printer and Scanner: Smaller, more specific interfaces.
  • MultiFunctionPrinter and SimplePrinter: Implement only the interfaces they need.

5. Dependency Inversion Principle (DIP)

Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces). Abstractions should not depend on details. Details should depend on abstractions.

Example:

// Abstraction
public interface MessageService {
    void sendMessage(String message, String receiver);
}

// Low-level module
public class EmailService implements MessageService {
    @Override
    public void sendMessage(String message, String receiver) {
        System.out.println("Email sent to " + receiver + " with message: " + message);
    }
}

// High-level module
public class Notification {
    private MessageService messageService;

    public Notification(MessageService messageService) {
        this.messageService = messageService;
    }

    public void send(String message, String receiver) {
        messageService.sendMessage(message, receiver);
    }
}

// Usage
public class Main {
    public static void main(String[] args) {
        MessageService emailService = new EmailService();
        Notification notification = new Notification(emailService);
        notification.send("Hello, Dependency Inversion Principle!", "[email protected]");
    }
}

Explanation:

  • MessageService: Interface representing the abstraction.
  • EmailService: Concrete implementation of the abstraction.
  • Notification: High-level module depending on the abstraction.

6. Encapsulate What Varies

Definition: Identify the aspects of your application that vary and separate them from what stays the same.

Example:

// Encapsulating the varying part: Payment Strategy
public interface PaymentStrategy {
    void pay(int amount);
}

public class CreditCardPayment implements PaymentStrategy {
    @Override
    public void pay(int amount) {
        System.out.println("Paid " + amount + " using Credit Card");
    }
}

public class PayPalPayment implements PaymentStrategy {
    @Override
    public void pay(int amount) {
        System.out.println("Paid " + amount + " using PayPal");
    }
}

// Context using the strategy
public class ShoppingCart {
    private PaymentStrategy paymentStrategy;

    public void setPaymentStrategy(PaymentStrategy paymentStrategy) {
        this.paymentStrategy = paymentStrategy;
    }

    public void checkout(int amount) {
        paymentStrategy.pay(amount);
    }
}

// Usage
public class Main {
    public static void main(String[] args) {
        ShoppingCart cart = new ShoppingCart();
        cart.setPaymentStrategy(new CreditCardPayment());
        cart.checkout(100);

        cart.setPaymentStrategy(new PayPalPayment());
        cart.checkout(200);
    }
}

Explanation:

  • PaymentStrategy: Interface representing the varying part.
  • CreditCardPayment and PayPalPayment: Concrete implementations of the varying part.
  • ShoppingCart: Context using the strategy.

7. DRY (Don't Repeat Yourself)

Definition: Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.

Avoid duplicate code by abstracting common things and placing them in a single location.

Example:

public class UserService {
    public User getUserById(String userId) {
        // Common logic to fetch user by ID
    }

    public void updateUser(User user) {
        // Common logic to update user
    }
}

Explanation:

  • UserService: Contains common methods for user operations, avoiding repetition.

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

Definition: Don't add functionality until it is necessary.

Example:

public class UserService {
    public void createUser(String name, String email) {
        // Only the necessary code for creating a user
    }
}

Explanation:

  • UserService: Only contains code for creating a user, no unnecessary features.

9. KISS (Keep It Simple, Stupid)

Definition: Simplicity should be a key goal in design, and unnecessary complexity should be avoided.

Example:

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }

    public int subtract(int a, int b) {
        return a - b;
    }
}

Explanation:

  • Calculator: Simple methods for basic arithmetic operations, no unnecessary complexity.

10. Composition over Inheritance

Definition: Favor composition over inheritance to achieve code reuse and flexibility.

Example:

// Composition
public class Engine {
    public void start() {
        System.out.println("Engine started");
    }
}

public class Car {
    private Engine engine;

    public Car(Engine engine) {
        this.engine = engine;
    }

    public void start() {
        engine.start();
        System.out.println("Car started");
    }
}

Explanation:

  • Engine: A class representing an engine.
  • Car: Uses an instance of Engine (composition) rather than inheriting from it.

11. Dependency Injection

Definition: A design pattern that allows a class to receive its dependencies from an external source rather than creating them itself.

Example:

// Dependency Injection
public interface Service {
    void execute();
}

public class ServiceImpl implements Service {
    @Override
    public void execute() {
        System.out.println("Service executed");
    }
}

// Injector class
public class ServiceInjector {
    public static Service getService() {
        return new ServiceImpl();
    }
}

// Client class
public class Client {
    private Service service;

    // Constructor injection
    public Client

(Service service) {
        this.service = service;
    }

    public void doSomething() {
        service.execute();
    }

    public static void main(String[] args) {
        // Injecting the dependency
        Service service = ServiceInjector.getService();
        Client client = new Client(service);
        client.doSomething();
    }
}

Explanation:

  • Service: Interface representing a service.
  • ServiceImpl: Concrete implementation of the service.
  • ServiceInjector: Provides the service instance.
  • Client: Receives the service dependency through constructor injection.

12. Conclusion

Object-Oriented Design principles are essential for building scalable, maintainable, and robust software systems. By adhering to principles like SRP, OCP, LSP, ISP, and DIP, as well as best practices like DRY, YAGNI, KISS, and composition over inheritance, developers can create software that is easy to understand, extend, and maintain. Additionally, applying Dependency Injection and encapsulating what varies can further enhance the flexibility and testability of your code. Understanding and applying these principles is key to becoming an effective Java developer.

Comments