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/elsetrees - 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.