Skip to content

🎟️ Factory Pattern: Smart Coupon System

πŸ“ Overview

The Factory Method Pattern provides an interface for creating objects in a superclass but allows subclasses to alter the type of objects that will be created. It centralizes object creation logic, making the code more flexible and easier to extend without modifying the existing business logic.

Core Concepts

  • Creation Decoupling: The code that uses the object (the Client) is separated from the code that creates it (the Factory).
  • Polymorphic Creation: The factory can return any object that implements a specific interface (e.g., Coupon).
  • Null Object Fallback: Handling unrecognized inputs by returning a safe "do-nothing" object instead of None.

🏭 The Engineering Story & Problem

😑 The Villain (The Problem)

You're building an e-commerce checkout system. Marketing keeps inventing new coupon types: PercentageOff, FlatAmount, BuyOneGetOne, FirstPurchaseBonus. In the "Hardcoded Switch" version, your DiscountEngine is a mess:

class DiscountEngine:
    def apply_coupon(self, code_data, total):
        # 😑 Maintenance nightmare: logic mixed with creation
        if code_data['type'] == 'PERCENT':
            coupon = PercentageDiscount(code_data['value'])
        elif code_data['type'] == 'FLAT':
            coupon = FlatDiscount(code_data['value'])
        # Adding a new type requires editing this engine!
        return coupon.apply(total)
Every time a new coupon type is added, you have to modify the core DiscountEngine. This violates the Open/Closed Principle and makes the engine risky to change.

🦸 The Hero (The Solution)

The Factory Pattern introduces the "Specialized Creator."
We pull the "How to create a coupon" logic out into a CouponFactory.
The DiscountEngine now just says: coupon = CouponFactory.get_coupon(code_data). It doesn't care if the factory returns a PercentageDiscount or a BOGODiscount. It just knows the result is a Coupon that has an apply() method. You can now add 50 new coupon types by just updating the Factory. The DiscountEngine remains clean and never needs to change.

πŸ“œ Requirements & Constraints

  1. (Functional): Create different discount objects (Percentage, Flat) based on raw input data.
  2. (Technical): The main engine must depend only on the Coupon interface.
  3. (Technical): Return a NoDiscount object if the input is invalid (Null Object pattern).

πŸ—οΈ Structure & Blueprint

Class Diagram

classDiagram
    direction TB
    class Coupon {
        <<interface>>
        +apply(price) float
    }
    class PercentageCoupon {
        +apply(price)
    }
    class FlatCoupon {
        +apply(price)
    }
    class CouponFactory {
        +get_coupon(data) Coupon
    }

    Coupon <|.. PercentageCoupon
    Coupon <|.. FlatCoupon
    CouponFactory ..> Coupon : creates

Runtime Context (Sequence)

sequenceDiagram
    participant Engine as DiscountEngine
    participant Factory as CouponFactory
    participant Coupon as PercentCoupon

    Engine->>Factory: get_coupon({"type": "PERCENT", "val": 10})
    Factory-->>Engine: PercentCoupon Instance
    Engine->>Coupon: apply(100.0)
    Coupon-->>Engine: 90.0

πŸ’» Implementation & Code

🧠 SOLID Principles Applied

  • Single Responsibility: The Factory handles creation; the Engine handles discount calculation.
  • Open/Closed: Add a HolidayDiscount class without changing the DiscountEngine.

🐍 The Code

The Villain's Code (Without Pattern)
class DiscountEngine:
    def process(self, data, total):
        # 😑 Creation logic leaked into business logic
        if data['type'] == 'PERCENT':
            discount = total * (data['value'] / 100)
        elif data['type'] == 'FLAT':
            discount = data['value']
        return total - discount
The Hero's Code (With Pattern)
from abc import ABC, abstractmethod

class Coupon(ABC):
    def __init__(self, code: str, value: float) -> None:
        self.code : str = code
        self.value : float = value

    @abstractmethod
    def applyDiscount(self, price: float) -> float:
        pass


class PercentageCoupon(Coupon):
    def applyDiscount(self, price: float) -> float:
        print(f"Applying Percentage discount of {self.value}% on the price {price}")
        return price * (1 - self.value)


class FlatCoupon(Coupon):
    def applyDiscount(self, price: float) -> float:
        print(f"Applying Flat discount of {self.value}")
        if price > self.value:
            return price - self.value
        return 0.0


class NoDiscountCoupon(Coupon):
    def applyDiscount(self, price: float) -> float:
        print("No Discount Applied")
        return price


class CouponFactory:
    def getCoupon(self, code: str, type: str, value: float) -> Coupon:
        if type == "PERCENT":
            return PercentageCoupon(code=code, value=value)
        elif type == "FLAT":
            return FlatCoupon(code=code, value=value)

        return NoDiscountCoupon(code="INVALID", value=0.0)


class DiscountEnginer:
    def __init__(self) -> None:
        self.couponFactory : CouponFactory = CouponFactory()

    def generateCoupons(self, raw_coupon_data: list[dict]) -> list[Coupon]:
        coupons: list[Coupon] = []
        for data in raw_coupon_data:
            coupons.append(self.couponFactory.getCoupon(code=data.get("code", ""), type=data.get("type", ""), value=data.get("value", 0.0)))

        return coupons

# Simulate database records
raw_coupon_data = [
    {"code": "SAVE10", "type": "PERCENT", "value": 0.10},
    {"code": "CASH20", "type": "FLAT", "value": 20.0},
    {"code": "SAVER2", "type": "APPROX", "value": 4.5},
]

engine = DiscountEnginer()
coupons = engine.generateCoupons(raw_coupon_data=raw_coupon_data)

for coupon in coupons:
    print(coupon.applyDiscount(price=60))

βš–οΈ Trade-offs & Testing

Pros (Why it works) Cons (The Twist / Pitfalls)
Decoupling: Engine doesn't know about concrete classes. Boilerplate: Can be overkill for simple systems.
Extensibility: Easy to add new types. Complexity: Adds an extra layer of abstraction.
Safety: Centralized error handling for bad inputs. Testing: Might need to mock the factory in some tests.

πŸ§ͺ Testing Strategy

  1. Unit Test Factory: Pass in various JSON strings and verify the correct class is instantiated.
  2. Test Null Case: Verify that an unknown type returns NoDiscount.
  3. Mock Test Engine: Verify that the engine correctly uses the object returned by the factory without knowing its type.

🎀 Interview Toolkit

  • Interview Signal: mastery of creational decoupling and interface-driven design.
  • When to Use:
    • "Don't know the exact types of objects your code will work with..."
    • "Consolidate complex creation logic in one place..."
    • "Allow users of your library to extend its components..."
  • Scalability Probe: "How to handle 1,000 types?" (Answer: Use a Registryβ€”a dictionary mapping type strings to classesβ€”instead of a long if/elif inside the factory.)
  • Design Alternatives:
    • Abstract Factory: If you're creating families of related objects.
    • Builder: If the object construction involves many steps/parts.
  • Abstract Factory β€” Factory Method is for one object; Abstract Factory is for a family.
  • Null Object β€” Factories often return a Null Object as a safe default.
  • Prototype β€” A factory can use a "Registry of Prototypes" to clone objects instead of instantiating them.