DevHuzaifa's Portfolio
3 min readUpdated Aug 17, 2026

The Strategy Pattern in Django: Replacing Complex If/Else Logic

Learn how to implement the Strategy Pattern in Django to replace messy if/else payment logic, decouple third-party APIs, and write modular, testable services.

#Django#System Design

As Django applications scale, views and service layers frequently accumulate cascading conditional blocks. Payment processing is the classic culprit:

python
# The anti-pattern: Fragile, high-coupling branching
def process_order_payment(order, payment_method, payment_data):
    if payment_method == "stripe":
        customer = stripe.Customer.retrieve(order.user.stripe_id)
        charge = stripe.Charge.create(amount=order.total_cents, currency="usd", customer=customer.id)
        return charge.paid
    elif payment_method == "paypal":
        token = paypal_client.get_token()
        response = paypal_client.capture_order(order.id, token=token)
        return response.status == "COMPLETED"
    elif payment_method == "payfast":
        signature = generate_payfast_signature(payment_data)
        response = payfast_client.process(data=payment_data, signature=signature)
        return response.is_successful()
    else:
        raise ValueError(f"Unsupported payment method: {payment_method}")

Every new provider requires modifying this central function, increasing regression risk and violating the Open/Closed Principle (open for extension, closed for modification). The Strategy Pattern resolves this by extracting each algorithm into its own interchangeable class.

Standard Strategy Pattern UML Architecture. Source: PARITOSH DADHICH - Medium

Standard Strategy Pattern UML Architecture. Source: PARITOSH DADHICH - Medium

What is the Strategy Pattern?

The Strategy Pattern is a behavioral design pattern that lets you define a family of algorithms, encapsulate each one inside a separate class, and make their objects interchangeable.

The architecture consists of three core elements:

  1. Context (PaymentService): Maintains a reference to one of the concrete strategies and delegates the execution to it rather than implementing the behavior directly.
  2. Strategy Interface (PaymentStrategy): A common contract (using Python’s abc.ABC or typing.Protocol) declaring methods all concrete strategies must implement.
  3. Concrete Strategies (StripeStrategy, PayPalStrategy, PayFastStrategy): Individual implementations of the algorithm using provider-specific SDKs and API calls.

Identifying When You Need It

Refactoring to the Strategy Pattern makes sense when your codebase exhibits these symptoms:

  • Branching bloat: Functions with multiple if/elif/else branches executing different variations of the same business task.
  • Frequent vendor churn: You regularly add, modify, or deprecate third-party integrations (e.g., shipping carriers, SMS gateways, payment processors).
  • Test friction: Unit testing a single provider requires mocking every unrelated SDK and API client imported in the same file.
  • Isolated runtime changes: The algorithm needs to be selected dynamically at runtime based on user input, geographic location, or database configuration.

Implementing Strategies in Django

Create a dedicated services/payments/ directory within your Django app to hold the strategy definitions and context.

plaintext
billing/
├── services/
│   ├── payments/
│   │   ├── __init__.py
│   │   ├── base.py          # Strategy Interface & Data Transfer Objects
│   │   ├── strategies.py    # Concrete implementations
│   │   ├── registry.py      # Factory / Strategy selector
│   │   └── service.py       # Context class

1. Define the Interface and Return Types

Using dataclasses for input/output payloads ensures strong typing and contract consistency across all gateways.

python
# billing/services/payments/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from decimal import Decimal
from typing import Optional

@dataclass(frozen=True)
class PaymentResult:
    success: bool
    transaction_id: Optional[str]
    error_message: Optional[str] = None

class PaymentStrategy(ABC):
    """Abstract base class defining the payment strategy contract."""

    @abstractmethod
    def process_payment(self, amount: Decimal, currency: str, payload: dict) -> PaymentResult:
        """Execute the charge against the external provider."""
        pass

    @abstractmethod
    def refund_payment(self, transaction_id: str, amount: Decimal) -> PaymentResult:
        """Handle full or partial refunds."""
        pass

2. Build Concrete Strategies

Each strategy encapsulates the quirks, SDK initializations, and exceptions of a single provider.

python
# billing/services/payments/strategies.py
from decimal import Decimal
from .base import PaymentStrategy, PaymentResult

class StripePaymentStrategy(PaymentStrategy):
    def process_payment(self, amount: Decimal, currency: str, payload: dict) -> PaymentResult:
        # Provider-specific logic (e.g., stripe.PaymentIntent.create)
        token = payload.get("stripe_token")
        if not token:
            return PaymentResult(success=False, transaction_id=None, error_message="Missing token")
        
        # Simulated API call
        return PaymentResult(success=True, transaction_id=f"ch_stripe_{token[:8]}")

    def refund_payment(self, transaction_id: str, amount: Decimal) -> PaymentResult:
        return PaymentResult(success=True, transaction_id=f"re_stripe_{transaction_id}")


class PayPalPaymentStrategy(PaymentStrategy):
    def process_payment(self, amount: Decimal, currency: str, payload: dict) -> PaymentResult:
        order_id = payload.get("paypal_order_id")
        return PaymentResult(success=True, transaction_id=f"pp_tx_{order_id}")

    def refund_payment(self, transaction_id: str, amount: Decimal) -> PaymentResult:
        return PaymentResult(success=True, transaction_id=f"pp_ref_{transaction_id}")


class PayFastPaymentStrategy(PaymentStrategy):
    def process_payment(self, amount: Decimal, currency: str, payload: dict) -> PaymentResult:
        signature = payload.get("signature")
        return PaymentResult(success=True, transaction_id=f"pf_{signature[:6]}")

    def refund_payment(self, transaction_id: str, amount: Decimal) -> PaymentResult:
        return PaymentResult(success=True, transaction_id=f"pf_ref_{transaction_id}")

Selecting Strategies: The Registry Pattern

To avoid re-introducing if/else ladders when choosing a strategy, use a dictionary-based registry or factory function:

python
# billing/services/payments/registry.py
from typing import Type
from .base import PaymentStrategy
from .strategies import StripePaymentStrategy, PayPalPaymentStrategy, PayFastPaymentStrategy

STRATEGY_MAP: dict[str, Type[PaymentStrategy]] = {
    "stripe": StripePaymentStrategy,
    "paypal": PayPalPaymentStrategy,
    "payfast": PayFastPaymentStrategy,
}

def get_payment_strategy(provider_name: str) -> PaymentStrategy:
    """Instantiate and return the requested payment strategy."""
    strategy_cls = STRATEGY_MAP.get(provider_name.lower())
    if not strategy_cls:
        raise ValueError(f"Unknown payment provider: {provider_name}")
    return strategy_cls()

Dependency Injection and the Context

The Context (PaymentService) accepts a PaymentStrategy via its constructor. This decoupling allows the service to orchestrate business workflows (auditing, database records, notifications) while delegating execution.

python
# billing/services/payments/service.py
from decimal import Decimal
from .base import PaymentStrategy, PaymentResult

class PaymentService:
    def __init__(self, strategy: PaymentStrategy):
        self._strategy = strategy

    def checkout(self, amount: Decimal, currency: str, payload: dict) -> PaymentResult:
        # Pre-processing hook: Log intent, create pending DB transaction
        result = self._strategy.process_payment(amount=amount, currency=currency, payload=payload)
        
        # Post-processing hook: Update order state, trigger invoice email
        return result

Using in Django Views

python
# billing/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status

from .services.payments.registry import get_payment_strategy
from .services.payments.service import PaymentService

class CheckoutView(APIView):
    def post(self, request):
        provider = request.data.get("payment_method")
        payload = request.data.get("payment_data", {})
        amount = request.data.get("amount")

        try:
            strategy = get_payment_strategy(provider)
        except ValueError as exc:
            return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST)

        # Inject strategy into context
        payment_service = PaymentService(strategy=strategy)
        result = payment_service.checkout(amount=amount, currency="USD", payload=payload)

        if result.success:
            return Response({"status": "paid", "transaction_id": result.transaction_id})
        return Response({"error": result.error_message}, status=status.HTTP_402_PAYMENT_REQUIRED)

Testing Individual Strategies

By isolating provider logic, tests become clean, fast, and independent. You can test each provider without setting up global state:

python
# billing/tests/test_strategies.py
from decimal import Decimal
from django.test import SimpleTestCase
from billing.services.payments.strategies import StripePaymentStrategy, PayPalPaymentStrategy
from billing.services.payments.service import PaymentService

class TestPaymentStrategies(SimpleTestCase):
    def test_stripe_strategy_success(self):
        strategy = StripePaymentStrategy()
        result = strategy.process_payment(
            amount=Decimal("49.99"),
            currency="USD",
            payload={"stripe_token": "tok_12345678"}
        )
        self.assertTrue(result.success)
        self.assertEqual(result.transaction_id, "ch_stripe_tok_1234")

    def test_payment_service_with_mock_strategy(self):
        class MockStrategy:
            def process_payment(self, amount, currency, payload):
                from billing.services.payments.base import PaymentResult
                return PaymentResult(success=True, transaction_id="mock_tx")

        service = PaymentService(strategy=MockStrategy())
        result = service.checkout(Decimal("10.00"), "USD", {})
        self.assertTrue(result.success)
        self.assertEqual(result.transaction_id, "mock_tx")

When the Strategy Pattern is Overengineering

While powerful, the Strategy Pattern introduces class boilerplate and indirection. Avoid it when:

  • You only have 2 static variations: If you only support credit_card and cash_on_delivery and have no plans to expand, a simple conditional in a service function is easier to read and maintain.
  • The behaviors share almost all logic: If the divergence is just a configuration setting or a single endpoint URL rather than an algorithmic flow, parameterize a single class instead.
  • Premature abstraction: Adding an abstract base class "just in case" you integrate an alternative vendor in two years adds unnecessary complexity today.