Java Implementation of Must Know Design Patterns

Creational Design Patterns

1-) Singleton Design Pattern

Singleton design pattern ensures that a class has only one instance and provides a global point of access to that instance. We often use it when we want to limit the number of instances of a class to one, such as managing a configuration manager, a database connection pool, or a logger.

Here is the simple example on how to implement this design pattern:

import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;

public class LoggingService {
    private static LoggingService instance;
    private PrintWriter logFile;

    // 🌟 We use private constructor to prevent direct access 🌟
    private LoggingService() {
        try {
            logFile = new PrintWriter(new FileWriter("application.log", true));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static synchronized LoggingService getInstance() {
        // We ensure that instance only created once
        if (instance == null) {
            instance = new LoggingService();
        }
        return instance;
    }

    public synchronized void log(String message) {
        logFile.println(message);
        logFile.flush();
    }

    public synchronized void closeLog() {
        logFile.close();
    }
}

In this example, there are couple of things to focus on:

  1. LoggingService has a private constructor, preventing direct access.
  2. The getInstance method that we wrote is a static method that provides a single point of access to the instance of the LoggingService. It uses lazy initialization to create the instance only when it’s needed.
  3. The log method allows us to log a message to the file, and it’s synchronized to ensure thread safety.
  4. The closeLog method is used to close the log file when we’re done with it.

Client Code:

public class MyApp {

    public static void main(String[] args) {
        //It will create the instance once
        LoggingService logger = LoggingService.getInstance();

        logger.log("Application started.");

        for (int i = 0; i < 10; i++) {
            logger.log("Processing task " + i);
        }

        logger.log("Application finished.");

        logger.closeLog();
    }

}

This Singleton pattern that we implement, ensures that there’s only one LoggingService instance, and multiple parts of the application can log messages to the same file without creating multiple log files or logger instances. It also provides a centralized point for managing the log file.

Why Do We Do This and How Would This Helps Us in Real Life Application?

1-) It ensures that a class has only one instance and provides a global point of access to that instance, which can be important in situations where having multiple instances could lead to problems. For example, in a logging service, having multiple log files would make it difficult to track and manage logs.

2-) Singleton allows efficient management of resources. For example, if we have a pool of connections to a database, limiting it to a single instance ensures that we do not consume resources by creating a large number of database connections.

3-) When implemented correctly, the Singleton pattern can provide thread safety by ensuring that only one instance is ever created. This is crucial in multithreaded environments to prevent race conditions and ensure proper synchronization.

2-) Builder Design Pattern

The Builder Design Pattern is a creational pattern that allows us to create complex objects step by step. We use it when an object has a large number of parameters, some of which have default values, and we want to make the object creation more readable and maintainable.

Here is the example of this design pattern in Java:

// Computer class to be built
public class Computer {

    private String cpu;
    private int ram;
    private int storage;
    private String gpu;
    private boolean hasBluetooth;
    private boolean hasWifi;

    private Computer(String cpu, int ram, int storage, String gpu, boolean hasBluetooth, boolean hasWifi) {
        this.cpu = cpu;
        this.ram = ram;
        this.storage = storage;
        this.gpu = gpu;
        this.hasBluetooth = hasBluetooth;
        this.hasWifi = hasWifi;
    }

    // Getters

    @Override
    public String toString() {
        return "Computer{" +
                "cpu='" + cpu + '\'' +
                ", ram=" + ram +
                ", storage=" + storage +
                ", gpu='" + gpu + '\'' +
                ", hasBluetooth=" + hasBluetooth +
                ", hasWifi=" + hasWifi +
                '}';
    }

    public static class ComputerBuilder {
        private String cpu;
        private int ram;
        private int storage;
        private String gpu;
        private boolean hasBluetooth;
        private boolean hasWifi;

        // Setters for optional parameters

        public ComputerBuilder(String cpu, int ram, int storage) {
            this.cpu = cpu;
            this.ram = ram;
            this.storage = storage;
        }

        public ComputerBuilder withGPU(String gpu) {
            this.gpu = gpu;
            return this;
        }

        public ComputerBuilder withBluetooth(boolean hasBluetooth) {
            this.hasBluetooth = hasBluetooth;
            return this;
        }

        public ComputerBuilder withWifi(boolean hasWifi) {
            this.hasWifi = hasWifi;
            return this;
        }

        public Computer build() {
            return new Computer(cpu, ram, storage, gpu, hasBluetooth, hasWifi);
        }
    }
}
public class ComputerBuilderExample {

    public static void main(String[] args) {

        Computer customComputer = new Computer.ComputerBuilder("Intel i9", 32, 1000)
                .withGPU("NVIDIA RTX 3090")
                .withBluetooth(true)
                .withWifi(true)
                .build();

        System.out.println(customComputer.toString());
    }
}

Why Do We Do This and How Would This Helps Us in Real Life Application?

1-) It simplifies the construction of complex objects by breaking down the process into smaller, manageable steps. This makes the code more readable and maintainable.

2-) In scenarios where an object has many optional parameters, using the Builder pattern allows us to set only the parameters we need, omitting those with default values. This avoids constructor overloads with numerous parameters.

3-) By chaining method calls to set different attributes, the code becomes more self-explanatory and easier to understand. This is especially beneficial when dealing with objects that have a large number of properties.

4-) If the object’s structure needs to change in the future, we can update the builder to accommodate these changes without modifying the client code. This enhances the code’s flexibility and adaptability.

In real-world applications, we might encounter scenarios where the Builder pattern is beneficial, such as:

  • Configuring a complex system or object with numerous parameters, some of which are optional.
  • Ensuring that certain objects cannot be modified after creation for thread safety or data integrity.
  • Many APIs, such as those for database connections or network requests, use the Builder pattern to provide a flexible and expressive way to configure requests or connections.
  • When deserializing objects from external data sources (e.g., JSON or XML), builders can be used to construct objects incrementally from the data.

3-) Factory Method Design Pattern

The Factory Method design pattern is used to create objects without specifying the exact class of object that will be created. It works by defining an interface for creating an object (the factory method) and allowing subclasses to alter the type of objects that will be created.

Let’s create a simplified example for a pizza restaurant with various pizza types and their corresponding factories.

// Let's Define Interface

interface Pizza {
    void prepare();
    void bake();
    void cut();
    void box();
}

Let’s create concrete products.

class MargheritaPizza implements Pizza {

    @Override
    public void prepare() {
        System.out.println("Preparing Margherita Pizza...");
    }

    @Override
    public void bake() {
        System.out.println("Baking Margherita Pizza...");
    }

    @Override
    public void cut() {
        System.out.println("Cutting Margherita Pizza...");
    }

    @Override
    public void box() {
        System.out.println("Boxing Margherita Pizza...");
    }
}

class PepperoniPizza implements Pizza {
    @Override
    public void prepare() {
        System.out.println("Preparing Pepperoni Pizza...");
    }

    @Override
    public void bake() {
        System.out.println("Baking Pepperoni Pizza...");
    }

    @Override
    public void cut() {
        System.out.println("Cutting Pepperoni Pizza...");
    }

    @Override
    public void box() {
        System.out.println("Boxing Pepperoni Pizza...");
    }
}

Defining Factory Method Interface:

interface PizzaFactory {
    Pizza createPizza();
}

Implementing this Factory Interface:

class MargheritaPizzaFactory implements PizzaFactory {
    @Override
    public Pizza createPizza() {
        return new MargheritaPizza();
    }
}

class PepperoniPizzaFactory implements PizzaFactory {
    @Override
    public Pizza createPizza() {
        return new PepperoniPizza();
    }
}

Client Code:

public class PizzaStore {
    public static void main(String[] args) {

        PizzaFactory margheritaFactory = new MargheritaPizzaFactory();
        Pizza margheritaPizza = margheritaFactory.createPizza();

        margheritaPizza.prepare();
        margheritaPizza.bake();
        margheritaPizza.cut();
        margheritaPizza.box();

        PizzaFactory pepperoniFactory = new PepperoniPizzaFactory();
        Pizza pepperoniPizza = pepperoniFactory.createPizza();

        pepperoniPizza.prepare();
        pepperoniPizza.bake();
        pepperoniPizza.cut();
        pepperoniPizza.box();
    }
}

Why Do We Do This and How Would This Helps Us in Real Life Application?

1-) Factory Method decouples the client code from the concrete classes it creates. Clients don’t need to know the exact class of objects they use; they rely on interfaces and abstract classes. This reduces the dependencies between components, making the system more maintainable and adaptable.

2-) It allows for easy addition of new types of objects or product variants (e.g., new pizza flavors, new vehicle types) without modifying existing code. We can create new concrete factories and products without affecting the client code.

3-) Factory Method promotes the use of abstract classes and interfaces, which provide a level of abstraction. This abstraction helps us developers focus on high-level design decisions rather than low-level implementation details.

4-) Factories can be reused across different parts of an application or even in different applications. For example, if we have a VehicleFactory, we can use it in various parts of our transportation-related application.

5-) In software that supports multiple languages or regions, Factory Method can be used to create objects tailored to specific locales. For instance, a LocalizationFactory could create objects with language-specific behavior.

6-) Consistency: By using factory methods, we ensure that objects are created consistently with a specific pattern or configuration. This can be crucial in scenarios where consistency is essential, such as in manufacturing or game development.

4-) Abstract Factory Design Pattern

Abstract Factory is a creational design pattern that provides an interface for creating families of related or dependent objects without specifying their concrete classes.

Let’s create a GUI framework for multiple operating systems (OS) like Windows and macOS. We’ll abstract the creation of buttons and checkboxes for each OS.

public interface GUIFactory {
    Button createButton();
    Checkbox createCheckbox();
}
public class WindowsFactory implements GUIFactory {
    @Override
    public Button createButton() {
        return new WindowsButton();
    }

    @Override
    public Checkbox createCheckbox() {
        return new WindowsCheckbox();
    }
}

public class MacOSFactory implements GUIFactory {
    @Override
    public Button createButton() {
        return new MacOSButton();
    }

    @Override
    public Checkbox createCheckbox() {
        return new MacOSCheckbox();
    }
}

Let’s define the abstract product classes Button and Checkbox:

public abstract class Button {
    public abstract void paint();
}

public abstract class Checkbox {
    public abstract void render();
}
public class WindowsButton extends Button {
    @Override
    public void paint() {
        System.out.println("Rendering a Windows button.");
    }
}

public class MacOSButton extends Button {
    @Override
    public void paint() {
        System.out.println("Rendering a macOS button.");
    }
}

public class WindowsCheckbox extends Checkbox {
    @Override
    public void render() {
        System.out.println("Rendering a Windows checkbox.");
    }
}

public class MacOSCheckbox extends Checkbox {
    @Override
    public void render() {
        System.out.println("Rendering a macOS checkbox.");
    }
}

Let us use the abstract factory to create GUI components based on the selected OS:

public class Application {
    public static void main(String[] args) {

        GUIFactory windowsFactory = new WindowsFactory();
        Button windowsButton = windowsFactory.createButton();
        Checkbox windowsCheckbox = windowsFactory.createCheckbox();

        windowsButton.paint();
        windowsCheckbox.render();


        GUIFactory macOsFactory = new MacOSFactory();
        Button macOsButton = macOsFactory.createButton();
        Checkbox macOsCheckbox = macOsFactory.createCheckbox();

        macOsButton.paint();
        macOsCheckbox.render();
    }
}

Why Do We Do This and How Would This Helps Us in Real Life Application?

1-) Design patterns help us create modular and extensible code. With Factory Method and Abstract Factory, we can add new product types or families without modifying existing code, making our system more adaptable to change.

2-) These patterns abstract the process of object creation, encapsulating the details of instantiation. This reduces the coupling between the client code and the concrete classes, promoting better separation of concerns and improved maintainability.

3-) Abstract Factory ensures that the created objects are compatible with each other, as they belong to the same family. This guarantees a consistent and reliable system, reducing the chances of runtime errors.

4-) Abstract Factory is particularly useful for creating cross-platform applications. For example, we can have different concrete factories for Windows and macOS, ensuring that GUI components are tailored to each platform while maintaining a consistent interface for the rest of the application.

Real-life applications that benefit from these patterns include:

  • Graphical User Interfaces (GUIs): GUI frameworks often use Abstract Factory to create UI components specific to different operating systems or themes.
  • Database Access: Database libraries can use Factory Method to create different types of database connections or query builders.
  • Game Development: Games may use Factory Method and Abstract Factory to create game objects, characters, weapons, or levels, ensuring compatibility within the game world.
  • Multi-Platform Software: Cross-platform mobile apps or desktop applications can utilize Abstract Factory to ensure consistent user experiences across different platforms.

Key Difference Between Factory Method and Abstract Factory Patterns:

  • Factory Method focuses on creating a single product (object) and allows subclasses to choose the concrete class of the product. It’s about creating an object, one at a time.
  • Abstract Factory, on the other hand, focuses on creating families of related or dependent objects. It involves multiple factory methods (one per product type) and is concerned with creating a set of objects that work together.

In summary, use Factory Method when we want to delegate the decision of which concrete class to instantiate to subclasses, and use Abstract Factory when we need to ensure that multiple objects created together are compatible and part of the same family.

5-) Prototype Design Pattern

The Prototype design pattern is a creational design pattern that allows us to create copies of objects, known as prototypes, without specifying their exact class.

Let’s imagine we are building a game where we have different types of monsters, and each monster has various attributes. We’ll use the Prototype pattern to create new monsters by cloning existing prototypes.

public interface Monster {
    Monster clone();
    void attack();
    void flee();
}
public class Goblin implements Monster {
    @Override
    public Monster clone() {
        return new Goblin();
    }

    @Override
    public void attack() {
        System.out.println("Goblin attacks with a club!");
    }

    @Override
    public void flee() {
        System.out.println("Goblin runs away!");
    }
}

public class Dragon implements Monster {
    @Override
    public Monster clone() {
        return new Dragon();
    }

    @Override
    public void attack() {
        System.out.println("Dragon breathes fire!");
    }

    @Override
    public void flee() {
        System.out.println("Dragon flies away!");
    }
}

Let’s create a MonsterRegistry class that manages the prototypes:

import java.util.HashMap;
import java.util.Map;

public class MonsterRegistry {
    private static Map<String, Monster> prototypes = new HashMap<>();

    static {
        prototypes.put("Goblin", new Goblin());
        prototypes.put("Dragon", new Dragon());
    }

    public static Monster getMonster(String type) {
        return prototypes.get(type).clone();
    }
}
public class Game {
    public static void main(String[] args) {
        Monster goblin = MonsterRegistry.getMonster("Goblin");
        Monster dragon = MonsterRegistry.getMonster("Dragon");

        goblin.attack();
        dragon.attack();

        goblin.flee();
        dragon.flee();
    }
}

In this example, we have created a MonsterRegistry that manages the prototypes of different monster types. When we want a new monster, we call getMonster from the registry, which clones the prototype and returns a new instance. This demonstrates the Prototype design pattern, allowing us to create new objects by copying existing ones without knowing their concrete classes.

1-) Creating objects from scratch can be resource-intensive, especially if they involve complex initialization processes or external resources. Prototype allows us to create new objects by cloning existing ones, which can be significantly faster and more efficient.

2-) In some cases, objects can be very complex to instantiate with many dependencies and configuration settings. With the Prototype pattern, we set up these complex objects once as prototypes and then clone them when needed, reducing the complexity of object creation.

3-) Prototypes can serve as templates for creating objects with different configurations. We can customize the cloned objects as per your needs, making it easier to create variations of complex objects.

4-) Without the Prototype pattern, we might need to create subclasses for each variation of an object. With prototypes, we can avoid excessive subclassing, which can lead to a complex class hierarchy.

Real-world examples of the Prototype pattern include:

  • Generating various types of documents with different content and layouts is more efficient using prototypes.
  • Design tools often use the Prototype pattern to create copies of shapes, objects, or components to be placed in a canvas. This allows users to manipulate objects without affecting the original.
  • In database connection pooling libraries, connection objects are often pooled, and when a new connection is needed, a prototype connection is cloned, which is faster than creating a new connection from scratch.
  • Java provides a built-in Cloneable interface and the clone() method, which can be used to implement the Prototype pattern.

In summary, the Prototype design pattern is valuable in real-world applications because it promotes efficient object creation, reduces complexity, allows customization, and provides flexibility to work with objects at runtime.

It’s particularly useful when dealing with complex objects or when we need to create multiple instances of similar objects with slight variations.

Structural Design Patterns

1-) Adapter Pattern Design

The Adapter Design Pattern is a structural design pattern that allows objects with incompatible interfaces to work together. It acts as a bridge between two incompatible interfaces.

Imagine we are building a multimedia player application that can play different types of audio files.

interface MediaPlayer {
    void play(String audioType, String fileName);
}

Concrete classes:

class MP3Player {
    public void playMP3(String fileName) {
        System.out.println("Playing MP3 file: " + fileName);
    }
}

class WAVPlayer {
    public void playWAV(String fileName) {
        System.out.println("Playing WAV file: " + fileName);
    }
}

Creating adapter class to make the existing players compatible with the MediaPlayer interface:

class MediaAdapter implements MediaPlayer {

    private MP3Player mp3Player;
    private WAVPlayer wavPlayer;

    public MediaAdapter() {
        mp3Player = new MP3Player();
        wavPlayer = new WAVPlayer();
    }

    @Override
    public void play(String audioType, String fileName) {
        if (audioType.equalsIgnoreCase("MP3")) {
            mp3Player.playMP3(fileName);
        } else if (audioType.equalsIgnoreCase("WAV")) {
            wavPlayer.playWAV(fileName);
        } else {
            System.out.println("Invalid media type: " + audioType);
        }
    }
}
class MultimediaPlayer {
    private MediaPlayer mediaPlayer;

    public MultimediaPlayer() {
        mediaPlayer = new MediaAdapter();
    }

    public void playAudio(String audioType, String fileName) {
        mediaPlayer.play(audioType, fileName);
    }
}

Client Code:

public class Main {
    public static void main(String[] args) {
        MultimediaPlayer player = new MultimediaPlayer();

        player.playAudio("MP3", "song.mp3");
        player.playAudio("WAV", "music.wav");
        player.playAudio("MP4", "video.mp4"); 

        /*
        Output:
        Playing MP3 file: song.mp3
        Playing WAV file: music.wav
        Invalid media type: MP4
        */
    }
}

Why Do We Do This and How Would This Helps Us in Real Life Application?

1-) In many real-world scenarios, we may have existing classes or libraries that have interfaces that are incompatible with the rest of our application. Adapters allow these incompatible interfaces to work together, ensuring that our code can use these classes without modification.

2-) Adapters promote code reusability. We can adapt multiple incompatible classes or components to a common interface, making it easier to reuse existing code in new projects or scenarios.

3-) When working with legacy code or third-party libraries that cannot be modified, adapters provide a way to integrate them into modern systems. This is particularly valuable when we want to use older components in a new application architecture.

4-) The Adapter Pattern aligns with the open-closed principle from the SOLID principles of object-oriented design. It allows us to add new adapters for different classes or components without modifying existing code, making our system more open for extension and closed for modification.

Real-World Example:

Imagine we’re developing a mobile app that needs to access various cloud storage services like Dropbox, Google Drive, and OneDrive. Each of these services has its own unique API and methods for file management.

By using the Adapter Pattern, we can create adapters for each cloud storage service that implements a common FileStorage interface with methods like uploadFiledownloadFile, and listFiles. Our app can then interact with these cloud services through the FileStorage interface, making it easy to switch between different storage providers or add new ones in the future without changing the core application logic.

2-) Proxy Design Pattern

The Proxy Design Pattern is a structural design pattern that provides a surrogate or placeholder for another object to control access to it. It is often used for scenarios like lazy initialization, access control, logging, monitoring, and more.

Imagine a virtual office access system where employees can enter a building by scanning their employee cards. However, we want to add an extra layer of security by using a proxy object.

Interface:

interface OfficeAccess {
    void enter();
}
class RealOfficeAccess implements OfficeAccess {
    private String employeeName;

    public RealOfficeAccess(String employeeName) {
        this.employeeName = employeeName;
    }

    @Override
    public void enter() {
        System.out.println(employeeName + " has entered the office.");
    }
}
class ProxyOfficeAccess implements OfficeAccess {
    private String employeeName;
    private RealOfficeAccess realAccess;

    public ProxyOfficeAccess(String employeeName) {
        this.employeeName = employeeName;
    }

    @Override
    public void enter() {
        // (lazy initialization)
        if (realAccess == null) {
            realAccess = new RealOfficeAccess(employeeName);
        }
               
        realAccess.enter();
    }
}
public class OfficeAccessClient {
    public static void main(String[] args) {
        OfficeAccess employeeAccess = new ProxyOfficeAccess("John Doe");
        
        employeeAccess.enter();
        // This second enter() will create a RealOfficeAccess object for 
        // John Doe
        employeeAccess.enter();
    }
}

In this example, RealOfficeAccess is the real object representing an employee’s access to the office. The ProxyOfficeAccess is a proxy that controls access to the real object. It performs lazy initialization of the real object and can add extra security checks, logging, or monitoring as needed.

Why Do We Do This and How Would This Helps Us in Real Life Application?

1-) The proxy can enforce access control policies. For example, it can check whether a user has the right permissions to access certain resources or perform specific actions.

2-) The proxy allows for lazy initialization of expensive resources. In some cases, creating an object might be resource-intensive, and by using a proxy, we ensure that the object is only created when actually needed.

3-) The proxy can implement security checks. For instance, it can verify user credentials, validate input data, or perform encryption/decryption before granting access. This is crucial for safeguarding sensitive data and resources.

4-) Logging access events helps in auditing and troubleshooting. By logging when and who accessed a resource, we can track any unauthorized or suspicious activities and also use this information for compliance purposes.

Behavioral Design Patterns

1-) Observer Design Pattern

The Observer Design Pattern is a behavioral pattern that defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.

Imagine we’re building a stock trading application, and we want to notify users when the price of a specific stock changes.

interface StockSubject {
    void register(StockObserver observer);
    void unregister(StockObserver observer);
    void notifyObservers();
}
class StockMarket implements StockSubject {
    private List<StockObserver> observers;
    private String stockName;
    private double price;

    public StockMarket(String stockName, double initialPrice) {
        this.observers = new ArrayList<>();
        this.stockName = stockName;
        this.price = initialPrice;
    }

    public void setPrice(double newPrice) {
        this.price = newPrice;
        notifyObservers();
    }

    @Override
    public void register(StockObserver observer) {
        observers.add(observer);
    }

    @Override
    public void unregister(StockObserver observer) {
        observers.remove(observer);
    }

    @Override
    public void notifyObservers() {
        for (StockObserver observer : observers) {
            observer.update(stockName, price);
        }
    }
}
interface StockObserver {
    void update(String stockName, double price);
}

Concrete Observer:

class Investor implements StockObserver {
    private String name;

    public Investor(String name) {
        this.name = name;
    }

    @Override
    public void update(String stockName, double price) {
        System.out.println(name + " received an update: " + stockName + " price is now $" + price);
    }
}
public class StockMarketDemo {
    public static void main(String[] args) {

        StockMarket stockMarket = new StockMarket("ABC Corp", 100.0);

        Investor investor1 = new Investor("John");
        Investor investor2 = new Investor("Alice");

        stockMarket.register(investor1);
        stockMarket.register(investor2);

        stockMarket.setPrice(110.0); 
        stockMarket.setPrice(95.0);  

        stockMarket.unregister(investor1);

        stockMarket.setPrice(120.0); 
    }
}

1-) It’s particularly useful for implementing real-time systems where changes in one part of the system should trigger updates in other parts. For instance, in a GUI framework, we can use the Observer Pattern to update UI components when underlying data changes.

2-) Many event-driven systems, such as graphical user interfaces, rely on the Observer Pattern. Events (e.g., button clicks, mouse movements) are essentially notifications sent by a source to multiple listeners.

3-) Observers can be thought of as subscribers to a publisher (the subject). This allows broadcasting information to multiple recipients efficiently. In systems like message brokers or chat applications, the Observer Pattern is crucial for distributing messages.

2-) Chain of Responsibility Design Pattern

Chain of Responsibility design pattern is a behavioral pattern that allows us to pass requests along a chain of handlers. Each handler decides either to process the request or to pass it to the next handler in the chain.

Imagine we work for a company, and employees can submit expense reports for approval. Depending on the amount of the expense, different managers have the authority to approve or reject the request.

Handler Interface:

public interface ExpenseHandler {
    void approveExpense(Expense expense);
}

Concrete Handlers:

public class TeamLead implements ExpenseHandler {
    private static final double APPROVAL_LIMIT = 1000;

    private ExpenseHandler nextHandler;

    @Override
    public void approveExpense(Expense expense) {
        if (expense.getAmount() <= APPROVAL_LIMIT) {
            System.out.println("Team Lead approved the expense of $" + expense.getAmount());
        } else if (nextHandler != null) {
            nextHandler.approveExpense(expense);
        }
    }

    public void setNextHandler(ExpenseHandler nextHandler) {
        this.nextHandler = nextHandler;
    }
}



public class Manager implements ExpenseHandler {
    private static final double APPROVAL_LIMIT = 5000;

    private ExpenseHandler nextHandler;

    @Override
    public void approveExpense(Expense expense) {
        if (expense.getAmount() <= APPROVAL_LIMIT) {
            System.out.println("Manager approved the expense of $" + expense.getAmount());
        } else if (nextHandler != null) {
            nextHandler.approveExpense(expense);
        } else {
            System.out.println("Expense exceeds approval limit. Rejected.");
        }
    }

    public void setNextHandler(ExpenseHandler nextHandler) {
        this.nextHandler = nextHandler;
    }
}



public class CEO implements ExpenseHandler {
    @Override
    public void approveExpense(Expense expense) {
        System.out.println("CEO approved the expense of $" + expense.getAmount());
    }
}
public class Expense {
    private double amount;

    public Expense(double amount) {
        this.amount = amount;
    }

    public double getAmount() {
        return amount;
    }
}

Client Code:

public class ExpenseApprovalDemo {
    public static void main(String[] args) {

        TeamLead teamLead = new TeamLead();
        Manager manager = new Manager();
        CEO ceo = new CEO();

        teamLead.setNextHandler(manager);
        manager.setNextHandler(ceo);

        Expense expense1 = new Expense(500);
        Expense expense2 = new Expense(2500);
        Expense expense3 = new Expense(10000);

        teamLead.approveExpense(expense1);
        teamLead.approveExpense(expense2);
        teamLead.approveExpense(expense3);
    }
}

Why Do We Do This and How Would This Helps Us in Real Life Application?

1-) In many systems, there are multiple objects that can handle a particular request, but the sender of the request doesn’t need to know which object will ultimately process it. The Chain of Responsibility decouples the sender from the receiver, allowing us to change or add handlers without affecting the sender.

2-) Handlers can be added, removed, or reordered dynamically, providing a flexible way to change the request processing flow at runtime. For example, we can easily add a new manager with a different approval limit without modifying existing code.

3-) Each handler in the chain has a single responsibility — either processing the request or passing it along. This promotes the Single Responsibility Principle and makes the codebase more maintainable.

4-) Handlers can be reused in different chains or scenarios. For instance, we might have different approval chains for expenses, leave requests, or purchase orders, but we can reuse the same manager or team lead handlers.