📝 Topics Covered

  1. 1. What are Creational Patterns?
  2. 2. Factory Method Design Pattern
  3. 3. Builder Design Pattern
  4. 4. Singleton Design Pattern
  5. 5. Prototype Design Pattern
  6. 6. Abstract Factory Design Pattern

1. What are Creational Patterns?

Creational design patterns deal with object creation mechanisms. They increase flexibility and reuse of existing code by abstracting the instantiation process. Rather than instantiating concrete classes directly, these patterns isolate how objects are created, composed, and represented.

We will cover the five core Creational patterns in this guide:

  1. Factory Method: Defer instantiation logic to subclasses.
  2. Builder: Construct complex objects step-by-step.
  3. Singleton: Restrict class instantiation to a single shared instance.
  4. Prototype: Clone fully configured objects to avoid expensive instantiation.
  5. Abstract Factory: Create families of related or dependent objects.

2. Factory Method Design Pattern

Factory design pattern is a creational design pattern that separates the logic of creating objects from the client code. The factory class is responsible for creating objects based on client parameters, shielding the client from concrete instantiation details.

🎥 Tutorial: Factory Method Pattern

  • Key Takeaways:
    • Avoids Tight Coupling: Eliminates direct dependencies between the creator class and concrete classes.
    • Open-Closed Principle: You can introduce new subclasses without breaking existing client code.
    • Shared Abstraction: The factory exposes a common shared type or interface, allowing high-level polymorphism.

🎥 Real-World Factory Pattern Use Cases

2.1 Factory Example 1: Vehicle Production

The Tightly-Coupled Way (Violates OCP): If we want to introduce a new Truck vehicle, the client must modify their nested conditional blocks directly.

# Interface
class Vehicle:
    def create_vehicle(self):
        pass

class Car(Vehicle):
    def create_vehicle(self):
        print('Car created')

class Bike(Vehicle):
    def create_vehicle(self):
        print("Bike created")

# Client code has to change every time a new class is introduced
if __name__ == '__main__':
    vehicle_type = 'car'
    if vehicle_type == 'car':
        car = Car()
        car.create_vehicle()
    elif vehicle_type == 'bike':
        bike = Bike()
        bike.create_vehicle()

The Clean Factory Way (Adheres to OCP): By introducing a VehicleFactory, the instantiation details are encapsulated entirely.

class Vehicle:
    def create_vehicle(self):
        pass

class Car(Vehicle):
    def create_vehicle(self):
        print('Car created')

class Bike(Vehicle):
    def create_vehicle(self):
        print("Bike created")

class VehicleFactory:
    @staticmethod
    def get_vehicle(vehicle_type):
        if vehicle_type == 'car':
            return Car()
        elif vehicle_type == 'bike':
            return Bike()
        raise ValueError(f"Unknown vehicle type: {vehicle_type}")

# Usage
if __name__ == '__main__':
    vehicle = VehicleFactory.get_vehicle('bike')
    vehicle.create_vehicle()

2.2 Factory Example 2: Milkshake Factory

from enum import Enum

class MilkshakeName(Enum):
    OREO = 0
    BUTTERSCOTCH = 1
    VANILLA = 2
    
class Milkshake:
    def __init__(self):
        self.name = None


class ButterscotchMilkshake(Milkshake):
    def __init__(self):
        super().__init__()
        self.name = "Butterscotch Milkshake"
        print(f"Prepared: {self.name}")

class VanillaMilkshake(Milkshake):
    def __init__(self):
        super().__init__()
        self.name = 'Vanilla Milkshake'
        print(f"Prepared: {self.name}")

class OreoMilkshake(Milkshake):
    def __init__(self):
        super().__init__()
        self.name = "Oreo Milkshake"
        print(f"Prepared: {self.name}")


class MilkshakeFactory:
    @staticmethod
    def create_milkshake(milkshake_code):
        if milkshake_code == MilkshakeName.OREO:
            return OreoMilkshake()
        elif milkshake_code == MilkshakeName.BUTTERSCOTCH:
            return ButterscotchMilkshake()
        elif milkshake_code == MilkshakeName.VANILLA:
            return VanillaMilkshake()
        raise ValueError("Invalid Milkshake code")

# Usage
if __name__ == '__main__':
    MilkshakeFactory.create_milkshake(MilkshakeName.BUTTERSCOTCH)

3. Builder Design Pattern

The Builder design pattern is used to construct complex objects step-by-step. Whenever we need to create complex objects with extensive configurations, Builder is the ideal pattern.

🎥 Tutorial: Builder Design Pattern

  • Key Takeaways:
    • Combats Telescoping Constructors: Gets rid of huge constructors taking massive lists of parameters.
    • Separation of Concerns: Encapsulates construction logic away from final product representations.
    • Fluent API Interface: Supports method chaining (e.g. StringBuilder in Java) for clean, readable code.

3.1 Builder Example 1: Customer Profile Creation

Without Builder (Cluttered methods for every optional parameter combination):

class Customer:
    def __init__(self):
        self.f_name = None
        self.m_name = None
        self.l_name = None

    def set_name_full(self, f_name, m_name, l_name):
        self.f_name = f_name
        self.m_name = m_name
        self.l_name = l_name

    def set_name_short(self, f_name, l_name):
        self.f_name = f_name
        self.l_name = l_name
    
    def get_name(self):
        print(f'{self.f_name} {self.m_name or ""} {self.l_name}')

With Fluent Builder:

class Customer:
    def __init__(self):
        self.builder = CustomerBuilder()

    def first_name(self, first):
        self.builder.parts.append(first)
        return self

    def middle_name(self, middle):
        self.builder.parts.append(middle)
        return self

    def last_name(self, last):
        self.builder.parts.append(last)
        return self

    def build(self):
        print(' '.join(self.builder.parts))


class CustomerBuilder:
    def __init__(self):
        self.parts = []

# Usage
if __name__ == '__main__':
    Customer().first_name('Amrit').middle_name('Kr').last_name('Prasad').build()

3.2 Builder Example 2: Complex Multi-Builder Person Profile

Using dedicated child builders (Information, Address, Job) operating on the same underlying object:

class Person:
    def __init__(self):
        self.name = None
        self.city = None
        self.company_name = None

    def __str__(self) -> str:
        return f'Name: {self.name}, Address: {self.city}, Employed at: {self.company_name}'


class PersonBuilder:
    def __init__(self, person=None):
        self.person = Person() if person is None else person
    
    @property
    def info(self):
        return PersonInfoBuilder(self.person)

    @property
    def lives(self):
        return PersonAddressBuilder(self.person)

    @property
    def works(self):
        return PersonJobBuilder(self.person)

    def build(self):
        return self.person


class PersonInfoBuilder(PersonBuilder):
    def name(self, name):
        self.person.name = name
        return self

class PersonJobBuilder(PersonBuilder):
    def at(self, company_name):
        self.person.company_name = company_name
        return self

class PersonAddressBuilder(PersonBuilder):
    def in_city(self, city):
        self.person.city = city
        return self


# Usage
if __name__ == '__main__':
    person = PersonBuilder()\
        .info.name('Amrit')\
        .lives.in_city('London')\
        .works.at('Fabrikam')\
        .build()
    print(person)

4. Singleton Design Pattern

The Singleton pattern ensures that a class has only one instance and provides a global access point to it. This pattern reduces memory usage by sharing a single connection or manager globally.

  • Typical Use Cases:
    • Shared Database Connections.
    • Application Loggers.
    • Shared Config Services.

🎥 Tutorial: Singleton Design Pattern

  • Loading Approaches:
    • Eager Loading: The instance is initialized as soon as the application launches.
    • Lazy Loading: The instance is only initialized when a module explicitly requests it.
  • Trade-Offs:
    • Pros: Guaranteed single instance, structured access to a shared resource, and straightforward implementation.
    • Cons: Harder to unit-test (introduces global state), and can introduce synchronization issues in multi-threaded contexts if not handled correctly.

4.1 Singleton via Static Methods (Basic)

class LoggerService:
    logger = None

    @staticmethod
    def get_logger():
        if LoggerService.logger is None:
            LoggerService.logger = []
            print('Initialized Logger Array')
        return LoggerService.logger
    
    @staticmethod
    def log(message):
        LoggerService.get_logger().append(message)
        print(f"Log: {LoggerService.logger}")

# Usage
if __name__ == "__main__":
    logger = LoggerService()
    logger.log('message-1')
    LoggerService.log('message-2')

4.2 Singleton using new (Allocator Pattern)

class Database:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            cls._instance = super(Database, cls).__new__(cls, *args, **kwargs)
            print('Loading database from file...')
        return cls._instance

# Usage
if __name__ == '__main__':
    d1 = Database()
    d2 = Database()
    print(f"Are instances identical? {d1 is d2}")

4.3 Singleton via Class Decorators

def singleton(class_):
    instances = {}
    def get_instance(*args, **kwargs):
        if class_ not in instances:
            instances[class_] = class_(*args, **kwargs)
        return instances[class_]
    return get_instance

@singleton
class Database:
    def __init__(self):
        print('Loading database...')

# Usage
if __name__ == '__main__':
    d1 = Database()
    d2 = Database()
    print(f"Are decorator instances identical? {d1 is d2}")

4.4 Singleton via Metaclasses

class Singleton(type):
    _instances = {}
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
        return cls._instances[cls]

class Database(metaclass=Singleton):
    def __init__(self):
        print('Loading database via metaclass...')

# Usage
if __name__ == '__main__':
    d1 = Database()
    d2 = Database()
    print(f"Are metaclass instances identical? {d1 is d2}")

5. Prototype Design Pattern

Prototype is a creational design pattern that lets you copy existing objects without making your code dependent on their concrete classes. Instead of constructing complicated objects from scratch, we reiterate and clone existing designs.

  • Key Takeaway: An existing fully configured object acts as a Prototype. We make deep copies (clones) of this prototype and customize the clone’s properties.

5.1 Prototype via Deep Copy

import copy

class Address:
    def __init__(self, street_address, city, country):
        self.country = country
        self.city = city
        self.street_address = street_address

    def __str__(self):
        return f'{self.street_address}, {self.city}, {self.country}'


class Person:
    def __init__(self, name, address):
        self.name = name
        self.address = address

    def __str__(self):
        return f'{self.name} lives at {self.address}'


if __name__ == '__main__':
    john = Person("John", Address("123 London Road", "London", "UK"))
    print(john)
    
    # Cloning the prototype
    jane = copy.deepcopy(john)
    jane.name = "Jane"
    jane.address.street_address = "124 xyz Road"
    
    print(john)
    print(jane)

5.2 Combining Prototype with a Factory

import copy

class Address:
    def __init__(self, street_address, city, country):
        self.country = country
        self.city = city
        self.street_address = street_address

    def __str__(self):
        return f'{self.street_address}, {self.city}, {self.country}'


class Person:
    def __init__(self, name, address):
        self.name = name
        self.address = address

    def __str__(self):
        return f'{self.name} lives at {self.address}'


class PersonFactory:
    prototype_person = Person("Prototype", Address("", "London", "UK"))

    @staticmethod
    def create_london_employee(name, street):
        employee = copy.deepcopy(PersonFactory.prototype_person)
        employee.name = name
        employee.address.street_address = street
        return employee


# Usage
if __name__ == '__main__':
    john = PersonFactory.create_london_employee('John', '123 London Road')
    ap = PersonFactory.create_london_employee('AP', '124 xyz Road')
    print(john)
    print(ap)

6. Abstract Factory Design Pattern

Abstract Factory is an abstraction layer over the Factory design pattern. It allows us to create families of related objects without specifying their concrete classes. It is essentially a factory of factories.

  • Analogy: While a single Factory creates products (e.g. Tata Cars), an Abstract Industry contains multiple Factories (e.g. Tata Factory, Hyundai Factory) that manufacture products.

🎥 Tutorial: Abstract Factory Design Pattern

6.1 Abstract Factory Example 1: Vehicle Brands

class Car:
    def drive(self):
        pass

class HyundaiCar(Car):
    def drive(self):
        print('Driving a Hyundai Car')

class TataCar(Car):
    def drive(self):
        print('Driving a Tata Car')


class Bike:
    def ride(self):
        pass

class HyundaiBike(Bike):
    def ride(self):
        print('Riding a Hyundai Bike')

class TataBike(Bike):
    def ride(self):
        print('Riding a Tata Bike')


# Concrete Factories
class TataFactory:
    @staticmethod
    def get_car():
        return TataCar()

    @staticmethod
    def get_bike():
        return TataBike()


class HyundaiFactory:
    @staticmethod
    def get_car():
        return HyundaiCar()

    @staticmethod
    def get_bike():
        return HyundaiBike()


# The Abstract Factory (Factory of Factories)
class VehicleBrandFactory:
    @staticmethod
    def get_factory(brand):
        if brand.lower() == 'tata':
            return TataFactory()
        elif brand.lower() == 'hyundai':
            return HyundaiFactory()
        raise ValueError(f"Unknown brand factory: {brand}")


# Usage
if __name__ == '__main__':
    factory = VehicleBrandFactory.get_factory('hyundai')
    car = factory.get_car()
    car.drive()

6.2 Abstract Factory Example 2: Hot Drinks

from abc import ABC, abstractmethod

# Product Hierarchies
class HotDrink(ABC):
    @abstractmethod
    def consume(self):
        pass

class Tea(HotDrink):
    def consume(self):
        print("This tea is nice but I'd prefer it with milk.")

class Coffee(HotDrink):
    def consume(self):
        print("This coffee is delicious.")


# Abstract Factory Interface
class HotDrinkFactory(ABC):
    @abstractmethod
    def prepare(self, amount):
        pass

# Concrete Factories
class TeaFactory(HotDrinkFactory):
    def prepare(self, amount):
        print(f"Steeping tea bag, pouring {amount}ml of boiling water...")
        return Tea()

class CoffeeFactory(HotDrinkFactory):
    def prepare(self, amount):
        print(f"Grinding coffee beans, pouring {amount}ml of boiling water...")
        return Coffee()


# Client Driver
def make_drink(drink_type):
    if drink_type.lower() == 'tea':
        return TeaFactory().prepare(200)
    elif drink_type.lower() == 'coffee':
        return CoffeeFactory().prepare(50)
    return None


if __name__ == '__main__':
    drink = make_drink('tea')
    if drink:
        drink.consume()