Understanding Creational Design Patterns

Krishan Aggarwal
8 min readApr 20, 2024

--

Photo by Roman Synkevych on Unsplash

Disclaimer : This article is exclusively for pizza lovers. If you’re not a fan of pizza, you might want to grab a slice before diving in!

Imagine you’re craving a customized pizza masterpiece — maybe with extra cheese and pineapple (a controversial choice, we know!). Now, what if you could whip up your dream pie from scratch, with the perfect ingredients and toppings? That’s where creational design patterns come into play, turning your coding cravings into reality.

In the world of software development, just like crafting the perfect pizza, sometimes you need more flexibility and control over how objects are created. Whether you’re building a small web application or a complex enterprise system, creational design patterns offer a recipe for success. They provide reusable solutions to common problems, allowing you to whip up objects with just the right ingredients, without reinventing the wheel every time.

So, why should you care about creational design patterns ? Well, think of them as your secret sauce for creating scalable, maintainable, and deliciously efficient code. Whether you’re a seasoned developer or just dipping your toes into the world of software architecture, understanding these patterns will level up your programming game faster than you can say “extra cheese, please!”

Factory Pattern

Managing a pizzeria can be chaotic. Initially, it's simple – you handcraft each pizza to order. But as requests for specialty pizzas flood in, maintaining efficiency becomes a challenge. You start creating separate classes for each pizza type in your PizzaService, cluttering your codebase and making it hard to maintain.
Here’s how our code would look like in Standard approach :

import { MargheritaPizza } from './MargheritaPizza';
import { PepperoniPizza } from './PepperoniPizza';
import { VeggiePizza } from './VeggiePizza';

export class PizzaService {
createPizza(type: string) {
switch (type) {
case 'Margherita':
return new MargheritaPizza();
case 'Pepperoni':
return new PepperoniPizza();
default:
throw new Error('Invalid pizza type!');
}
}

As the pizza menu grows, so does the complexity of your code. Each new pizza type requires modifying the PizzaService, leading to bloated, error-prone code. Plus, any change risks breaking other parts of your system – not ideal for a smooth-running pizzeria, or software application.

Enter the Factory Pattern:

In software development, the Factory Pattern abstracts object creation by providing an interface for creating objects in a superclass. Subclasses then implement this interface to create specific types of objects, promoting loose coupling and scalability.

class PizzaFactory {
createPizza(type: string): Pizza {
switch (type.toLowerCase()) {
case 'margherita':
return new MargheritaPizza();
case 'pepperoni':
return new PepperoniPizza();
default:
throw new Error(`Unknown pizza type: ${type}`);
}
}
}

@Injectable()
class PizzaService {
constructor(private readonly pizzaFactory: PizzaFactory) {}

orderPizza(type: string) {
try {
const pizza = this.pizzaFactory.createPizza(type);
pizza.prepare();
pizza.bake();
} catch (error) {
console.error(error.message);
}
}
}

This approach has several benefits:

  1. Code Reusability: The pizza creation logic is centralized in the factory class, so you don’t have to duplicate it across your codebase.
  2. Easy Maintenance: If you need to add or remove pizza types, you only have to modify the factory class, keeping your codebase more maintainable.
  3. Decoupling: The code that creates pizza objects is decoupled from the rest of your application logic. If you need to change how pizzas are created, you only have to update the factory class.

Abstract Factory Pattern

Imagine you’re not only making pizzas but also other Italian dishes like pasta. Each dish has its own set of ingredients and preparation methods. In the standard approach, you might end up with separate factories for pizzas and pasta, leading to duplication and coupling.
Let see our code in the standard approach :

// Pizza classes
class MargheritaPizza {
prepare() {
console.log("Preparing Margherita pizza...");
}
// Other pizza methods...
}

class PepperoniPizza {
prepare() {
console.log("Preparing Pepperoni pizza...");
}
}

// Pasta classes
class SpaghettiCarbonara {
prepare() {
console.log("Preparing Spaghetti Carbonara...");
}
}
class PenneArrabiata {
prepare() {
console.log("Preparing Penne Arrabiata...");
}
}

Now, if you receive an order for pizza or pasta, you might manually choose the respective class to create the dish, which could lead to similar issues we discussed earlier with the standard factory pattern.

const orderType = 'pasta';

let dish;

if (orderType === 'pizza') {
dish = new MargheritaPizza();
} else if (orderType === 'pasta') {
dish = new SpaghettiCarbonara();
} else {
throw new Error(`Unknown dish type: ${orderType}`);
}

dish.prepare();

This approach works, but it has its flaws:

  1. Code Duplication: Just like before, you’re duplicating logic for creating different types of dishes.
  2. Coupling: The code for deciding which dish to create is coupled with the rest of your application logic, making it harder to change or extend in the future.

Now, let’s see how the abstract factory pattern can help.

The abstract factory pattern provides an interface for creating families of related or dependent objects without specifying their concrete classes. It allows you to create families of objects (in our case, pizzas and pasta) without specifying their concrete classes upfront.

First, let’s define the interfaces and classes for our abstract factory:

// Abstract classes/interfaces for pizza and pasta
interface Pizza {
prepare(): void;
// Other pizza methods...
}

interface Pasta {
prepare(): void;
// Other pasta methods...
}

// Abstract factory interface
interface ItalianFoodFactory {
createPizza(): Pizza;
createPasta(): Pasta;
}


class MargheritaPizza implements Pizza {
prepare() {
console.log("Preparing Margherita pizza...");
}
// Other pizza methods...
}

class PepperoniPizza implements Pizza {
prepare() {
console.log("Preparing Pepperoni pizza...");
}
// Other pizza methods...
}

// Concrete pasta classes
class SpaghettiCarbonara implements Pasta {
prepare() {
console.log("Preparing Spaghetti Carbonara...");
}
// Other pasta methods...
}

class PenneArrabiata implements Pasta {
prepare() {
console.log("Preparing Penne Arrabiata...");
}
// Other pasta methods...
}

// Concrete factory implementing the abstract factory interface
class ItalianRestaurant implements ItalianFoodFactory {
createPizza(): Pizza {
// Logic to create and return a pizza
return new MargheritaPizza();
}

createPasta(): Pasta {
// Logic to create and return a pasta
return new SpaghettiCarbonara();
}
}
const italianRestaurant = new ItalianRestaurant();
const pizza = italianRestaurant.createPizza();
const pasta = italianRestaurant.createPasta();

pizza.prepare();
pasta.prepare();

In this code, you’re able to create both pizzas and pasta without knowing the specific classes of each dish. Thus Abstract factory pattern decouples the client code from the concrete classes, making it more flexible and easier to maintain.🍝🍕

Builder Pattern

Imagine you’re not only creating pizzas and pasta but also allowing customers to customize their orders with various toppings and ingredients. In the standard approach, you might end up with multiple constructors or methods with optional parameters, leading to complex and error-prone code.

Here’s how it might look without using the builder pattern:

class Pizza {
private toppings: string[] = [];
private size: string;
private crust: string;

constructor(size: string, crust: string, toppings: string[] = []) {
this.size = size;
this.crust = crust;
this.toppings = toppings;
}

// Methods to add toppings, bake, cut, etc.
}

Now, if a customer wants a custom pizza with specific toppings, you might need to pass a lot of parameters to the constructor:

const customPizza = new Pizza('large', 'thin', ['cheese','mushrooms']);

This approach works, but it can become unwieldy and error-prone, especially as the number of parameters grows.

Here’s how the builder pattern can help:

The builder pattern separates the construction of a complex object from its representation, allowing you to create objects step by step. Each step involves calling a method to set one or more properties of the object until it’s fully constructed.

class Pizza {
private toppings: string[] = [];
private size: string;
private crust: string;

constructor(builder: PizzaBuilder) {
this.size = builder.size;
this.crust = builder.crust;
this.toppings = builder.toppings;
}

// Methods to add toppings, bake, cut, etc.
}

class PizzaBuilder {
private _size: string;
private _crust: string;
private _toppings: string[] = [];

constructor(size: string, crust: string) {
this._size = size;
this._crust = crust;
}

addToppings(toppings: string[]) {
this._toppings.push(...toppings);
return this;
}

build() {
return new Pizza(this);
}

get size() {
return this._size;
}

get crust() {
return this._crust;
}

get toppings() {
return this._toppings;
}
}

Now, you can create a pizza step by step using the builder pattern:

const customPizza = new PizzaBuilder('large', 'thin')
.addToppings(['cheese', 'pepperoni', 'mushrooms'])
.build();

This way, the builder pattern allows you to create complex objects with a clear and fluent interface, making it easy to understand and maintain.
It’s like having your own personal pizza architect, ensuring that every pie is crafted to perfection! 🍕

Prototype Pattern

Imagine you’re running a pizza restaurant where customers often order similar pizzas with slight variations. In the standard approach, if you have to create similar objects repeatedly, you might end up duplicating a lot of initialization code or resorting to manual copying, leading to code redundancy and maintenance issues.

Here’s how it might look without using the prototype pattern:

class Pizza {
private size: string;
private crust: string;
private toppings: string[];

constructor(size: string, crust: string, toppings: string[]) {
this.size = size;
this.crust = crust;
this.toppings = toppings;
}

// Method to add toppings, etc.
}

const basePizza = new Pizza('medium', 'thin', ['cheese']);
const customPizza = new Pizza('large', 'thick', ['cheese', 'pepperoni']);

In this approach, you’re manually creating each pizza object, which can be tedious and error-prone, especially if the initialization logic is complex or if you have to create many similar objects.

Now, let’s see how the prototype pattern can help:

The prototype pattern allows you to create new objects by cloning an existing object, known as the prototype. This approach eliminates the need for manual copying or initialization, making it easier to create new objects with default or customized properties.

Let’s implement the prototype pattern for creating pizzas:

class Pizza {
private size: string;
private crust: string;
private toppings: string[];

constructor(size: string, crust: string, toppings: string[]) {
this.size = size;
this.crust = crust;
this.toppings = toppings;
}

clone(): Pizza {
return new Pizza(this.size, this.crust, this.toppings);
}
}

const basePizza = new Pizza('medium', 'thin', ['cheese']);
const customPizza = basePizza.clone();
customPizza.addToppings(['pepperoni', 'mushrooms']);

This way, the prototype pattern allows you to create new objects based on existing prototypes, reducing redundancy and making your code more maintainable.
It’s like having a pizza replication machine in your kitchen, ready to produce delicious pies at a moment’s notice! 🍕

Singleton Pattern

Imagine you have a pizza oven in your restaurant that needs to be shared across multiple pizza orders. In the standard approach, you might end up creating multiple instances of the oven, leading to resource wastage and inconsistency in baking pizzas.

Here’s how it might look without using the Singleton pattern:

class PizzaOven {
// Imagine some properties and methods here...
}

// Creating multiple instances of PizzaOven
const oven1 = new PizzaOven();
const oven2 = new PizzaOven();

console.log(oven1 === oven2); // Output: false, different instances

In this standard approach, each time you create a PizzaOven object, a new instance is created, even if you intend to use the oven in a singleton manner.

Now, let’s see how the Singleton pattern can help:

The Singleton pattern ensures that a class has only one instance and provides a global point of access to that instance.

Here’s how you can implement the Singleton pattern for the pizza oven:

class PizzaOven {
private static instance: PizzaOven;

// The PizzaOven class has a private constructor to
// prevent direct instantiation from outside the class.
private constructor() {
// Imagine some initialization here...
}

static getInstance(): PizzaOven {
if (!PizzaOven.instance) {
PizzaOven.instance = new PizzaOven();
}
return PizzaOven.instance;
}
}

// Accessing the singleton instance of PizzaOven
const oven1 = PizzaOven.getInstance();
const oven2 = PizzaOven.getInstance();

console.log(oven1 === oven2); // Output: true, same instance

So, with the Singleton pattern, you can ensure that there’s only one instance of the pizza oven, preventing unnecessary duplication of resources and ensuring consistent behavior across your application. It’s like having a single, reliable oven in your kitchen that all your chefs can use without causing chaos! 🍕

What’s Next: Exploring Design Principles and Structural Patterns

As you savor the taste of creational design patterns, dive deeper into the world of software engineering with design principles like SOLID and structural patterns like Adapter and Decorator. These concepts will enhance your ability to create flexible, maintainable, and scalable software solutions, ensuring that your coding journey remains both delicious and enriching. 🚀

--

--

Krishan Aggarwal

I am here to turn backend mysteries into bedtime stories—with a side of laughter and simplicity.