Mastering the Factory Method Design Pattern: Building a Task Management CLI
Sidali Assoul
Posted on November 14, 2024
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:
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.
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 ...
Structure
The structure of the factory design pattern consists of the following classes:
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.
ConcreteFactory : The concrete factory extends/implements the Factory , overrides the factory method ( create ) and returns a sub-type of the IProduct interface ( ConcreteProducts ).
IProduct : is the common interface which will be implemented by many ConcreteProducts.
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
Then run the cli app
npm start
After running the CLI app, you will see this menu list which shows the different options you can perform in this application:
- Add a task.
- List the created tasks.
- Complete a task by toggling its status.
- 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.
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
}
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
}
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()})`
}
}
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));
}
}
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"
}
}
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"
}
}
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.")
}
}
}
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.`)
}
}
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()
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:
- Encapsulate object creation logic in separate classes (factories).
- Easily switch between different object creation strategies at runtime.
- Maintain a clean separation of concerns between object creation and object use.
- 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.
Contact
If you have any questions or want to discuss something further feel free to Contact me here.
Happy coding!
Posted on November 14, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 14, 2024
October 21, 2024