Have you ever found yourself needing to create multiple variations of different families of object in your application without duplicating the logic over and over?
Or perhaps you’ve built an application, only to realize that new requirements or a client’s changed preferences demand entirely new objects, forcing you to rework your entire codebase?
What if there was a way to seamlessly introduce new variations without breaking your existing code just by plugging in a new implementation?
That’s where the Abstract Factory design pattern comes in!
In this tutorial, we’ll break down this powerful design pattern by building a Node.js CLI application for creating mutiple types of resumes supporting multiples formats and themes.
Overview
The Abstract Factory 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 using the new keyword or operator.
You can think of the Abstract Factory design pattern as a generalization of the factory method design pattern which we've covered in this blog article.
Problem
The Abstract Factory design pattern solves the following problems:
- How can we create families of related products such as: PDFResume , JSONResume , and MarkdownResume?
- How can we support having multiple variants per product family such as: CreativeResume , MinimalistResume , and ModernResume?
- How can we support adding more variants and products without breaking our existing consuming or client code?
Solution
The Abstract Factory design pattern solves these problems by declaring an interface or abstract class for each type of product.
export abstract class PDFResume {}
export abstract class JSONResume {}
export abstract class MarkdownResume {}
And then, as the name of the pattern implies, we create an abstract factory which is an interface that declares factory methods that create every type of product:
- createPDFResume : which returns a PDFResume type or subtype.
- createMarkdownResume : which returns a MarkdownResume type or subtype.
- createJSONResume : which returns a JSONResume type or subtype.
export interface ResumeFactory {
createPDFResume(): PDFResume
createMarkdownResume(): MarkdownResume
createJSONResume(): JSONResume
}
Okay, now we have a generic factory which returns every possible type of product, but how can we support multiple variants per product?
The answer is by creating a ConcreteFactory which implements the abstract factory ( ResumeFactory ).
export class CreativeResumeFactory implements ResumeFactory {
createPDFResume(): CreativePDFResume {
return new CreativePDFResume() // CreativePDFResume implements PDFResume
}
createMarkdownResume(): CreativeMarkdownResume {
return new CreativeMarkdownResume() // CreativeMarkdownResume implements MarkdownResume
}
createJSONResume(): CreativeJSONResume {
return new CreativeJSONResume() // CreativeJSONResume implements JSONResume
}
}
Now, to consume our factories in our client class, we just have to declare a variable of type ResumeFactory and then instantiate the corresponding Concrete factory depending on the user input.
Client code:
// User inputs...
let theme = "minimalist"
let format = "pdf"
let factory: ResumeFactory
switch (theme) {
case "minimalist":
factory = new MinimalistResumeFactory()
break
case "modern":
factory = new ModernResumeFactory()
break
case "creative":
factory = new CreativeResumeFactory()
break
default:
throw new Error("Invalid theme.")
}
const userInput = await getUserInput()
let resume
switch (format) {
case "pdf":
resume = factory.createPDFResume()
break
case "markdown":
resume = factory.createMarkdownResume()
break
case "json":
resume = factory.createJSONResume()
break
default:
throw new Error("Invalid format.")
}
Structure
The structure of the Abstract Factory design pattern consists of the following classes:
- Factory : The reason for naming this design pattern abstract factory is that this class represents the contract between all the ConcreteFactories. It defines all the factory methods.
- The number of factory methods is equal to the number of products.
- Each factory method should return an abstract or generic product type ( IProduct{j} ).
In our case, the factory methods declared in Factory are: createProductA and createProductB
- ConcreteFactory{i} : These classes implement the Factory class and provide custom implementations for each factory method.
- In the above schema, i is equal to either 1 or 2.
- The number of ConcreteFactories is equal to the number of possible variants per product.
- Each concrete factory method should return an object which is an instance of the corresponding product.
- IProduct{j} : These classes correspond to the abstract product types.
- In the above schema, j is equal to either A or B.
- Each IProduct{j} is implemented by many concrete product classes.
ConcretProductA1 and ConcretProductA2 implement IProductA ConcretProductB1 and ConcretProductB2 implement IProductB
- ConcreteProducts are the products which implement one of the IProduct{j} generic types.
Practical Scenario
In this section, we are going to put the previous example into action by building a fully working Node.js TypeScript CLI Application which creates a resume based on the chosen theme and format by the user.
Feel free to check out the full working code by cloning this repository on your machine.
Then run the following commands:
npm install
npm start
Declaring Types
Let's start by declaring the types which we will be using throughout the tutorial to ensure type safety.
interfaces/Types
export type ResumeData = {
name: string
email: string
phone: string
experience: Experience[]
}
export type Experience = {
company: string
position: string
startDate: string
endDate: string
description: string
}
- The ResumeData type defines all the attributes of a resume object such as: name, email, phone, and an array of experiences.
- The Experience type consists of the: company, position, startDate, endDate, and description.
Declaring Our Abstract Factory
Now, let's declare the generic factory type, which will be defining the three factory methods which correspond to the different supported product types: PDFResume , MarkdownResume , and JSONResume.
interfaces/ResumeFactory
import { JSONResume } from "../resumes/json/JSONResume"
import { MarkdownResume } from "../resumes/markdown/MarkdownResume"
import { PDFResume } from "../resumes/pdf/PdfResume"
export interface ResumeFactory {
createPDFResume(): PDFResume
createMarkdownResume(): MarkdownResume
createJSONResume(): JSONResume
}
We will be going through their code in the next section.
Declaring The Shared Class for the Different Types of Documents
Next, let's move on to creating our generic product classes.
Every product type will be an abstract class because we want to share both attributes and methods between their corresponding subtypes.
- JSONResume : The class has a protected data attribute, storing an object of type ResumeData with an extra attribute called style.
The class defines:
- A getter method to access the data attribute.
- An abstract generate method which will be overridden by the subclasses later.
- A saveToFile method with a basic implementation, which consists of storing the resume data in a JSON file.
resumes/json/JSONResume
import * as fs from "fs/promises"
import { ResumeData } from "../../interfaces/Types"
export abstract class JSONResume {
protected data!: ResumeData & { style: string }
abstract generate(data: ResumeData): void
async saveToFile(fileName: string): Promise<void> {
await fs.writeFile(fileName, JSON.stringify(this.data, null, 2))
}
getData(): any {
return this.data
}
}
The keyword abstract means that the class is a generic type which can't be instantiated; it can only be inherited by other classes.
- MarkdownResume : The class has a protected content attribute, storing the markdown string.
The class defines:
- A getter method to access the content attribute.
- An abstract generate method which will be overridden by the subclasses later.
- A saveToFile method which takes a fileName and then stores the markdown formatted string content into a file.
resumes/markdown/MarkdownResume
import * as fs from "fs/promises"
import { ResumeData } from "../../interfaces/Types"
export abstract class MarkdownResume {
protected content: string = ""
abstract generate(data: ResumeData): void
async saveToFile(fileName: string): Promise<void> {
await fs.writeFile(fileName, this.content)
}
getContent(): string {
return this.content
}
}
- PDFResume :
The class has a protected doc object of type PDFKit.PDFDocument , which is imported from a library called pdfkit. The library simplifies creating and manipulating PDF documents through its object-oriented interface.
The class defines:
- A getter method to access the doc attribute.
- An abstract generate method which will be overridden by the subclasses later.
- A saveToFile method which saves the doc in-memory PDF object into a specific file.
resumes/pdf/PDFResume
import * as fs from "fs"
import PDFDocument from "pdfkit"
import { ResumeData } from "../../interfaces/Types"
export abstract class PDFResume {
protected doc: PDFKit.PDFDocument
constructor() {
this.doc = new PDFDocument()
}
abstract generate(data: ResumeData): void
async saveToFile(fileName: string): Promise<void> {
const stream = fs.createWriteStream(fileName)
this.doc.pipe(stream)
this.doc.end()
await new Promise<void>((resolve, reject) => {
stream.on("finish", resolve)
stream.on("error", reject)
})
}
getBuffer(): Buffer {
return this.doc.read() as Buffer
}
}
Declaring our Concrete Factories
Now that we've defined our generic product types and our abstract factory , it's time to proceed with the creation of our ConcreteFactories which correspond to the different variants of every generic product type.
We have 3 possible variants for a resume: Creative , Minimalist , and Modern. And 3 types of generic Products: JSON , PDF , and Markdown.
The abstract factory ( ResumeFactory ) defines the 3 factory methods which are responsible for creating our products:
- createPDFResume : creates an instance of type PDFResume.
- createMarkdownResume : creates an instance of type MarkdownResume.
- createJSONResume : creates an instance of type JSONResume.
To support multiple variants per product, we will have to create 3 concrete factories.
Each Concrete factory will be creating the 3 types of products but with its own flavors:
- CreativeResumeFactory creates products of the Creative variant.
- MinimalistResumeFactory creates products of the Minimalist variant.
- ModernResumeFactory creates products of the Modern variant.
factories/CreativeResumeFactory
import { ResumeFactory } from "../interfaces/ResumeFactory"
import { CreativeJSONResume } from "../resumes/json/CreativeJSONResume"
import { CreativeMarkdownResume } from "../resumes/markdown/CreativeMarkdownResume"
import { CreativePDFResume } from "../resumes/pdf/CreativePDFResume"
export class CreativeResumeFactory implements ResumeFactory {
createPDFResume(): CreativePDFResume {
return new CreativePDFResume() // CreativePDFResume extends PDFResume
}
createMarkdownResume(): CreativeMarkdownResume {
return new CreativeMarkdownResume() // CreativeMarkdownResume extends MarkdownResume
}
createJSONResume(): CreativeJSONResume {
return new CreativeJSONResume() // CreativeJSONResume extends JSONResume
}
}
- The CreativeResumeFactory factory methods return the creative concrete product variant for every type of product.
factories/MinimalistResumeFactory
import { ResumeFactory } from "../interfaces/ResumeFactory"
import { MinimalistJSONResume } from "../resumes/json/MinimalistJSONResume"
import { MinimalistMarkdownResume } from "../resumes/markdown/MinimalistMarkdownResume"
import { MinimalistPDFResume } from "../resumes/pdf/MinimalistPDFResume"
export class MinimalistResumeFactory implements ResumeFactory {
createPDFResume(): MinimalistPDFResume {
return new MinimalistPDFResume() // extends PDFResume
}
createMarkdownResume(): MinimalistMarkdownResume {
return new MinimalistMarkdownResume() // extends MarkdownResume
}
createJSONResume(): MinimalistJSONResume {
return new MinimalistJSONResume() // extends JSONResume
}
}
- The MinimalistResumeFactory factory methods return the minimalist concrete product variant for every type of product.
factories/ModernResumeFactory
import { ResumeFactory } from "../interfaces/ResumeFactory"
import { ModernJSONResume } from "../resumes/json/ModernJSONResume"
import { ModernMarkdownResume } from "../resumes/markdown/ModernMarkdownResume"
import { ModernPDFResume } from "../resumes/pdf/ModernPDFResume"
export class ModernResumeFactory implements ResumeFactory {
createPDFResume(): ModernPDFResume {
return new ModernPDFResume() // extends PDFResume
}
createMarkdownResume(): ModernMarkdownResume {
return new ModernMarkdownResume() // extends MarkdownResume
}
createJSONResume(): ModernJSONResume {
return new ModernJSONResume() // extends JSONResume
}
}
- The ModernResumeFactory factory methods return the modern concrete product variant for every type of product.
The Creative Resume Factory Concrete Products
Now, let's create the previous ConcreteProducts which are returned by the CreativeResumeFactory
PDF Resume :
resumes/pdf/CreativePDFResume
import { ResumeData } from "../../interfaces/Types"
import { PDFResume } from "./PdfResume"
export class CreativePDFResume extends PDFResume {
generate(data: ResumeData): void {
this.doc.rect(0, 0, 200, this.doc.page.height).fill("#FFD700")
this.doc.fill("black").fontSize(28).text(data.name, 220, 50)
this.doc.fontSize(12).text(`\${data.email} | \${data.phone}`, 220, 80)
this.doc.fontSize(16).text("Experience", 220, 120)
let yPos = 140
data.experience.forEach((exp: any) => {
this.doc.fontSize(14).text(exp.company, 220, yPos)
this.doc
.fontSize(12)
.text(
`\${exp.position} (\${exp.startDate} - \${exp.endDate})`,
220,
yPos + 20
)
this.doc
.fontSize(10)
.text(exp.description, 220, yPos + 40, { width: 350 })
yPos += 80
})
}
}
Markdown Resume :
resumes/markdown/CreativeMarkdownResume
import { ResumeData } from "../../interfaces/Types"
import { MarkdownResume } from "./MarkdownResume"
export class CreativeMarkdownResume extends MarkdownResume {
generate(data: ResumeData): void {
this.content = `# 🌟 \${data.name} 🌟\n\n`
this.content += `📧 \${data.email} | 📞 \${data.phone}\n\n`
this.content += "## 💼 Career Journey\n\n"
data.experience.forEach((exp: any) => {
this.content += `### 🏢 \${exp.company}\n\n`
this.content += ` **\${exp.position}** | \${exp.startDate} - \${exp.endDate}\n\n`
this.content += `\${exp.description}\n\n`
})
}
}
JSON Resume :
resumes/json/CreativeJSONResume
import { ResumeData } from "../../interfaces/Types"
import { JSONResume } from "./JSONResume"
export class CreativeJSONResume extends JSONResume {
generate(data: ResumeData): void {
this.data = {
style: "creative",
...data,
}
}
}
The Minimalist Resume Factory Concrete Products
Next, let's create the previous ConcreteProducts which are returned by the MinimalistResumeFactory
PDF Resume :
resumes/pdf/MinimalistPDFResume
import { ResumeData } from "../../interfaces/Types"
import { PDFResume } from "./PdfResume"
export class MinimalistPDFResume extends PDFResume {
generate(data: ResumeData): void {
this.doc.fontSize(24).text(data.name, { align: "center" })
this.doc
.fontSize(12)
.text(`Email: \${data.email} | Phone: \${data.phone}`, {
align: "center",
})
this.doc.moveDown()
this.doc.fontSize(16).text("Experience")
data.experience.forEach((exp: any) => {
this.doc.fontSize(14).text(exp.company)
this.doc
.fontSize(12)
.text(`\${exp.position} (\${exp.startDate} - \${exp.endDate})`)
this.doc.fontSize(10).text(exp.description)
this.doc.moveDown()
})
}
}
Markdown Resume :
resumes/markdown/MinimalistMarkdownResume
import { ResumeData } from "../../interfaces/Types"
import { MarkdownResume } from "./MarkdownResume"
export class MinimalistMarkdownResume extends MarkdownResume {
generate(data: ResumeData): void {
this.content = `# \${data.name}\n\n`
this.content += `Email: \${data.email} | Phone: \${data.phone}\n\n`
this.content += "## Experience\n\n"
data.experience.forEach((exp: any) => {
this.content += `### \${exp.company}\n\n`
this.content += `\${exp.position} (\${exp.startDate} - \${exp.endDate})\n\n`
this.content += `\${exp.description}\n\n`
})
}
}
JSON Resume :
resumes/json/MinimalistJSONResume
import { ResumeData } from "../../interfaces/Types"
import { JSONResume } from "./JSONResume"
export class MinimalistJSONResume extends JSONResume {
generate(data: ResumeData): void {
this.data = {
style: "minimalist",
...data,
}
}
}
The Modern Resume Factory Concrete Products
Finally, let's create the previous ConcreteProducts which are returned by the ModernResumeFactory
PDF Resume :
resumes/pdf/ModernPDFResume
import { ResumeData } from "../../interfaces/Types"
import { PDFResume } from "./PdfResume"
export class ModernPDFResume extends PDFResume {
generate(data: ResumeData): void {
this.doc.rect(0, 0, this.doc.page.width, 60).fill("#4A90E2")
this.doc.fill("white").fontSize(28).text(data.name, 50, 20)
this.doc.fontSize(12).text(`\${data.email} | \${data.phone}`, 50, 50)
this.doc.fill("black").fontSize(16).text("Experience", 50, 80)
let yPos = 100
data.experience.forEach((exp: any) => {
this.doc.fontSize(14).text(exp.company, 50, yPos)
this.doc
.fontSize(12)
.text(
`\${exp.position} (\${exp.startDate} - \${exp.endDate})`,
50,
yPos + 20
)
this.doc.fontSize(10).text(exp.description, 50, yPos + 40, { width: 500 })
yPos += 80
})
}
}
Markdown Resume :
resumes/markdown/ModernMarkdownResume
import { ResumeData } from "../../interfaces/Types"
import { MarkdownResume } from "./MarkdownResume"
export class ModernMarkdownResume extends MarkdownResume {
generate(data: ResumeData): void {
this.content = `# \${data.name}\n\n`
this.content += ` **Email:** \${data.email} | **Phone:** \${data.phone}\n\n`
this.content += "## Professional Experience\n\n"
data.experience.forEach((exp: any) => {
this.content += `### \${exp.company}\n\n`
this.content += ` **\${exp.position}** | \${exp.startDate} - \${exp.endDate}\n\n`
this.content += `\${exp.description}\n\n`
})
}
}
JSON Resume :
resumes/json/ModernJSONResume
import { ResumeData } from "../../interfaces/Types"
import { JSONResume } from "./JSONResume"
export class ModernJSONResume extends JSONResume {
generate(data: ResumeData): void {
this.data = {
style: "modern",
...data,
}
}
}
Using Our Factories in our Index.ts File
Let's start bearing the fruits of our previous work by using our factories in the client code.
Look how we can now consume our resume builder library in a very clean way by just using our factories.
The user only has to provide two things:
- The Product Type : What type of PDFs does he want to create?
- The theme : What kind of resume styles does he prefer?
index.ts
#!/usr/bin/env node
import chalk from "chalk"
import inquirer from "inquirer"
import { CreativeResumeFactory } from "./factories/CreativeResumeFactory"
import { MinimalistResumeFactory } from "./factories/MinimalistResumeFactory"
import { ModernResumeFactory } from "./factories/ModernResumeFactory"
import { ResumeFactory } from "./interfaces/ResumeFactory"
import { getUserInput } from "./utils/userInput"
async function main() {
console.log(chalk.blue.bold("Welcome to the Theme-Based Resume Generator!"))
const { theme, format } = await inquirer.prompt([
{
type: "list",
name: "theme",
message: "Choose a resume theme:",
choices: ["minimalist", "modern", "creative"],
},
{
type: "list",
name: "format",
message: "Choose an output format:",
choices: ["pdf", "markdown", "json"],
},
])
let factory: ResumeFactory
switch (theme) {
case "minimalist":
factory = new MinimalistResumeFactory()
break
case "modern":
factory = new ModernResumeFactory()
break
case "creative":
factory = new CreativeResumeFactory()
break
default:
throw new Error("Invalid theme.")
}
const userInput = await getUserInput()
let resume
switch (format) {
case "pdf":
resume = factory.createPDFResume()
break
case "markdown":
resume = factory.createMarkdownResume()
break
case "json":
resume = factory.createJSONResume()
break
default:
throw new Error("Invalid format.")
}
console.log(chalk.yellow("Generating your resume..."))
try {
resume.generate(userInput)
const fileName = `\${userInput.name.replace(/\s+/g, "_")}_\${theme}_resume.\${format}`
await resume.saveToFile(fileName)
console.log(chalk.green.bold("Resume generated successfully!"))
console.log(
chalk.cyan(
`Your \${theme} \${format} resume has been saved as \${fileName}.`
)
)
} catch (error) {
console.error(
chalk.red("Error generating resume:"),
(error as Error).message
)
}
}
main().catch((error) => {
console.error(chalk.red("An unexpected error occurred:"), error.message)
process.exit(1)
})
The code above works in three steps:
- User Inputs: We first get the theme and format values.
- Choosing A factory : Then we instantiate the corresponding factory based on the theme value.
- Creating the Product : Finally, we call the corresponding factory method depending on the chosen format.
The user doesn't care about how products and their corresponding variants are created; they only need to select a theme and format , and that's it - the corresponding product gets created as requested.
The client code is now robust for changes. If we want to add a new theme or style, we can just create a new factory which is responsible for doing so.
We've used the chalk library to color our terminal logs depending on their semantic meaning.
To be able to get the inputs from the CLI app's user, we've used the inquirer package, which provides a really appealing and user-friendly way to get various types of inputs from the user.
- The getUserInput function was used to get the main resume information: name, email, phone.
- The getExperience utility function was used to recursively retrieve the experience information from the user. In other words, it prompts the user to fill in the experience information for the first entry, then asks if they have another experience to add. If the answer is no, the function just returns; on the other hand, if they select yes, they will be asked again to fill in the next experience's information.
utils/userInput
import inquirer from "inquirer"
import { Experience, ResumeData } from "../interfaces/Types"
export async function getUserInput(): Promise<ResumeData> {
const questions = [
{
type: "input",
name: "name",
message: "Enter your full name:",
validate: (input: string) => input.trim() !== "" || "Name is required",
},
{
type: "input",
name: "email",
message: "Enter your email address:",
validate: (input: string) =>
/\S+@\S+\.\S+/.test(input) || "Please enter a valid email address",
},
{
type: "input",
name: "phone",
message: "Enter your phone number:",
validate: (input: string) =>
input.trim() !== "" || "Phone number is required",
},
]
const { name, email, phone } = await inquirer.prompt<{
name: string
email: string
phone: string
}>(questions)
const experience = await getExperience()
return { name, email, phone, experience }
}
async function getExperience(): Promise<Experience[]> {
const experience: Experience[] = []
let addMore = true
while (addMore) {
const job = await inquirer.prompt<Experience>([
{
type: "input",
name: "company",
message: "Enter company name:",
validate: (input: string) =>
input.trim() !== "" || "Company name is required",
},
{
type: "input",
name: "position",
message: "Enter your position:",
validate: (input: string) =>
input.trim() !== "" || "Position is required",
},
{
type: "input",
name: "startDate",
message: "Enter start date (MM/YYYY):",
validate: (input: string) =>
/^\d{2}\/\d{4}$/.test(input) || "Please enter a valid date (MM/YYYY)",
},
{
type: "input",
name: "endDate",
message: 'Enter end date (MM/YYYY or "Present"):',
validate: (input: string) =>
/^\d{2}\/\d{4}$/.test(input) ||
input.toLowerCase() === "present" ||
'Please enter a valid date (MM/YYYY) or "Present"',
},
{
type: "input",
name: "description",
message: "Enter job description:",
validate: (input: string) =>
input.trim() !== "" || "Job description is required",
},
])
experience.push(job)
const { continueAdding } = await inquirer.prompt<{
continueAdding: boolean
}>({
type: "confirm",
name: "continueAdding",
message: "Do you want to add another job experience?",
default: false,
})
addMore = continueAdding
}
return experience
}
Conclusion
The Abstract Factory pattern is a powerful tool in the arsenal of software designers and developers. It provides a structured approach to creating families of related objects without specifying their concrete classes. This pattern is particularly useful when:
- A system should be independent of how its products are created, composed, and represented.
- A system needs to be configured with one of multiple families of products.
- A family of related product objects is designed to be used together, and you need to enforce this constraint.
- You want to provide a class library of products, and you want to reveal just their interfaces, not their implementations.
In our practical example, we've seen how the Abstract Factory pattern can be applied to create a flexible and extensible resume generation system. This system can easily accommodate new resume styles or output formats without modifying the existing code, demonstrating the power of the Open/Closed Principle in action.
While the Abstract Factory pattern offers many benefits, it's important to note that it can introduce additional complexity to your codebase. Therefore, it's crucial to assess whether the flexibility it provides is necessary for your specific use case.
By mastering design patterns like the Abstract Factory, you'll be better equipped to create robust, flexible, and maintainable software systems. Keep exploring and applying these patterns in your projects to elevate your software design skills.
Contact
If you have any questions or want to discuss something further, feel free to Contact me here.
Happy coding!
Top comments (0)