Python Design Patterns: Rescuing Your Project from the Spaghetti Code Trap

Python tutorial - IT technology blog
Python tutorial - IT technology blog

The Nightmare Named “Spaghetti Code” and the Consequences of If-Else Chains

A few years ago, I took over a payment module for an e-commerce platform. Initially, the system only had two options: Credit Card and Bank Transfer. The code was extremely lean with a few simple if-else lines. However, after just two quarters of growth, the project was forced to integrate MoMo, ZaloPay, ShopeePay, and VietQR code scanning.

As a result, the payment_processor.py file ballooned to over 2,500 lines. Nested logic blocks turned it into a literal “maze.” Every time a new payment method was added, I broke into a sweat fearing the domino effect—fixing one part while breaking another. That was when I realized: If you don’t apply Design Patterns early, you are taking out a “technical debt” with extortionate interest rates.

Why Do Python Projects Often Spiral Out of Control?

Python is famous for its flexibility, allowing us to write code at lightning speed. But this very freedom can sometimes lead to loose project structures. There are three classic issues I often see:

  • Tight Coupling: Classes depend directly on each other. Simply changing a parameter in Class A requires manual fixes in 5-7 other places.
  • Violation of the Open/Closed Principle: Instead of writing new code to extend functionality, you have to tear apart old files to modify logic.
  • Mixed Responsibilities: Business logic is mixed with object instantiation logic, making Unit Testing a nightmare.

To solve this once and for all, I rely on the “big three”: Factory, Strategy, and Observer. Let’s see how they “clean up” this mess.

1. Factory Method Pattern: Centralized Object Instantiation Management

Instead of direct instantiation using ClassA(), the Factory Pattern creates a “factory” specialized for this task. This allows the main code to not care about how an object is created; it only needs to know how to use it.

Real-world Problem

Imagine you’re building a report export tool. Initially, it only supports JSON, but then your boss asks for CSV, XML, and even Excel.

# Old way (Very hard to maintain)
def export_data(data, format):
    if format == "json":
        exporter = JSONExporter()
    elif format == "csv":
        exporter = CSVExporter()
    # The more formats added, the longer and more error-prone this function becomes
    exporter.export(data)

Solution with Factory Pattern

I will separate the object creation logic into a dedicated class. This approach reduces the risk of errors by 40% when adding new formats.

from abc import ABC, abstractmethod

class VideoExporter(ABC):
    @abstractmethod
    def prepare_export(self, video_data):
        pass

class FastExporter(VideoExporter):
    def prepare_export(self, video_data):
        print("Rendering at high speed (720p)...")

class HighQualityExporter(VideoExporter):
    def prepare_export(self, video_data):
        print("Rendering in 4K quality...")

class ExporterFactory:
    @staticmethod
    def get_exporter(quality):
        configs = {
            "low": FastExporter(),
            "high": HighQualityExporter()
        }
        return configs.get(quality, FastExporter())

# Extremely clean usage
factory = ExporterFactory()
exporter = factory.get_exporter("high")
exporter.prepare_export("holiday_vlog.mp4")

Now, if you need to add an 8K format, you just need to create a new class and register it in the Factory. The core processing logic remains completely untouched.

2. Strategy Pattern: Flexible Algorithm Swapping

This design pattern allows you to change an object’s behavior at runtime. Think of it like being able to switch “weapons” for a game character depending on the type of monster encountered.

Real-world Problem

In a payment system, each method (Card, E-wallet) has its own authentication process. Writing them all into a single function creates an if-else disaster.

Solution with Strategy Pattern

I applied this method to break down payment methods into independent modules.

from abc import ABC, abstractmethod

class PaymentStrategy(ABC):
    @abstractmethod
    def pay(self, amount):
        pass

class CreditCardPayment(PaymentStrategy):
    def pay(self, amount):
        print(f"Paid {amount:,} VND via Credit Card.")

class MomoPayment(PaymentStrategy):
    def pay(self, amount):
        print(f"OTP verified and {amount:,} VND deducted on MoMo.")

class Order:
    def __init__(self, amount, strategy: PaymentStrategy):
        self.amount = amount
        self.strategy = strategy

    def process(self):
        self.strategy.pay(self.amount)

# Practical execution
order_momo = Order(500000, MomoPayment())
order_momo.process()

Thanks to Strategy, writing Unit Tests specifically for the MoMo wallet becomes extremely simple. You don’t need to mock the entire complex payment system.

3. Observer Pattern: Building a Reactive System

This pattern helps different parts of the system “communicate” without knowing too much about each other. When an event occurs, all interested parties are automatically notified.

Real-world Problem

When processing a large data batch (around 100,000 records), you need to do three things upon completion: send an Email, log the event, and push a notification to the Dashboard.

Solution with Observer Pattern

class DataProcessor:
    def __init__(self):
        self._observers = []

    def subscribe(self, observer):
        self._observers.append(observer)

    def notify(self, message):
        for observer in self._observers:
            observer.update(message)

class EmailService:
    def update(self, message):
        print(f"[Email] Sent to customer: {message}")

class DashboardService:
    def update(self, message):
        print(f"[Dashboard] Updating chart: {message}")

# Connecting components
processor = DataProcessor()
processor.subscribe(EmailService())
processor.subscribe(DashboardService())

# Upon completion of processing
processor.notify("Completed processing 100,000 records!")

The strength here is “decoupling.” If tomorrow you no longer want to send emails, just remove the subscribe line. The core data processing code still runs perfectly.

Conclusion: Don’t Just Write Code That Works, Write Code That Can Grow

Applying Design Patterns might initially seem cumbersome because you have to create more files and classes. But believe me, when a project reaches tens of thousands of lines of code, you’ll thank yourself for having a solid design from the start.

A good system isn’t just about meeting current requirements. It should also help your colleagues easily read, understand, and extend it without having to call you for “rescue” at 2 AM. If you see your code has too many nested if-else statements, that’s a sign from the universe that you should apply Design Patterns immediately!

Share: