DEV Community

SJ W
SJ W

Posted on • Updated on

Design Patterns (Creational)

Introduction

In the fifth post of my blog, I will go over all the design patterns that can be applied as solutions to various problems that we commonly face during the design process of the SW. The proper design of the SW is extremely crucial, for the fact that a poorly designed SW is an absolute nightmare to deal with as a developer and a customer. For developers, it can lead to issues of SW vulnerabilities, scalability, and the bad reputation of a company, and for customers, performance, UX, etc. What's a better way to mitigate those issues than to properly design the SW from scratch? By utilizing the well-known design strategies used by developers all across the programming industries, you can simply save others from having to spend an enormous amount of time fixing bugs and trying to make sense of the code you wrote, and ultimately make everyone who comes into contact with the SW happy. I am not trying to say that these are some sort of bulletproof, one-size-fits-all solutions that may completely eliminate all the problems that the SW may have later down the line; however, we can mitigate some of the unnecessary issues arising from haphazardly approaching the design process by incorporating the well-proven, brilliant techniques into the SW. 

Prior to writing this, I have to remind you first that I do not have expertise in this domain of knowledge. The primary purpose of writing this post is to deepen my understanding of design patterns by writing about what I learn and know in order to improve my skill set as a programmer. If you see any errors or mistakes in my writing about pretty much anything you see, feel free to point them out by commenting below so that I can fix them! 

What are Design Patterns?

Since the inception of the very first computer, the ever-shifting landscape of the computing world has seen an incomprehensible amount of software that ultimately changed the history of mankind forever. The combination of physical components that together make up the backbone of what we know as the computer is basically nothing without the SW. For example, an operating system provides us various tools that enable us to gracefully and effectively control this marvel of the invention without having to directly communicate with various pieces of hardware. It also provides us with a virtual platform on which we run various pieces of application software that aid us in conveniently managing and solving our daily tasks. In fact, you are using an application called Web Browser on your smartphone or computer to read this very post right now to learn more about Design Pattern, and the very web browser you are using is made by a group of people intent on helping people connect to the ever-increasing world of the Internet. 

Creating well-run, maintained software requires one to carefully plan everything from the start to make sure that it has a good foundational background from which things can flourish. Unfortunately, people who came to invent the SW at the outset of the computing world did not have the luxury of developing the SW with such knowledge. Since it was something that was so new, they had to pave ways for us to get to where we are today by experimenting and trying various things that gave birth to brilliant solutions that we resort to to this day, but it was not expected to be without issues. Without all the tools and methodologies at their disposal, creating the SW was not really efficient and structured. Some of the problems they faced included failing to meet the requirements, being overdue, budgeting, etc. Recognizing the need to address those issues, people decided to come up with the discipline of SW Engineering which focused on processes, methodologies, and guidelines to develop the SW as efficiently and methodically as possible.

Design patterns aim to make the design process of the software's development much more pleasant and, most importantly, efficient. During the design process, we often come across instances where we may have no idea how to come up with fundamentally sound solutions to mitigate the unforeseen issues that can arise in the future. Design patterns provide you with a set of ideas or guidelines on how to think about approaching the issues from a high-level view. I would like to think of it as the template from which we can standardize approaches to coming up with robust solutions to all the recurring problems we often face. It does not necessarily give you a set of concrete solutions that may be incorporated right into the SW or encompasses only a few handfuls of situations to which design patterns could be applied, but sort of guides you towards where you may find the solutions. Why racking your brains trying to reinvent the wheel when you can simply resort to the proven-to-work guidance from the brilliance of those that came before us on the implementation of the SW? You also get to reap the benefits of efficiency, scalability, flexibility, etc.!

Currently, there are about 22 design patterns that are well-used to this day and are grouped into three different types: Creational, structural, and behavioral. Each category aims to address different types of problems, and their names simply give away which to use for the types of problems you are trying to resolve. Creational Patterns deal with the creation of objects. Structural patterns deal with merging two or more objects into compositional structures. Behavioral patterns streamline communication and delegate various responsibilities to objects in situations. As the first part of the series, I am going to cover creational patterns first—their definition, description, and how we may apply each pattern to instances that we may encounter in the future—and structural patterns and behavioral patterns subsequently. 

Creational Patterns

Click patterns below to navigate

  1. Factory Method
  2. Abstract Factory
  3. Builder
  4. Prototype
  5. Singleton

My Definition

As the name suggests, Creational Patterns mainly deal with creating objects. Here are some of the concepts that you will often encounter while going through each pattern:

  • Abstraction
  • Reusability

Abstraction

While going through and researching various aspects of each pattern of this category, I noticed that some of the patterns use some sort of an abstract class that is used to create other subclasses. I guess, since Design Patterns are for OOP in general, any concept related to OOP is often utilized. So how does an abstract class give you an advantage? One of the biggest advantages you can have from this is that you can simply add new subclasses, without having to introduce the new code to many different parts of the system. Here is an example: let's say you are creating an application for reading the documents of various types. Regardless of the type of document, you can assume that they all share the following functionalities: you must be able to open, write, save and close. Let's say that you decide to create a function for creating a document. Instead of having to create functions for opening each type of document tediously, you can simply create a function that accepts any object that inherits the abstract class, and invoke "open()" of its abstract class. Since you can add more concrete classes that represent the new types of document, from now on, as a result, your system your system becomes more flexible!

Economical

Some of the Creational Patterns we are going to delve into put a huge emphasis on the economical use of the objects. What I mean by economical is that it aims to reduce the unnecessary creation and use of the objects. One of the examples would be a pattern called Singleton, which ensures that there is one object of a specific class that is created and gets shared by other objects. There is also a pattern called Prototype, which deals with cloning an object, entirely avoiding the process of generating a new object - sometimes, it's just more time-consuming to generate a new object from the direct instantiation of an object than to clone an object that already exists in the memory. We will go deeper into those patterns mentioned above, so make sure to read it until the end!

Factory Method

The Factory Method is a design pattern that emphasizes using an abstract Factory class to provide an interface for creating an object, with its concrete subclasses determining the type of object to create. Let's delve into this pattern with a simple example:

Example

  • Scenario: Your business has a Payment Processor class that initially accepts only credit cards as a valid payment method. However, as your business grows, there is a demand to also accept PayPal as a payment method. Since your existing Payment Processor class is strictly for accepting credit cards, you would need to create a separate Payment Processor class for PayPal. While creating a separate Payment Processor class for each payment method might not always be overly complicated, in a large system, this approach could require various parts of the code to be updated to start accepting new payment methods.
  • Solution: To resolve this issue, an abstract factory class can be used to provide interfaces for creating objects, although the actual object creation implementation may vary depending on its subclasses. This allows for having the client code that accepts any subclass of an abstract Factory class. The client code creates a specific payment method object, using the Factory object, and then processes a payment with it. Check out the code below or the GitHub link here
// Abstract class called PaymentMethod
class PaymentMethod {
    constructor() {
        if (this.constructor === PaymentMethod) {
            throw new Error("Cannot instantiate abstract class!");
        }
    }
    connectToGateway() {
        throw new Error("This method must be overwritten!");
    }
    pay(amount) {
        throw new Error("This method must be overwritten!");
    }
}

// Concrete class called CreditCard
class CreditCard extends PaymentMethod {
    #gateway;
    constructor(cardNumber, cardHolder, expiryDate) {
        super();
        this.#gateway = this.connectToGateway(cardNumber, cardHolder, expiryDate);
    }
    connectToGateway(cardNumber, cardHolder, expiryDate) {
        console.log('Connecting to payment gateway...');
    }
    pay(amount) {
        console.log(`Paying $${amount} using Credit Card`);
    }
}

// Concrete class called PayPal
class PayPal extends PaymentMethod {
    #gateway;
    constructor(email, password) {
        super();
        this.#gateway = this.connectToGateway(email, password);
    }
    connectToGateway(email, password) {
        console.log('Connecting to payment gateway...');
    }
    pay(amount) {
        console.log(`Paying $${amount} using PayPal`);
    }
}

// Concrete class called BitPay
class BitPay extends PaymentMethod {
    #gateway;
    constructor(email, password) {
        super();
        this.#gateway = this.connectToGateway(email, password);
    }
    connectToGateway(email, password) {
        console.log('Connecting to payment gateway...');
    }
    pay(amount) {
        console.log(`Paying $${amount} using BitPay`);
    }
}

// Abstract factory method to create payment methods
class PaymentMethodFactory {
    constructor() {
        if (this.constructor === PaymentMethodFactory) {
            throw new Error("Cannot instantiate abstract class!");
        }
    }
    createPaymentMethod() {
        throw new Error("This method must be overwritten!");
    }
}

// Concrete factory method to create CreditCard payment methods
class CreditCardPaymentMethodFactory extends PaymentMethodFactory {
    #cardNumber;
    #cardHolder;
    #expiryDate;
    constructor(cardNumber, cardHolder, expiryDate) {
        super();
        this.#cardNumber = cardNumber;
        this.#cardHolder = cardHolder;
        this.#expiryDate = expiryDate;
    }
    createPaymentMethod() {
        return new CreditCard(this.#cardNumber, this.#cardHolder, this.#expiryDate);
    }
}

// Concrete factory method to create PayPal payment methods
class PayPalPaymentMethodFactory extends PaymentMethodFactory {
    #email;
    #password;
    constructor(email, password) {
        super();
        this.#email = email;
        this.#password = password;
    }
    createPaymentMethod() {
        return new PayPal(this.#email, this.#password);
    }
}

// Concrete factory method to create BitPay payment methods
class BitPayPaymentMethodFactory extends PaymentMethodFactory {
    #email;
    #password;
    constructor(email, password) {
        super();
        this.#email = email;
        this.#password = password;
    }
    createPaymentMethod() {
        return new BitPay(this.#email, this.#password);
    }
}


// Class that will use the factory method to create payment methods
class PaymentProcessor {
    #paymentMethodFactory;
    #paymentMethod;
    constructor(paymentMethodFactory) {
        this.#paymentMethodFactory = paymentMethodFactory;
    }
    processPayment(amount) {
        this.#paymentMethod = this.#paymentMethodFactory.createPaymentMethod();
        this.#paymentMethod.pay(amount);
    }
}

// Usage
const creditCardPaymentMethodFactory = new CreditCardPaymentMethodFactory('123123', 'John Doe', '12/23');
const paymentProcessor = new PaymentProcessor(creditCardPaymentMethodFactory);
paymentProcessor.processPayment(100);
Enter fullscreen mode Exit fullscreen mode

In the example above, there are two abstract classes that essentially enable this pattern: PaymentMethod and PaymentMethodFactory. The PaymentMethod class represents an interface, with which the various types of payment method are implemented, and the PaymentMethodFactory class represents an abstract class, with which various subclasses are created depending on the types of objects they intend to generate.

  • PaymentMethod class has one abstract function called "pay" that needs to be overridden by its subclasses. The reason why is that, regardless of the type of payment method you use, the only purpose of payment method is to allow users to pay for items with their choices of payment method. That means that every payment method should commonly share the function of "pay" a user may use to make a payment; however, the exact implementation of every subclass may vary. To allow the system to accept payments using credit cards, PayPal and BitPay(Cryptocurrency), the business decides to implement each payment method, creating a subclass inheriting the PaymentMethod class and uniquely implementing its own version of the function "pay."
  • PaymentMethodFactory class, just like the PaymentMethod class, provides an interface for a subclass generating an object of a single type of payment method. Since each subclass that extends the PaymentMethod class is responsible for creating a single type of object, we can assume that we will have to create a subclass that extends the PaymentMethodFactory class for each payment method available. The only job that a Factory subclass performs is providing an interface for generating an object of a certain payment method. Every Factory subclass has to implement its own version of a function called "createPaymentMethod", because the way an object of each class gets generated may differ. In order to create an object of the class CreditCard, you have to provide the card number, name of the holder, and expiration date; however, in order to create an object of the class PayPal, all you have to provide is the credentials used to log in to one's PayPal account. Regardless, all we have to ensure is that it has to return an object of the PaymentMethod variant.
  • Lastly, the instatiation of an object of PaymentProcessor class - we'll call this client code - requires any subclass that extends the PaymentMethodFactory Class. Inside the constructor function of PaymentProcessor class, you can see that it assigns the class you provide it with to the private variable called #paymentMethodFactory - In JavaScript, prepending a variable with "#" results in it becoming a private instance variable. Finally, when you invoke its function called "processPayment," it generates an object of a subclass extending the PaymentMethod class, by utilizing the Factory subclass you provided at the time of its instantiation, and proceeds to make a payment based on the argument you provide to the function.

When to Use This?

  1. When you do not have a clear idea of the types of object that could be created - Considering the situation above, you can tell that the system may or may not be extended at some point, by providing more payment methods on demand; however, you have no idea exactly which payment methods will get implemented later down the line. I don't know about you, but for me, it feels a lot cleaner to have the function accept an object of any subclass extending the PaymentMethodFactory class.
  2. When you want to create an object inside the factory class - Honestly, I had a hard time coming to terms with this statement, when I first learned about it - like why do you have to use a Factory method to generate an object when you can directly generate it without the Factory class outside the client code; however, when the creation of an object is often complex and requires some crazy preparation logic, then it is probably better for the system to delegate the responsibilities of creating an object to another class. Providing an Factory interface, whose sole responsiblity is generating an object, to the client code promotes decoupling and flexibility. Decoupling makes the system a lot modular.
  3. How about not altering the client code? - Adding new classes by extending the abstract class, without altering the content of the client code whenever you introduce new payment method, the system becomes more flexible and maintainable!

Back To List

Abstract Factory

Another pattern that involves the word Factory in its name, you can easily assume that it might work in a similar manner as its Factory Method counterpart; however, unlike Factory Method pattern, it can generate more than one type of object! Abstract Factory is a creational design pattern that let you generate families of related objects without specifying their concrete classes. Let's explore what it is all about, by going through the example below.

Example

  • Scenario: You are a lazy developer that tries to develop the comprehensive system toolkit for the maintenance of various OS. The tookit has the following functionalities: Task Manager, Disk Manager, and Network Manager. Since every OS works differently, even though they work fundamentally similar, the implementation of the toolkit for each OS may differ from one another, and you initially think of creating a separate SW for each OS; however, you are too lazy for that approach and start thinking that it would be nice if you can just have one program completely compatible with the operating systems of many different types. Instead of having the toolkit working for just one specific OS, it would be neat if the program detects the type of OS, in which the program runs, and functions accordingly.
  • Solution: Create an abstract class for each tool in the toolkit, and an abstract Factory class for generating those tools in the toolkit based on the type of OS, in which the toolkit runs. Since the toolkit you are trying to develop, regardless of the type of OS, should have the same functionalities as one another, you can simply create an abstract class for each functionality that is going to be integrated into the SW. For each OS, you can simply implement its own version of the functionality inheriting the abstract class. The abstract Factory class provides the interfaces for generating the tools included in the toolkit. For each OS, extend the abstract Factory class! Check out this GitHub link or the code below for clarification!
// Abstract class for creating a factory for various toolkits for various OS
class ToolkitFactory {
    constructor() {
        if (this.constructor === ToolkitFactory) {
            throw new Error("Cannot instantiate abstract class!");
        }
    }
    createTaskManager() {
        throw new Error("This method must be overwritten!");
    }
    createDiskManager() {
        throw new Error("This method must be overwritten!");
    }
    createNetworkManager() {
        throw new Error("This method must be overwritten!");
    }
}

// Concrete class for creating a factory for a Windows toolkit
class WindowsToolkitFactory extends ToolkitFactory {
    createTaskManager() {
        return new WindowsTaskManager();
    }
    createDiskManager() {
        return new WindowsDiskManager();
    }
    createNetworkManager() {
        return new WindowsNetworkManager();
    }
}

// Concrete class for creating a factory for a Linux toolkit
class LinuxToolkitFactory extends ToolkitFactory {
    createTaskManager() {
        return new LinuxTaskManager();
    }
    createDiskManager() {
        return new LinuxDiskManager();
    }
    createNetworkManager() {
        return new LinuxNetworkManager();
    }
}

// Abstract class for a TaskManager
class TaskManager {
    constructor() {
        if (this.constructor === TaskManager) {
            throw new Error("Cannot instantiate abstract class!");
        }
    }
    list() {
        throw new Error("This method must be overwritten!");
    }
    start() {
        throw new Error("This method must be overwritten!");
    }
    stop() {
        throw new Error("This method must be overwritten!");
    }
}

// Concrete class for a Windows TaskManager
class WindowsTaskManager extends TaskManager {
    list() {
        console.log("Listing Windows tasks...");
    }
    start() {
        console.log("Starting Windows task...");
    }
    stop() {
        console.log("Stopping Windows task...");
    }
}

// Concrete class for a Linux
class LinuxTaskManager extends TaskManager {
    list() {
        console.log("Listing Linux tasks...");
    }
    start() {
        console.log("Starting Linux task...");
    }
    stop() {
        console.log("Stopping Linux task...");
    }
}

// Abstract class for a DiskManager
class DiskManager {
    constructor() {
        if (this.constructor === DiskManager) {
            throw new Error("Cannot instantiate abstract class!");
        }
    }
    list() {
        throw new Error("This method must be overwritten!");
    }
    format() {
        throw new Error("This method must be overwritten!");
    }
}

// Concrete class for a Windows DiskManager
class WindowsDiskManager extends DiskManager {
    list() {
        console.log("Listing Windows disks...");
    }
    format() {
        console.log("Formatting Windows disk...");
    }
}

// Concrete class for a Linux
class LinuxDiskManager extends DiskManager {
    list() {
        console.log("Listing Linux disks...");
    }
    format() {
        console.log("Formatting Linux disk...");
    }
}

// Abstract class for a NetworkManager
class NetworkManager {
    constructor() {
        if (this.constructor === NetworkManager) {
            throw new Error("Cannot instantiate abstract class!");
        }
    }
    list() {
        throw new Error("This method must be overwritten!");
    }
    connect() {
        throw new Error("This method must be overwritten!");
    }
    disconnect() {
        throw new Error("This method must be overwritten!");
    }
}

// Concrete class for a Windows NetworkManager
class WindowsNetworkManager extends NetworkManager {
    list() {
        console.log("Listing Windows networks...");
    }
    connect() {
        console.log("Connecting to Windows network...");
    }
    disconnect() {
        console.log("Disconnecting from Windows network...");
    }
}

// Concrete class for a Linux
class LinuxNetworkManager extends NetworkManager {
    list() {
        console.log("Listing Linux networks...");
    }
    connect() {
        console.log("Connecting to Linux network...");
    }
    disconnect() {
        console.log("Disconnecting from Linux network...");
    }
}

// Client code for returning the correct toolkit factory
function getToolkitFactory(os) {
    if (os === 'Windows') {
        return new WindowsToolkitFactory();
    } else if (os === 'Linux') {
        return new LinuxToolkitFactory();
    } else {
        throw new Error("Unsupported OS");
    }
}

// Client code for using the toolkit factory
function useToolkit(toolkitFactory) {
    const taskManager = toolkitFactory.createTaskManager();
    const diskManager = toolkitFactory.createDiskManager();
    const networkManager = toolkitFactory.createNetworkManager();

    while (1) {
        console.log('1. List tasks');
        console.log('2. Start task');
        console.log('3. Stop task');
        console.log('4. List disks');
        console.log('5. Format disk');
        console.log('6. List networks');
        console.log('7. Connect to network');
        console.log('8. Disconnect from network');
        console.log('9. Exit');
        const choice = prompt('Enter your choice: ');
        switch (choice) {
            case '1':
                taskManager.list();
                break;
            case '2':
                taskManager.start();
                break;
            case '3':
                taskManager.stop();
                break;
            case '4':
                diskManager.list();
                break;
            case '5':
                diskManager.format();
                break;
            case '6':
                networkManager.list();
                break;
            case '7':
                networkManager.connect();
                break;
            case '8':
                networkManager.disconnect();
                break;
            case '9':
                return;
            default:
                console.log('Invalid choice');
        }
    }
}

// Usage
const windowsToolkitFactory = getToolkitFactory('Windows');
useToolkit(windowsToolkitFactory);
Enter fullscreen mode Exit fullscreen mode

In the example above, we see four abstract classes: ToolkitFactory, NetworkManager, DiskManager, and TaskManager.

  • ToolkitFactory - Your goal is to create the toolkit for only two types of OS: Windows and Linux. Since the ToolkitFactory class provides the interface for generating the available tools whose name end with Manager, you can extend this class by creating a Factory subclass for each OS.
  • NetworkManager, DiskManager, and TaskManager - The toolkit provides the functionalities for managing one's computer, but since it consists of many tools, whose implementation may vary depending on the OS, you simply extend them as well by creating a subclass for each class, and let the concrete Factory class instantiate an object using the subclass, just like how we did in the example of Factory Method pattern.
  • The client code consists of two functions: one for generating the toolkit, and another for interacting with the toolkit. The function "getToolkitFactory" creates an object of a concrete Factory class based on the type of OS the program runs in. The function "useToolkit" accepts the Factory class as its argument, generates an object of each tool, and provides a prompt for a user to use the tools.

When to Use This?

  1. When you do not have a clear idea of which families of objects to create - The only difference between this pattern and Factory Method pattern is that you can generate objects of more than one type. If you group the related items into several groups based on the families they belong to, then I guess you can use this pattern.
  2. Supporting multiple variants of products: If you develop an application like above where you have to support multiple variants of products without knowing the specifics of each variant, then this pattern may be for you.

Back To List

Builder

The Builder pattern is a creational design pattern that provides a flexible approach for constructing a complex object. In this pattern, the builder provides the interface for making a modification to an object it intends to generate, and the client code updates an object by invoking the functions of a builder class successively. The definition might be hard to understand unless you have a look at the example below:

Example

  • Scenario: You are a developer tasked with creating a simple script for making an HTTP request to a server. There is a number of aspects that you have to consider when it comes to an HTTP request: you may include in an HTTP request various properties, such as URL, method, cookies, headers, etc. Here is a thing though: you do not always have to provide every one of those properties I mentioned above for each request. For example, if you are making a simple request for fetching information from a website that doesn't require a log-in, then you can simply provide "URL" and "method" parameter with arguments. Or let's say that you want to log in to the website, then the most common approach is to provide "POST" for the "method" parameter, login information for the "data" parameter, and an URL for the "URL" parameter. Every instance is different, and you should allow a user to configure it however you want based on one's circumstances.
  • Solution: How about providing a class with various functions for configuring an object that you intend to generate. Invoking a function of the class alters a certain aspect of an object, and you can invoke or skip them based on your needs at any given point, providing you with flexibility. When you are finally done with the configuration of an object, you can simply return the object, by invoking a function called "build". Check out this GitHub link or the code below for clarification!
class HTTPRequest {
    constructor() {
        this.url = null;
        this.method = 'GET'; // Default method
        this.headers = {};
        this.cookies = {};
        this.data = null;
    }

    toString() {
        return `HTTPRequest(${this.method} ${this.url}, Headers: ${JSON.stringify(this.headers)}, Cookies: ${JSON.stringify(this.cookies)}, Data: ${this.data})`;
    }

    send() {
        console.log(`Sending request to ${this.url} using method ${this.method}`);
    }
}

// Abstract class for a builder
class HTTPRequestBuilder {
    constructor() {
        if (this.constructor === HTTPRequestBuilder) {
            throw new Error("Cannot instantiate abstract class!");
        }
    }

    setURL(url) {
        this.request.url = url;
        return this;
    }

    setMethod(method) {
        this.request.method = method;
        return this;
    }

    setHeaders(headers) {
        this.request.headers = headers;
        return this;
    }

    setCookies(cookies) {
        this.request.cookies = cookies;
        return this;
    }

    setData(data) {
        this.request.data = data;
        return this;
    }

    build() {
        return this.request;
    }
}

// Concrete class for a GET request builder
class GetRequestBuilder extends HTTPRequestBuilder {
    constructor() {
        super();
        this.request = new HTTPRequest();
        this.request.method = 'GET';
    }
}

// Concrete class for a POST request builder
class PostRequestBuilder extends HTTPRequestBuilder {
    constructor() {
        super();
        this.request = new HTTPRequest();
        this.request.method = 'POST';
    }
}

// Director class to build requests (optional)
class RequestDirector {
    constructor(builder) {
        this.builder = builder;
    }

    construct(url, data = {}, headers = {}, cookies = {}) {
        return this.builder
            .setURL(url)
            .setHeaders(headers)
            .setCookies(cookies)
            .setData(data)
            .build();
    }
}

// Usage
const director = new RequestDirector(new PostRequestBuilder());
// Prompts the user to enter data
const request = director.construct('http://example.com', { name: 'John Doe', age: 42 });
console.log(request.toString()); // HTTPRequest(POST http://example.com, Headers: {}, Cookies: {}, Data: {"name":"John Doe","age":42})
request.send(); // Sends the request to the server
Enter fullscreen mode Exit fullscreen mode
  • We have one abstract class called HTTPRequestBuilder which has various functions for altering a request object depending on the situational context. Its only responsibility is to provide the abstract interface for configuring an object flexibly. Each function it provides, except for the function "build", returns an object itself, which allows an object to chain itself, every time it modifies the request. Lastly, you invoke the function "build" to finally get the object of HTTPRequest that you modify.

  • Using the HTTPRequestBuilder class, two concrete builder classes - GetRequestBuilder and PostRequestBuilder - are created. Usually in HTTP, you often differentiate the types of the requests based on their method - GET is often used to fetch the information, whereas POST is used for submitting information of a user, etc. At the time of instantiation, each concrete Builder class instantiates an object of HTTPRequest whose properties are going to be modified by the builder itself, and immediately assigns the method to it accordingly.

  • The RequestDirector class is completely optional and performs the task of modifying the request to one's liking, by receiving an object of a concrete builder class and invoking its various functions.

When to Use This?

  1. When you have to provide a lot of different arguments to a class for the instantiation of an object - A class having a large amount of parameters for generating an object is usually a bad programming approach, so one way to mitigate this is to provide a builder class that modifies gradually by invoking each function specific for altering only an aspect of an object.
  2. When there are multiple ways of instantiating an object - I think, in Java and multiple programming languages - you can simply provide as many constructor functions as possible to provide as many different ways to create an object. But it may often look bad if you don't do it right, and I feel like an approach of builder class that promotes the gradual change of an object is suited for the readability of the code and the customization of an object.

Back To List

Prototype

The Prototype pattern is a creational design pattern that lets you create objects without having to instantiate them using classes. It is often used when it takes a while to instantiate an object via a class, saving computing and temporal resources. Let's check out the example for understanding it better!

Example

  • Scenario: You are developing a graphical video game and are tasked with creating an NPC that is going to be spawned in huge numbers. However, due to the way generating a character works, you find out that it takes quite a while to generate a single character using a standard approach of the instantiation of an NPC via a class, which makes it really inefficient and a waste of time.
  • Solution: Eventually, you figure out the cause of the issue: the texture processing of an NPC. The process of loading the texture files of an NPC and applying them to a character proves to be really inefficient. What if you could just generate a single NPC and copy its processed texture to other NPCs by cloning it? Check out the excerpt of the code below or this GitHub link for source code.
// Abstract class for a game NPC
class NPC {
    constructor() {
        if (this.constructor === NPC) {
            throw new Error("Cannot instantiate abstract class!");
        }
    }
    clone() {
        throw new Error("This method must be overwritten!");
    }
}

// Concrete class for a game NPC with a lot of properties, including textures
class Orc extends NPC {
    #name;
    #health;
    #attack;
    #defense;
    #textures;
    constructor(name, health, attack, defense, textures) {
        super();
        this.#name = name;
        this.#health = health;
        this.#attack = attack;
        this.#defense = defense;
        if (Array.isArray(textures))
            this.loadTextures(textures);
        else
            this.#textures = textures;
    }
    processTextures(textures) {
        console.log("Processing textures...");
        return textures;
    }
    loadTextures(textures) {
        console.log("Loading textures...");
        this.#textures = this.processTextures(textures);
    }
    clone() {
        const clone = structuredClone(this);
        clone.#health = 100;
        return clone;
    }
}

// Usage
// Create an Orc NPC
const orc = new Orc("Orc", 100, 20, 10, ["orc.png", "orc.3d"]);
const orc2 = orc.clone();
Enter fullscreen mode Exit fullscreen mode
  • The abstract class called NPC is an abstract class providing the interface for implementing a class that represents a type of NPC. It has one function called "clone," and delegates the responsiblity of cloning to objects.
  • The instantiation of an object of a concrete class called Orc, which is going to be one of the NPCs of the game, launches the initialization process that runs the complex code for loading and processing the texture files of the character for rendering it on the screen. Instead of generating a new object using the Orc class, you invoke the function "clone" of an already existing object and make a hard copy of it. That way, you simply bypass the process of loading and processing the texture files needed to render an NPC on the screen.

When to Use This?

  1. When instantiating an object is resource-intensive: If instantiating an object is simply too inefficient, compared to that of cloning, then you know that this might be a better choice than creating it from scratch.
  2. Avoiding subclassing: Apparently, subclassing excessively can make your code complex and hard to maintain in many situations. If you are in a situation where you have way too many subclasses, then it is probably better to clone an object than to follow a rigid class hierarchy. For example, In a graphics editing application, different shapes might share the same set of functions but differ in their properties, such as color, size, or position. Using the Prototype Pattern, you can clone a shape and adjust its properties, avoiding the need for a subclass for each shape variant.

Back To List

Singleton

The Singleton pattern is a creational design pattern that emphasizes using one object for a class, providing a single access point for controlling an object. If you need only one object of a class for an application, then this may be what you need. Let's check out the example below to see how it may be used.

Example

  • Scenario: You develop an online document-editing application with a functionality that allows multiple people to work on a single document simulataneously in real-time. Your job is to ensure that only one person modifies a document at a time, which essentially prevents others from doing so.
  • Solution: Instead of making multiple copies of a document for each user in a session, you generate a single object representing a document, and let others take turns editing it. To allow a single user to edit a document while others wait, you may implement the lock feature that gives only one person access to the editing feature of the document; However, to prevent a person from locking a document indefinitely, you implement a timeout feature, which revokes one's access to a lock after a certain time. Check out this GitHub link or the code below for clarification!
class Document {
    static #lock = null;
    static #content = {
        title: "Document",
        body: "This is a document",
        footer: "End of document"
    };

    constructor() {
        throw new Error("Cannot instantiate this class!");
    }

    static acquireLock(name) {
        if (Document.#isLockExpired()) {
            Document.releaseLock();
        }
        if (!Document.isLocked()) {
            Document.#lock = {
                name: name,
                timestamp: Date.now()
            };
        } else {
            throw new Error("Document is already locked!");
        }
    }

    static releaseLock() {
        Document.#lock = null;
    }

    static isLocked() {
        return Boolean(Document.#lock);
    }

    static #isLockExpired() {
        return Document.#lock && (Date.now() - Document.#lock.timestamp > 5000);
    }

    static modifyContent(key, content) {
        if (!Document.isLocked() || Document.#lock.name !== key)
            throw new Error("Document is locked or you do not hold the lock!");
        Document.#content = content;
        Document.releaseLock();
    }

    static getContent() {
        return Document.#content;
    }
}

// Usage
const key = 'John Doe';
Document.acquireLock(key);
console.log(Document.isLocked()); // true
console.log(Document.getContent());
Document.modifyContent(key, {
    title: "New Document",
    body: "This is a new document",
    footer: "End of new document"
});
console.log(Document.getContent());
Enter fullscreen mode Exit fullscreen mode
  • In the example, there is a class called Document. The class cannot be instatiated, since it intends to provides a single access point for everyone that access the application.
  • It has two static private variables: lock and content. The lock holds the key of a user and timestamp for recording when the lock was acquired by the user. Modifying the content of the Document class requires one to acquire a lock by invoking the static function called "acquireLock." The function essentially checks if the lock is already acquired by someone, and if it does, then determines if the lock is already expired or not. A user modifies the content of the Document class, by invoking the function "modifyContent", which accepts two arguments: key, content. A user provides its key that one uses to generate a lock, and the system verifies to make sure that the user is the one that has acquired the lock by comparing the user key to that of "lock" static variable. The Document class replaces its "content" static variable with what a user provides, and lastly, releases a lock, which allows others to acquire it for modifying the Document.

When to Use This?

  1. When you have to manage a shared resource: I feel like this is often used in a situation where various objects need to share a single object. Sharing a configuration object, a connection to DB, and such come to mind.
  2. Controlled access and modification - When you have to make sure that only one person may modify the content and such at a time, like in the example above, then you should consider appplying this pattern to the application, to prevent concurrent modifications.

Back To List

Summary

For this blog post, we went over the Design Patterns: why they matter, the types of patterns, and most importantly, the patterns of the Creational variant. Emphasizing the economical and inheritable approaches, the creational design patterns provide the effective ways of creating objects. Factory Method pattern provides an interface for creating an object of a certain type. Abstract Factory pattern provides an interface for creating the objects of families. Builder pattern provides an interface for creating a complex object flexibly. Prototype pattern provides you a efficient way of creating an object with the concept of cloning. Singleton pattern utilizes a single access point to resource, providing a way to share resource concurrently while preventing race conditions. They are all well effective in decoupling the system, making it more modular and manageable! For the next blog, I will go over the rest of the Structural Patterns. Thank you for reading this long post!

Top comments (0)