CodingBowl

10 Python Patterns, Part 2: Strategy Pattern in Django

Published on 22 Jan 2026 Tech Software Architecture
image
Photo by DL314 Lin on Unsplash

In Part 1, we established Dependency Injection as the foundation for scalable Django architecture. Once dependencies are injectable, the next problem appears quickly: conditional logic.

If your Django views or services are filled with if/elif branches deciding how something should behave, you are ready for the Strategy Pattern.

What Is the Strategy Pattern?

The Strategy Pattern encapsulates interchangeable behavior behind a common interface. Instead of branching logic, you select a strategy object and delegate the behavior to it.

In Django projects, this is especially useful for:

  • Payment methods
  • Notification channels
  • Pricing rules
  • Permission checks
  • Business workflows

The Common Django Anti-Pattern

Large conditional blocks are the usual starting point.


class OrderService:
    def process_payment(self, order, method):
        if method == "card":
            return self._pay_by_card(order)
        elif method == "paypal":
            return self._pay_by_paypal(order)
        elif method == "wallet":
            return self._pay_by_wallet(order)
        else:
            raise ValueError("Unsupported payment method")

This code works, but every new payment method forces you to modify existing logic. That violates the Open/Closed Principle and increases risk over time.

What the Strategy Pattern Fixes

  • No growing if/else trees
  • No rewriting existing services
  • Each behavior is isolated and testable

Step 1: Define the Strategy Interface

All strategies follow the same contract.


class PaymentStrategy:
    def pay(self, order):
        raise NotImplementedError

Step 2: Implement Concrete Strategies


class CardPaymentStrategy(PaymentStrategy):
    def pay(self, order):
        return f"Paid order {order.id} with card"


class PayPalPaymentStrategy(PaymentStrategy):
    def pay(self, order):
        return f"Paid order {order.id} with PayPal"


class WalletPaymentStrategy(PaymentStrategy):
    def pay(self, order):
        return f"Paid order {order.id} with wallet"

Each strategy focuses on a single responsibility. No shared branching logic.

Step 3: Create the Service That Uses a Strategy

The service does not know which strategy it receives. It only knows the interface.


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

    def process(self, order):
        return self.strategy.pay(order)

Step 4: Select the Strategy in the View

Strategy selection belongs at the application boundary. In Django, that is usually the view.


from django.views import View
from django.http import JsonResponse, Http404
from .models import Order
from .services import PaymentService
from .strategies import (
    CardPaymentStrategy,
    PayPalPaymentStrategy,
    WalletPaymentStrategy,
)

class ProcessPaymentView(View):

    STRATEGIES = {
        "card": CardPaymentStrategy,
        "paypal": PayPalPaymentStrategy,
        "wallet": WalletPaymentStrategy,
    }

    def post(self, request, pk):
        method = request.POST.get("method")

        if method not in self.STRATEGIES:
            raise Http404("Invalid payment method")

        order = Order.objects.get(pk=pk)
        strategy = self.STRATEGIES[method]()
        service = PaymentService(strategy)

        result = service.process(order)
        return JsonResponse({"result": result})

Why the View Chooses the Strategy

The view is the integration layer. It translates HTTP input into application behavior.

Business rules stay inside strategies. The service coordinates behavior. The view only wires things together.

Step 5: Testing with a Fake Strategy

Strategies are trivial to test because they are isolated.


class FakePaymentStrategy:
    def __init__(self):
        self.called = False

    def pay(self, order):
        self.called = True
        return "ok"

from django.test import TestCase

class PaymentServiceTest(TestCase):

    def test_payment_service_uses_strategy(self):
        strategy = FakePaymentStrategy()
        service = PaymentService(strategy)

        result = service.process(order=type("O", (), {"id": 1})())

        self.assertTrue(strategy.called)
        self.assertEqual(result, "ok")

Another Common Use Case: Notification Strategies

Notification logic often grows quietly and becomes difficult to change. Email today, SMS tomorrow, webhooks next month. Conditionals do not scale here.

The Anti-Pattern


class NotificationService:
    def notify(self, user, channel, message):
        if channel == "email":
            send_email(user.email, message)
        elif channel == "sms":
            send_sms(user.phone, message)
        elif channel == "webhook":
            send_webhook(user.webhook_url, message)

Strategy Interface


class NotificationStrategy:
    def send(self, user, message):
        raise NotImplementedError

Concrete Strategies


class EmailNotification(NotificationStrategy):
    def send(self, user, message):
        return f"Email sent to {user.email}"


class SMSNotification(NotificationStrategy):
    def send(self, user, message):
        return f"SMS sent to {user.phone}"


class WebhookNotification(NotificationStrategy):
    def send(self, user, message):
        return f"Webhook sent to {user.webhook_url}"

Service and View Wiring


class NotificationService:
    def __init__(self, strategy):
        self.strategy = strategy

    def notify(self, user, message):
        return self.strategy.send(user, message)

class SendNotificationView(View):

    STRATEGIES = {
        "email": EmailNotification,
        "sms": SMSNotification,
        "webhook": WebhookNotification,
    }

    def post(self, request, pk):
        channel = request.POST.get("channel")
        if channel not in self.STRATEGIES:
            raise Http404()

        user = User.objects.get(pk=pk)
        strategy = self.STRATEGIES[channel]()
        service = NotificationService(strategy)

        return JsonResponse({
            "result": service.notify(user, request.POST.get("message"))
        })

Another Example: Pricing and Discount Rules

Pricing logic changes frequently and differs by context. Embedding these rules inside services leads to fragile code.

The Anti-Pattern


class PricingService:
    def calculate_price(self, order, discount_type):
        if discount_type == "seasonal":
            return order.total * 0.9
        elif discount_type == "loyalty":
            return order.total * 0.85
        elif discount_type == "staff":
            return order.total * 0.7
        return order.total

Strategy Interface


class PricingStrategy:
    def calculate(self, order):
        raise NotImplementedError

Concrete Strategies


class NoDiscountStrategy(PricingStrategy):
    def calculate(self, order):
        return order.total


class SeasonalDiscountStrategy(PricingStrategy):
    def calculate(self, order):
        return order.total * 0.9


class LoyaltyDiscountStrategy(PricingStrategy):
    def calculate(self, order):
        return order.total * 0.85


class StaffDiscountStrategy(PricingStrategy):
    def calculate(self, order):
        return order.total * 0.7

Service and View Wiring


class PricingService:
    def __init__(self, strategy):
        self.strategy = strategy

    def get_final_price(self, order):
        return self.strategy.calculate(order)

class OrderPriceView(View):

    STRATEGIES = {
        "none": NoDiscountStrategy,
        "seasonal": SeasonalDiscountStrategy,
        "loyalty": LoyaltyDiscountStrategy,
        "staff": StaffDiscountStrategy,
    }

    def get(self, request, pk):
        discount = request.GET.get("discount", "none")
        if discount not in self.STRATEGIES:
            raise Http404()

        order = Order.objects.get(pk=pk)
        strategy = self.STRATEGIES[discount]()
        service = PricingService(strategy)

        return JsonResponse({
            "order_id": order.id,
            "final_price": service.get_final_price(order),
        })

Business Workflow Example: Order Fulfillment Strategies

Business workflows often change based on context. An order may follow a different fulfillment process depending on whether it is digital, physical, or subscription-based. Encoding these flows with conditionals quickly becomes unmanageable.

The Anti-Pattern


class OrderWorkflowService:
    def process(self, order):
        if order.type == "digital":
            self._deliver_download(order)
        elif order.type == "physical":
            self._ship_package(order)
        elif order.type == "subscription":
            self._activate_subscription(order)

Each new order type forces changes to existing workflow logic.

Step 1: Define the Workflow Strategy Interface


class OrderWorkflowStrategy:
    def run(self, order):
        raise NotImplementedError

Step 2: Implement Workflow Strategies


class DigitalOrderWorkflow(OrderWorkflowStrategy):
    def run(self, order):
        order.status = "delivered"
        return "Download link sent"


class PhysicalOrderWorkflow(OrderWorkflowStrategy):
    def run(self, order):
        order.status = "shipped"
        return "Order shipped"


class SubscriptionOrderWorkflow(OrderWorkflowStrategy):
    def run(self, order):
        order.status = "active"
        return "Subscription activated"

Each strategy owns an entire workflow, not just a single action.

Step 3: Workflow Service


class OrderWorkflowService:
    def __init__(self, strategy):
        self.strategy = strategy

    def process(self, order):
        return self.strategy.run(order)

Step 4: Select Workflow Strategy in a Class-Based View


from django.views import View
from django.http import JsonResponse, Http404
from .models import Order
from .services import OrderWorkflowService
from .workflows import (
    DigitalOrderWorkflow,
    PhysicalOrderWorkflow,
    SubscriptionOrderWorkflow,
)

class ProcessOrderView(View):

    WORKFLOWS = {
        "digital": DigitalOrderWorkflow,
        "physical": PhysicalOrderWorkflow,
        "subscription": SubscriptionOrderWorkflow,
    }

    def post(self, request, pk):
        order = Order.objects.get(pk=pk)

        if order.type not in self.WORKFLOWS:
            raise Http404("Unsupported order type")

        strategy = self.WORKFLOWS[order.type]()
        service = OrderWorkflowService(strategy)

        result = service.process(order)
        order.save()

        return JsonResponse({
            "order_id": order.id,
            "status": order.status,
            "result": result,
        })

Why Strategy Fits Business Workflows

  • Each workflow is isolated and readable
  • New workflows do not affect existing ones
  • Complex sequences live outside views
  • Testing workflows becomes straightforward

Architectural Insight

When a “process” differs by type, status, or lifecycle, it is a workflow — not a conditional. The Strategy Pattern keeps workflows explicit, testable, and adaptable.

What the Strategy Pattern Gave Us

  • No conditional logic in services
  • New behaviors added without modifying existing code
  • Clear separation of responsibilities
  • Simple, fast unit tests

The Strategy Pattern pairs naturally with Dependency Injection. Once dependencies are injectable, behaviors become replaceable.

Whenever behavior changes based on type, mode, or context, the Strategy Pattern removes branching logic and keeps your system open to change. Combined with Dependency Injection, it enables clean, scalable Django architecture.

In the next post, we’ll look at the Builder Pattern and how it cleans up complex object construction in Django.

Meow! AI Assistance Note

This post was created with the assistance of Gemini AI and ChatGPT.
It is shared for informational purposes only and is not intended to mislead, cause harm, or misrepresent facts. While efforts have been made to ensure accuracy, readers are encouraged to verify information independently. Portions of the content may not be entirely original.

image
Photo by Yibo Wei on Unsplash