DEV Community

Sidali Assoul
Sidali Assoul

Posted on • Originally published at spithacode.com on

Mastering the Factory Method Design Pattern: Building a Task Management CLI

Have you encountered a case where the creation of an object or a family of objects in your application requires some kind of business logic which you don't want to just repeat over and over in your application?

Or have you ever built an application, got it done after a lot of hustle, then discovered that you misunderstood a requirement or maybe your client just changed their mind, and now you need to refactor all the business logic which creates your application objects?

What if there was a way to just swap the previous implementation with the new one, just by changing a few lines of code?

Well, that's where the Factory Method design pattern comes in!

Throughout this tutorial, we will be demystifying this Design Pattern while building a fully functional Task Management Node.js CLI Application!

Overview

Factory Method is a creational design pattern , which is a category of design patterns that deals with the different problems that come with the native way of creating objects with the new keyword or operator.

Problem

The Factory Method Design pattern focuses on solving the following problems:

  1. The creation of many objects using the new operator may require complex business logic, to either determine the correct instance which should be created or the necessary parameters for that instance.

  2. Refactoring the calls to the new operator to instantiate our entities requires modifying all the references in our code, which makes introducing new types of objects hard and time-consuming.

If you needed to introduce a new type of object or change the existing business logic for creating a specific entity, you would have to refactor all of the creational code which is spread across your codebase.

Solution

The Factory Method Design pattern solves these problems by moving the objects creation responsibility into special classes called Factories.

Instead of spreading the creational code for each type of object in our application and using the new operator to instantiate them, we call these classes known as Factories.

Each Factory contains a method which is responsible for creating a new instance of a specific type of object, which is the reason for naming this design pattern factory method.

The factory design pattern provides an interface for creating objects of a specific type ( IProduct ) in a superclass (interface or abstract class) named Factory while allowing subclasses ( ConcreteFactories ) to alter the type of the returned objects ( ConcreteProducts ).

So if you needed to change the creation business logic or the type of objects which is returned by the factory method calls, you just have to change one line of code.

const factory: Factory = new ConcreteFactory1() //<-------- change this
//client code
const product: IProduct = factory.create() // ConcreteProduct1

// rest of the client code ...

// change the above line to

const factory: Factory = new ConcreteFactory2() // <--------- to this
//client code
const product: IProduct = factory.create() // ConcreteProduct2
//rest of the client code ...

Enter fullscreen mode Exit fullscreen mode

Structure

Factory Design Pattern Schema

The structure of the factory design pattern consists of the following classes:

  1. Factory : an interface (or abstract class) which declares the factory method ( create ). You can think of it as the generic factory or the superclass of all the ConcreteFactories. The factory method returns an IProduct which is a common type between all the products which can be made by the factory.

  2. ConcreteFactory : The concrete factory extends/implements the Factory , overrides the factory method ( create ) and returns a sub-type of the IProduct interface ( ConcreteProducts ).

  3. IProduct : is the common interface which will be implemented by many ConcreteProducts.

  4. ConcreteProduct : a class which is an IProduct.

Practical Scenario

Now let's put this design pattern into practice by building a fully working Task Management Node.js CLI app.

You can find the final code in this repository. Just clone it and run the following commands.

First install dependencies

npm install

Enter fullscreen mode Exit fullscreen mode

Then run the cli app

npm start

Enter fullscreen mode Exit fullscreen mode

After running the CLI app, you will see this menu list which shows the different options you can perform in this application:

  1. Add a task.
  2. List the created tasks.
  3. Complete a task by toggling its status.
  4. Switch between two modes: Priority based mode and standard mode. And guess what? We will be switching between factories :
  • A StandardTaskFactory
  • A PriorityBasedTaskFactory

Each factory will be following its own way or business logic for creating different types of tasks: Simple , Urgent , and Recurring.

Factory Method Demo: Task Management CLI App

Declaring our Types

So let's start by declaring our types:

types.ts

export type TaskType = "simple" | "urgent" | "recurring"

export interface TaskAnswers {
  description: string
  dueDate?: string
  priority?: number
  isRecurring: boolean
  interval?: number
}

Enter fullscreen mode Exit fullscreen mode

We've defined some utility types that we will be using in our CLI app such as:

  • TaskType : A union type for storing all the types of tasks.
  • TaskAnswers : When our CLI app asks the user for some inputs, we will be storing the result in an object which satisfies this type.

Now let's define our common task interface, which will be an abstract class in our case because we want to share some common attributes, like: description , id , and completed between our ConcreteTasks.

The decision of choosing an interface or abstract class for your common type depends on the use case. If you needed to just share method signatures between your subtypes, use an interface. But if you have some shared attributes between your types or maybe you want to provide some default implementations for some methods, then use an abstract class.

Creating the task common type

Task.ts abstract Task

export abstract class Task {
  completed: boolean = false

  constructor(
    public id: number,
    public description: string
  ) {}

  complete(): void {
    this.completed = true
  }

  abstract getStatus(): string
}

Enter fullscreen mode Exit fullscreen mode

Creating Concrete Tasks

Next, let's declare our concreteTasks classes, which will be implementing the Task abstract class and overriding some methods' behavior ( SimpleTask and UrgentTask ) or adding some extra attributes like nextDueDate for the RecurringTask.

Task.ts Concrete Tasks

export class SimpleTask extends Task {
  getStatus(): string {
    return `[${this.completed ? "X" : " "}] ${this.id}: ${this.description}`
  }
}

export class UrgentTask extends Task {
  getStatus(): string {
    return `[${this.completed ? "X" : " "}] ${this.id}: ${this.description} (URGENT)`
  }
}

export class RecurringTask extends Task {
  private nextDueDate: Date

constructor(
id: number,
description: string,
private interval: number
) {
super(id, description)
this.nextDueDate = new Date(Date.now() + interval _ 24 _ 60 _ 60 _ 1000)
}

complete(): void {
super.complete()
this.nextDueDate = new Date(
Date.now() + this.interval _ 24 _ 60 _ 60 _ 1000
)
}

getStatus(): string {
return `[${this.completed ? "X" : " "}] ${this.id}: ${this.description} (Recurring, Next: ${this.nextDueDate.toDateString()})`
}
}

Enter fullscreen mode Exit fullscreen mode

Declaring The Generic Factory

Now, it's time for declaring the generic Factory type for creating tasks.

TaskFactory.ts

import { Task } from "./Task"
import { TaskType } from "./types"

export abstract class TaskFactory {
  protected nextId: number = 1;
  protected taskCount: Record<TaskType, number> = {
    simple: 0,
    urgent: 0,
    recurring: 0,
  };
  protected lastCreatedTaskType: TaskType | null = null;

abstract createTask(
description: string,
dueDate?: string,
priority?: number,
isRecurring?: boolean,
interval?: number,
): Task;

protected abstract determineTaskType(
description: string,
dueDate?: string,
priority?: number,
isRecurring?: boolean,
interval?: number,
): TaskType;

protected calculateDaysUntilDue(dueDate: string): number {
const due = new Date(dueDate);
const now = new Date();
const diffTime = due.getTime() - now.getTime();
return Math.ceil(diffTime / (1000 _ 3600 _ 24));
}
}

Enter fullscreen mode Exit fullscreen mode

In the above code, we've declared the TaskFactory which is the common or parent type of all our factories.

The generic factory comes with:

  • An abstract factory method named createTask.
  • An abstract determineTaskType method which will contain the business logic for determining the type of a task based on various parameters such as: description , dueDate , priority , isRecurring , and interval.
  • A protected method named calculateDaysUntilDue which comes with a default implementation for calculating the days until the due date for a given task.

The utility methods declared next to the factory method will be used inside the implementation of the factory method next to the task creation business logic.

Our factory won't be just a stateless factory, but it will keep track of the following states which will be updated every time a Task gets created.

  • nextId : the next integer id which will be assigned to the upcoming task.
  • taskCount : is a map storing the created task types counts.
  • lastCreatedTaskType : the factory is keeping track of the last created task in this state variable.

The reason behind storing the counts is to give the ConcreteFactories the ability to alter their decisions about the task type based on the distribution of the already created tasks by type.

Creating The Concrete Factories

Now let's move on to the ConcreteFactories code.

We will be creating two kinds of factories: StandardTaskFactory and PriorityBasedTaskFactory.

Each concrete factory will be extending the Task Generic Factory and overriding it as needed while returning the chosen task type based on a custom business logic.

Depending on the determined task type, the factory will instantiate the corresponding Task instance: SimpleTask , UrgentTask , and RecurringTask.

The StandardTaskFactory will be mainly using the following metrics:

  • The presence of isRecurring and interval variables.
  • Is the priority interval greater than 8.
  • Are the daysUntilDue lower than 2.
  • Does the description of the task contain the keywords: urgent or important
  • Is the urgent tasks count surpassing the simple tasks count by a factor of two.
  • If the lastCreatedTaskType is urgent , there is a 70% probability that the next created task is simple.

TaskFactories.ts StandardTaskFactory

import { RecurringTask, SimpleTask, Task, UrgentTask } from "./Task"
import { TaskFactory } from "./TaskFactory"
import { TaskType } from "./types"

export class StandardTaskFactory extends TaskFactory {
  constructor() {
    super()
  }

  createTask(
    description: string,
    dueDate?: string,
    priority?: number,
    isRecurring?: boolean,
    interval?: number
  ): Task {
    const id = this.nextId++
    const taskType = this.determineTaskType(
      description,
      dueDate,
      priority,
      isRecurring,
      interval
    )

    this.taskCount[taskType]++
    this.lastCreatedTaskType = taskType

    switch (taskType) {
      case "simple":
        return new SimpleTask(id, description)
      case "urgent":
        return new UrgentTask(id, description)
      case "recurring":
        return new RecurringTask(id, description, interval || 7)
      default:
        throw new Error(`Unknown task type: ${taskType}`)
    }
  }

  protected determineTaskType(
    description: string,
    dueDate?: string,
    priority?: number,
    isRecurring?: boolean,
    interval?: number
  ): TaskType {
    if (isRecurring && interval) {
      return "recurring"
    }

    if (priority && priority > 8) {
      return "urgent"
    }

    if (dueDate) {
      const daysUntilDue = this.calculateDaysUntilDue(dueDate)
      if (daysUntilDue <= 2) {
        return "urgent"
      }
    }

    if (
      description.toLowerCase().includes("urgent") ||
      description.toLowerCase().includes("important")
    ) {
      return "urgent"
    }

    if (this.taskCount["urgent"] > this.taskCount["simple"] * 2) {
      return "simple"
    }

    if (this.lastCreatedTaskType === "simple" && Math.random() > 0.7) {
      return "urgent"
    }

    return "simple"
  }
}

Enter fullscreen mode Exit fullscreen mode

On the other hand, the PriorityBasedTaskFactory is using the following metrics:

  • The presence of isRecurring and interval variables.
  • Is the priority interval greater than 8.
  • Is the priority interval greater than 5 and less than 8.
  • Are the daysUntilDue lower than 5.

TaskFactories.ts PriorityBasedTaskFactory

export class PriorityBasedTaskFactory extends TaskFactory {
  constructor() {
    super()
  }

  createTask(
    description: string,
    dueDate?: string,
    priority?: number,
    isRecurring?: boolean,
    interval?: number
  ): ITask {
    const id = this.nextId++
    const taskType = this.determineTaskType(
      description,
      dueDate,
      priority,
      isRecurring,
      interval
    )

    this.taskCount[taskType]++
    this.lastCreatedTaskType = taskType

    switch (taskType) {
      case "simple":
        return new SimpleTask(id, description)
      case "urgent":
        return new UrgentTask(id, description)
      case "recurring":
        return new RecurringTask(id, description, interval || 7)
      default:
        throw new Error(`Unknown task type: ${taskType}`)
    }
  }

  protected determineTaskType(
    description: string,
    dueDate?: string,
    priority?: number,
    isRecurring?: boolean,
    interval?: number
  ): TaskType {
    if (isRecurring && interval) {
      return "recurring"
    }

    if (priority) {
      if (priority >= 8) return "urgent"
      if (priority >= 5) return "simple"
    }

    if (dueDate) {
      const daysUntilDue = this.calculateDaysUntilDue(dueDate)
      if (daysUntilDue <= 5) {
        return "urgent"
      }
    }

    return "simple"
  }
}

Enter fullscreen mode Exit fullscreen mode

The Task Manager Class

  • The TaskManager class stores the generic Tasks in a local array property named tasks.
  • It takes a generic TaskFactory as a constructor argument.
  • It's providing many methods for manipulating the tasks:

  • addTask for creating a new task using the generic factory which it got from the constructor.

  • listTasks goes through all the generic tasks in the tasks array and prints their status via the getStatus method which is shared between the generic tasks instances.

  • completeTask marks a task as completed.

TaskManager.ts

import { Task } from "./Task"
import { TaskFactory } from "./TaskFactory"

export class TaskManager {
  private tasks: Task[] = []

  constructor(private factory: TaskFactory) {}

  setFactory(factory: TaskFactory): void {
    this.factory = factory
  }

  addTask(
    description: string,
    dueDate?: string,
    priority?: number,
    isRecurring?: boolean,
    interval?: number
  ): void {
    const task = this.factory.createTask(
      description,
      dueDate,
      priority,
      isRecurring,
      interval
    )
    this.tasks.push(task)
    console.log("Task added successfully!")
  }

  listTasks(): void {
    if (this.tasks.length === 0) {
      console.log("No tasks found.")
      return
    }
    this.tasks.forEach((task) => console.log(task.getStatus()))
  }

  completeTask(id: number): void {
    const task = this.tasks.find((t) => t.id === id)
    if (task) {
      task.complete()
      console.log("Task marked as completed!")
    } else {
      console.log("Task not found.")
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

The CLI Class

The CLI is responsible for showing a menu which displays various options then asking the user to choose among various options such as:

  • Add Task : adding a new task.
  • List Tasks : displaying all the created tasks with their completion status and type.
  • Complete Task : completing a task given its unique ID.
  • Switch Priority Mode : switching between the priority mode.
  • Exit : Exiting the program.

The CLI class instantiates the task manager class while passing the current active factory. By default, we are passing an instance of the StandardTaskFactory.

When the user decides to choose the Switch Priority Mode option, we prompt them to select the factory instance which they would like to use.

cli.ts

import inquirer from "inquirer"

import { PriorityBasedTaskFactory, StandardTaskFactory } from "./TaskFactories"
import { TaskFactory } from "./TaskFactory"
import { TaskManager } from "./TaskManager"
import { TaskAnswers } from "./types"

export class CLI {
  private taskManager: TaskManager
  private factories: { [key: string]: TaskFactory } = {
    Standard: new StandardTaskFactory(),
    "Priority-based": new PriorityBasedTaskFactory(),
  }

  constructor() {
    this.taskManager = new TaskManager(this.factories["Standard"])
  }

  async start(): Promise<void> {
    console.log("Welcome to the Advanced Task Manager CLI!")
    await this.mainMenu()
  }

  private async mainMenu(): Promise<void> {
    const { choice } = await inquirer.prompt<{ choice: string }>([
      {
        type: "list",
        name: "choice",
        message: "What would you like to do?",
        choices: [
          { name: "Add Task", value: "add" },
          { name: "List Tasks", value: "list" },
          { name: "Complete Task", value: "complete" },
          { name: "Switch Priority Mode", value: "switch" },
          { name: "Exit", value: "exit" },
        ],
      },
    ])

    switch (choice) {
      case "add":
        await this.addTask()
        break
      case "list":
        this.taskManager.listTasks()
        break
      case "complete":
        await this.completeTask()
        break
      case "switch":
        await this.switchFactory()
        break
      case "exit":
        console.log("Goodbye!")
        process.exit(0)
    }

    if (choice !== "exit") {
      await this.mainMenu()
    }
  }

  private async addTask(): Promise<void> {
    const answers = await inquirer.prompt<TaskAnswers>([
      {
        type: "input",
        name: "description",
        message: "Enter task description:",
      },
      {
        type: "input",
        name: "dueDate",
        message: "Enter due date (YYYY-MM-DD, optional):",
        validate: (input: string) => {
          if (!input) return true
          const date = new Date(input)
          return date instanceof Date && !isNaN(date.getTime())
            ? true
            : "Please enter a valid date or leave empty"
        },
      },
      {
        type: "number",
        name: "priority",
        message: "Enter priority (1-10, optional):",
        validate: (input: number) => {
          if (!input) return true
          return input >= 1 && input <= 10
            ? true
            : "Please enter a number between 1 and 10 or leave empty"
        },
      },
      {
        type: "confirm",
        name: "isRecurring",
        message: "Is this a recurring task?",
        default: false,
      },
      {
        type: "number",
        name: "interval",
        message: "Enter recurrence interval in days:",
        when: (answers: TaskAnswers) => answers.isRecurring,
        validate: (input: number) =>
          input > 0 ? true : "Please enter a positive number",
      },
    ])

    this.taskManager.addTask(
      answers.description,
      answers.dueDate,
      answers.priority,
      answers.isRecurring,
      answers.interval
    )
  }

  private async completeTask(): Promise<void> {
    const { id } = await inquirer.prompt<{ id: number }>([
      {
        type: "number",
        name: "id",
        message: "Enter the ID of the task to complete:",
        validate: (input: number) =>
          input > 0 ? true : "Please enter a valid task ID",
      },
    ])

    this.taskManager.completeTask(id)
  }

  private async switchFactory(): Promise<void> {
    const { factoryChoice } = await inquirer.prompt<{ factoryChoice: string }>([
      {
        type: "list",
        name: "factoryChoice",
        message: "Choose a task factory:",
        choices: Object.keys(this.factories),
      },
    ])

    this.taskManager.setFactory(this.factories[factoryChoice])
    console.log(`Switched to ${factoryChoice} factory.`)
  }
}

Enter fullscreen mode Exit fullscreen mode

Finally, the index.ts file just instantiates the CLI class then calls the start method on the instantiated CLI object.

index.ts

import { CLI } from "./cli"

const cli = new CLI()
cli.start()

Enter fullscreen mode Exit fullscreen mode

Conclusion

In this tutorial, we've explored the Factory Method design pattern and its practical implementation in a Task Management CLI application. By using this pattern, we've created a flexible and extensible system that can easily accommodate different types of tasks and task creation strategies.

The Factory Method pattern allows us to:

  1. Encapsulate object creation logic in separate classes (factories).
  2. Easily switch between different object creation strategies at runtime.
  3. Maintain a clean separation of concerns between object creation and object use.
  4. Extend the system with new types of tasks or creation strategies without modifying existing code.

By implementing this pattern in our Task Management CLI, we've demonstrated how it can be used to create a more maintainable and adaptable codebase. This approach is particularly useful in scenarios where the types of objects being created might change over time, or where the creation logic might vary based on different conditions or user preferences.

As you continue to develop and expand your applications, consider how the Factory Method pattern and other design patterns can help you create more robust, flexible, and maintainable code.

Top comments (0)