Top 9 Essential Design Patterns in Software Engineering

Top 9 Essential Design Patterns in Software Engineering

Practical Examples of Common Design Patterns in Modern Software Development

Introduction

Hello fellas! It's been so long since I wrote something on Hashnode. Again, I am sharing what I learned about the Top 9 Essential Design Patterns in Software Engineering.

Design patterns are a crucial part of software engineering. They provide tested, proven development paradigms, making it easier to solve recurring design problems efficiently. This blog will cover some of the most common design patterns: Prototype, Singleton, Memento, Proxy, Flyweight, Factory, Builder, Strategy, and Pub-Sub patterns.

What are Design Patterns?

Design patterns are reusable solutions to common problems in software design. They offer a way to standardize your code structure and improve code readability, maintainability, and scalability. Originally popularized by the "Gang of Four" (GoF), these patterns have become fundamental in software development.

Categories of Design Patterns

  1. Creational Patterns: Concerned with the way objects are created. Examples: Singleton, Prototype, Factory, Builder.

  2. Structural Patterns: Deal with object composition. Examples: Proxy, Flyweight.

  3. Behavioral Patterns: Focus on communication between objects. Examples: Memento, Strategy, Pub-Sub.

Common Design Patterns

1. Prototype Pattern (Creational)

Intent:

The Prototype Pattern is used to create duplicate objects while keeping performance in mind. It involves implementing a prototype interface that tells the object to create a clone of itself.

This pattern is used when the type of objects to create is determined by a prototypical instance, which is cloned to produce new objects. For example, a board game which can have multiple boards, a chess board, a tictactoe board, etc.

UML Diagram:

+-------------------+
|   Prototype       |
+-------------------+
| + clone()         |
+-------------------+
        ^
        |
+-------+---------+
|     Concrete    |
|    Prototype    |
+-----------------+
| + clone()       |
+-----------------+

Java Code Snippet:

public abstract class Prototype {
    public abstract Prototype clone();
}

public class ConcretePrototype extends Prototype {
    private String state;

    public ConcretePrototype(String state) {
        this.state = state;
    }

    @Override
    public Prototype clone() {
        return new ConcretePrototype(state);
    }

    public String getState() {
        return state;
    }
}

// Usage
public class PrototypeDemo {
    public static void main(String[] args) {
        ConcretePrototype original = new ConcretePrototype("Original");
        ConcretePrototype clone = (ConcretePrototype) original.clone();

        System.out.println("Original state: " + original.getState());
        System.out.println("Clone state: " + clone.getState());
    }
}

2. Singleton Pattern (Creational)

Intent:

Ensure a class has only one instance and provide a global point of access to it.

UML Diagram:

+-------------------+
|    Singleton      |
+-------------------+
| - uniqueInstance  |
+-------------------+
| + getInstance()   |
+-------------------+

Java Code Snippet:

public class Singleton {
    private static Singleton uniqueInstance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (uniqueInstance == null) {
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }

    public void showMessage() {
        System.out.println("Hello from Singleton!");
    }
}

// Usage
public class SingletonDemo {
    public static void main(String[] args) {
        Singleton singleton = Singleton.getInstance();
        singleton.showMessage();
    }
}

3. Memento Pattern (Behavioral)

Intent:

The Memento Pattern is used to restore the state of an object to a previous state. Here, we used to maintain history so that going back to any version in history is super easy. But yeah, it comes at the cost of maximizing the memory usage.

UML Diagram:

+-------------------+       +-------------------+
|   Originator      |<----->|     Memento       |
+-------------------+       +-------------------+
| - state           |       | - state           |
+-------------------+       +-------------------+
| + createMemento() |       | + getState()      |
| + setMemento()    |       +-------------------+
+-------------------+

Java Code Snippet:

public class Memento {
    private String state;

    public Memento(String state) {
        this.state = state;
    }

    public String getState() {
        return state;
    }
}

public class Originator {
    private String state;

    public void setState(String state) {
        this.state = state;
    }

    public String getState() {
        return state;
    }

    public Memento createMemento() {
        return new Memento(state);
    }

    public void setMemento(Memento memento) {
        state = memento.getState();
    }
}

// Usage
public class MementoDemo {
    public static void main(String[] args) {
        Originator originator = new Originator();
        originator.setState("State1");
        Memento memento = originator.createMemento();

        originator.setState("State2");
        originator.setMemento(memento);

        System.out.println("Restored state: " + originator.getState());
    }
}

4. Proxy Pattern (Structural)

Intent:

Provide a surrogate or placeholder for another object to control access to it.

What happens in a proxy design pattern is, that instead of storing the whole representation, we can store proxy( e.g., unique strings) using which the original representation could be created if required on the go.

UML Diagram:

+---------------+
|   Subject     |
+---------------+
| + request()   |
+---------------+
        ^
        |
+-------+---------+
|     RealSubject |
+---------------+
| + request()    |
+---------------+
        ^
        |
+-------+---------+
|     Proxy       |
+---------------+
| + request()    |
+---------------+

Java Code Snippet:

public interface Subject {
    void request();
}

public class RealSubject implements Subject {
    @Override
    public void request() {
        System.out.println("Request processed by RealSubject");
    }
}

public class Proxy implements Subject {
    private RealSubject realSubject;

    @Override
    public void request() {
        if (realSubject == null) {
            realSubject = new RealSubject();
        }
        realSubject.request();
    }
}

// Usage
public class ProxyDemo {
    public static void main(String[] args) {
        Subject proxy = new Proxy();
        proxy.request();
    }
}

5. Flyweight Pattern (Structural)

Intent:

The Flyweight Pattern is used to minimize memory usage by sharing as much data as possible with other similar objects.

A shared history sort of technique could be used to here to optimise on the memory usage.

UML Diagram:

+------------------+
|     Flyweight    |
+------------------+
| + operation()    |
+------------------+
        ^
        |
+-------+-------+
|   Concrete    |
|   Flyweight   |
+---------------+
| + operation() |
+---------------+
        ^
        |
+-------+-------+
| Flyweight     |
|  Factory      |
+---------------+
| + getFlyweight()|
+-----------------+

Java Code Snippet:

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

public interface Flyweight {
    void operation(String extrinsicState);
}

public class ConcreteFlyweight implements Flyweight {
    private final String intrinsicState;

    public ConcreteFlyweight(String intrinsicState) {
        this.intrinsicState = intrinsicState;
    }

    @Override
    public void operation(String extrinsicState) {
        System.out.println("Intrinsic State: " + intrinsicState + ", Extrinsic State: " + extrinsicState);
    }
}

public class FlyweightFactory {
    private final Map<String, Flyweight> flyweights = new HashMap<>();

    public Flyweight getFlyweight(String key) {
        if (!flyweights.containsKey(key)) {
            flyweights.put(key, new ConcreteFlyweight(key));
        }
        return flyweights.get(key);
    }
}

// Usage
public class FlyweightDemo {
    public static void main(String[] args) {
        FlyweightFactory factory = new FlyweightFactory();
        Flyweight flyweight1 = factory.getFlyweight("A");
        Flyweight flyweight2 = factory.getFlyweight("A");

        flyweight1.operation("State1");
        flyweight2.operation("State2");

        System.out.println(flyweight1 == flyweight2); // true
    }
}

6. Factory Pattern (Creational)

Intent:

The Factory Pattern is used to create objects without specifying the exact class of object that will be created. This intends to encapsulate object creation logic at a single place.

UML Diagram:

+-------------------+
|   Creator         |
+-------------------+
| + factoryMethod() |
+-------------------+
        ^
        |
+-------+---------+
|  ConcreteCreator |
+------------------+
| + factoryMethod()|
+------------------+
        ^
        |
+-------+---------+
|   Product       |
+------------------+
| + operation()   |
+------------------+
        ^
        |
+-------+---------+
| ConcreteProduct |
+------------------+
| + operation()   |
+------------------+

Java Code Snippet:

public interface Product {
    void operation();
}

public class ConcreteProduct implements Product {
    @Override
    public void operation() {
        System.out.println("ConcreteProduct operation");
    }
}

public abstract class Creator {
    public abstract Product factoryMethod();
}

public class ConcreteCreator extends Creator {
    @Override
    public Product factoryMethod() {
        return new ConcreteProduct();
    }
}

// Usage
public class FactoryDemo {
    public static void main(String[] args) {
        Creator creator = new ConcreteCreator();
        Product product = creator.factoryMethod();
        product.operation();
    }
}

7. Builder Pattern (Creational)

Intent:

The Builder Pattern is used to construct complex objects step by step. It separates the construction of a complex object from its representation.

Here, object creation becomes easier with any combination of parameters.

UML Diagram:

+------------------+
|    Builder       |
+------------------+
| + buildPart()    |
+----------------

--+
        ^
        |
+-------+--------+
|   Concrete     |
|    Builder     |
+----------------+
| + buildPart()  |
+----------------+
        ^
        |
+-------+-------+
|   Director    |
+---------------+
| + construct() |
+---------------+
        ^
        |
+-------+--------+
|   Product     |
+---------------+
| + addPart()   |
+---------------+

Java Code Snippet:

public class Product {
    private String part1;
    private String part2;

    public void setPart1(String part1) {
        this.part1 = part1;
    }

    public void setPart2(String part2) {
        this.part2 = part2;
    }

    @Override
    public String toString() {
        return "Product [part1=" + part1 + ", part2=" + part2 + "]";
    }
}

public abstract class Builder {
    protected Product product = new Product();

    public abstract void buildPart1();
    public abstract void buildPart2();

    public Product getResult() {
        return product;
    }
}

public class ConcreteBuilder extends Builder {
    @Override
    public void buildPart1() {
        product.setPart1("Part1");
    }

    @Override
    public void buildPart2() {
        product.setPart2("Part2");
    }
}

public class Director {
    private Builder builder;

    public Director(Builder builder) {
        this.builder = builder;
    }

    public Product construct() {
        builder.buildPart1();
        builder.buildPart2();
        return builder.getResult();
    }
}

// Usage
public class BuilderDemo {
    public static void main(String[] args) {
        Builder builder = new ConcreteBuilder();
        Director director = new Director(builder);
        Product product = director.construct();
        System.out.println(product);
    }
}

8. Strategy Pattern (Behavioral)

Intent:

The Strategy Pattern is used to create a family of algorithms, encapsulate each one, and make them interchangeable. This pattern lets the algorithm vary independently from clients that use it.

Instead of implementing a single algorithm directly, code receives runtime instructions as to which in a family of algorithms to use.

UML Diagram:

+-------------------+
|    Context        |
+-------------------+
| - strategy        |
+-------------------+
| + contextInterface() |
+-------------------+
        ^
        |
+-------+---------+
|  Strategy        |
+------------------+
| + algorithmInterface() |
+------------------+
        ^
        |
+-------+--------+
| ConcreteStrategy |
+-----------------+
| + algorithmInterface() |
+-----------------+

Java Code Snippet:

public interface Strategy {
    void algorithmInterface();
}

public class ConcreteStrategyA implements Strategy {
    @Override
    public void algorithmInterface() {
        System.out.println("Algorithm A");
    }
}

public class ConcreteStrategyB implements Strategy {
    @Override
    public void algorithmInterface() {
        System.out.println("Algorithm B");
    }
}

public class Context {
    private Strategy strategy;

    public Context(Strategy strategy) {
        this.strategy = strategy;
    }

    public void contextInterface() {
        strategy.algorithmInterface();
    }
}

// Usage
public class StrategyDemo {
    public static void main(String[] args) {
        Context context = new Context(new ConcreteStrategyA());
        context.contextInterface();

        context = new Context(new ConcreteStrategyB());
        context.contextInterface();
    }
}

9. Pub-Sub Pattern (Behavioral)

Intent:

The Pub-Sub Pattern, short for Publisher-Subscriber Pattern, allows objects to subscribe to event streams and get notified when events occur.

Basically, this model is based on decoupling components in a system where the publisher is responsible for sending messages whereas the subscriber is responsible for receiving those messages.

UML Diagram:

+-----------------+
|   Publisher     |
+-----------------+
| + subscribe()   |
| + unsubscribe() |
| + notify()      |
+-----------------+
        ^
        |
+-------+---------+
|   Subscriber    |
+-----------------+
| + update()      |
+-----------------+

Java Code Snippet:

import java.util.ArrayList;
import java.util.List;

public interface Subscriber {
    void update(String message);
}

public class ConcreteSubscriber implements Subscriber {
    private String name;

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

    @Override
    public void update(String message) {
        System.out.println(name + " received: " + message);
    }
}

public class Publisher {
    private List<Subscriber> subscribers = new ArrayList<>();

    public void subscribe(Subscriber subscriber) {
        subscribers.add(subscriber);
    }

    public void unsubscribe(Subscriber subscriber) {
        subscribers.remove(subscriber);
    }

    public void notifySubscribers(String message) {
        for (Subscriber subscriber : subscribers) {
            subscriber.update(message);
        }
    }
}

// Usage
public class PubSubDemo {
    public static void main(String[] args) {
        Publisher publisher = new Publisher();

        Subscriber subscriber1 = new ConcreteSubscriber("Subscriber 1");
        Subscriber subscriber2 = new ConcreteSubscriber("Subscriber 2");

        publisher.subscribe(subscriber1);
        publisher.subscribe(subscriber2);

        publisher.notifySubscribers("Event 1");
    }
}

How to Choose the Right Design Pattern

Choosing the right design pattern depends on various factors, including the specific requirements of your project, scalability, and maintainability. Be mindful of the following:

  • Project Requirements: Match the pattern to the problem at hand.

  • Scalability: Consider how the pattern will perform as the system grows.

  • Maintainability: Ensure the pattern chosen will make the system easier to maintain.

Best Practices

  • When to Use: Use design patterns judiciously; overusing them can make your code unnecessarily complex.

  • Documentation: Document the use of design patterns in your code to ensure that other developers can understand the design decisions.

Conclusion

Design patterns are powerful tools in software engineering that can help you create more robust, maintainable, and scalable code. By understanding and applying these common design patterns, you can improve the quality of your software and streamline the development process. Share your experiences and thoughts in the comments below!

Sign up for my email newsletter and get notified about new blog posts.

Thank you for reading!

Follow me on my socials, GitHub, LinkedIn and Twitter!

Did you find this article valuable?

Support Anant Singh Raghuvanshi by becoming a sponsor. Any amount is appreciated!