DEV Community

Manoj Khatri
Manoj Khatri

Posted on

The Everyday Backend Engineer: Step 9 — The Strategy Pattern

Welcome back to The Everyday Backend Engineer: Practical Design Patterns. So far, we have cleaned up our system structures. Today, we step firmly into Category 3: Behavioral Patterns by exploring a pattern designed to make your runtime logic incredibly fluid and interchangeable: The Strategy Pattern.

Let’s break down how this pattern eliminates messy conditional cascades using a universally relatable engineering scenario.


🔴 The Problem: Dynamic Business Rule Cascades

Imagine you are building the payment or pricing execution service for a global ride-sharing app like Uber. When a rider selects a destination, the application must compute or process the payment based on their chosen method:

  1. Rider A selects PayPal.
  2. Rider B selects Stripe (Credit Card).
  3. Rider C selects Apple Pay.

If you rely on your first instinct to handle this operational variance, you will likely bundle all the conditions directly inside a single core execution block:

// ❌ Bad Practice: Core logic file is choked with varying vendor logic
class PaymentProcessor {
    async processPayment(amount, type) {
        // The codebase smell: Behavioral conditional mapping
        if (type === 'stripe') {
            console.log(`Processing $${amount} via Stripe API Gateway...`);
            // Complex Stripe SDK integration logic goes here
        } else if (type === 'paypal') {
            console.log(`Processing $${amount} via PayPal OAuth flow...`);
            // Complex PayPal SDK integration logic goes here
        } else if (type === 'applepay') {
            console.log(`Processing $${amount} via Apple Pay secure token...`);
            // Complex Apple Pay merchant logic goes here
        } else {
            throw new Error("Unsupported payment gateway provider.");
        }
    }
}

module.exports = PaymentProcessor;

Enter fullscreen mode Exit fullscreen mode

Why does this fail global engineering standards?

What happens when your product manager says, "We are expanding to Europe, we need to integrate **Adyen* immediately"*?

You are forced to rip open this core, battle-tested PaymentProcessor file, add another nested else if branch, and risk introducing bugs into your existing Stripe or PayPal flows. This directly violates the Open-Closed Principle (software entities should be open for extension, but closed for modification).


🟢 The Solution: Encapsulated Strategies

The Strategy Pattern isolates your fluctuating execution behaviors out of the main driver file and encapsulates each one inside its own standalone "Strategy" class.

You then define a core driver container (called the Context). At runtime, based on the user's explicit action, you simply pass the required strategy instance into the Context. The Context executes the logic uniformly using a standardized interface method.

🛒 The Real-World Analogy: "The Airport Check-out Terminal"

Think of paying at an International Airport Duty-Free Counter. The automated self-checkout register totals your items and prompts for payment. The terminal doesn't change its internal operating hardware whether you choose to swipe a physical Visa card, tap a mobile device via Apple Pay, or scan a digital wallet. The terminal acts as the stable Context—it simply accepts your chosen external transaction tool (The Strategy) and delegates the execution to it uniformly.


💻 The Clean Node.js Implementation

Let’s refactor our payment pipeline cleanly by splitting our behaviors into interchangeable strategy classes:

// strategies/paymentStrategies.js

// Strategy 1: Isolated Stripe Processing
class StripeStrategy {
    async pay(amount) {
        console.log(`💳 Safely routed $${amount} through Stripe Payment Gateway.`);
        return { success: true, provider: 'Stripe' };
    }
}

// Strategy 2: Isolated PayPal Processing
class PayPalStrategy {
    async pay(amount) {
        console.log(`📲 Safely authenticated and routed $${amount} through PayPal.`);
        return { success: true, provider: 'PayPal' };
    }
}

// Strategy 3: Isolated Apple Pay Processing
class ApplePayStrategy {
    async pay(amount) {
        console.log(`🍏 Secured token verified. Routed $${amount} via Apple Pay.`);
        return { success: true, provider: 'ApplePay' };
    }
}

module.exports = { StripeStrategy, PayPalStrategy, ApplePayStrategy };

Enter fullscreen mode Exit fullscreen mode

Now, let's create our clean PaymentContext manager which remains completely oblivious to vendor-specific implementation details:

// managers/paymentContext.js
class PaymentContext {
    constructor() {
        this.strategy = null; // No initial strategy bound by default
    }

    // 🎯 Dynamically assign any strategy behavior on the fly at runtime
    setStrategy(paymentStrategyInstance) {
        this.strategy = paymentStrategyInstance;
    }

    async executeTransaction(amount) {
        if (!this.strategy) {
            throw new Error("Operational error: Please assign a target payment strategy first.");
        }

        // The Context calls the interface uniformly. It doesn't care how the vendor works!
        return await this.strategy.pay(amount);
    }
}

module.exports = PaymentContext;

Enter fullscreen mode Exit fullscreen mode

🎯 Executing Behaviors Fluently at Runtime

See how effortlessly we can switch runtime execution tracks based on incoming client payloads:

// controllers/checkoutController.js
const PaymentContext = require('../managers/paymentContext');
const { StripeStrategy, PayPalStrategy, ApplePayStrategy } = require('../strategies/paymentStrategies');

async function handleCheckoutRoute(req, res) {
    const { amount, selectedMethod } = req.body; // e.g., { amount: 150, selectedMethod: 'paypal' }

    const billingSystem = new PaymentContext();

    // 🚀 Pair the context engine with the user's specific runtime choice
    if (selectedMethod === 'stripe') {
        billingSystem.setStrategy(new StripeStrategy());
    } else if (selectedMethod === 'paypal') {
        billingSystem.setStrategy(new PayPalStrategy());
    } else if (selectedMethod === 'applepay') {
        billingSystem.setStrategy(new ApplePayStrategy());
    }

    // Process payment seamlessly
    const receipt = await billingSystem.executeTransaction(amount);
    return res.status(200).json(receipt);
}

Enter fullscreen mode Exit fullscreen mode

If you need to introduce Adyen next week, your native PaymentContext and handleCheckoutRoute structures stay completely untouched. You simply drop a new AdyenStrategy class into your strategy folder and pass it into .setStrategy().


🧠 Code Smell Test: When should you use a Strategy?

Keep this indicator in mind during architecture design sessions:

  • The Smell: A method accepts a configuration string or an application flag, and routes the transaction down branching blocks containing heavy, structural, and unique algorithm paths.
  • The Fix: Decouple those unique operations into separate strategy modules and use a centralized controller context to run them uniformly.

That wraps up our exploration of the Strategy Pattern! Your runtime workflows are now elegantly modular and open for endless extension.

Up next in our architectural journey is Step 10: The Observer Pattern, where we will explore the engine behind Node.js's native Event-Driven Architecture (EventEmitter) to broadcast events seamlessly across your system layers.

Top comments (0)