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.
As Django applications scale, views and service layers frequently accumulate cascading conditional blocks. Payment processing is the classic culprit:
# 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
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:
- Context (
PaymentService): Maintains a reference to one of the concrete strategies and delegates the execution to it rather than implementing the behavior directly. - Strategy Interface (
PaymentStrategy): A common contract (using Python’sabc.ABCortyping.Protocol) declaring methods all concrete strategies must implement. - 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/elsebranches 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.
billing/
├── services/
│ ├── payments/
│ │ ├── __init__.py
│ │ ├── base.py # Strategy Interface & Data Transfer Objects
│ │ ├── strategies.py # Concrete implementations
│ │ ├── registry.py # Factory / Strategy selector
│ │ └── service.py # Context class1. Define the Interface and Return Types
Using dataclasses for input/output payloads ensures strong typing and contract consistency across all gateways.
# 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."""
pass2. Build Concrete Strategies
Each strategy encapsulates the quirks, SDK initializations, and exceptions of a single provider.
# 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:
# 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.
# 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 resultUsing in Django Views
# 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:
# 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_cardandcash_on_deliveryand 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.