Every checkout flow eventually needs more than one payment method. Credit card today, PayPal next quarter, a crypto wallet next based on convenience. The easy way to do it is a payment_method string and a growing if/elif inside process_order(). It works for two methods. By the fourth, the function is 200 lines long, half the branches share duplicated fee-calculation logic, and nobody wants to touch it before a release.
The fix for this mess is Strategy pattern, and it’s one of the few design patterns that pays for itself almost immediately in Python — because the language’s structural typing means you don’t need inheritance hierarchies or boilerplate to get the benefit. This post builds a payment processing system with it, from the interface up to testing and the point where the pattern stops being worth it.
The problem with branching on type
Here’s the naive version, and it’s the version most codebases actually ship first:
def process_payment(method: str, amount: float, details: dict) -> dict:
if method == "credit_card":
# validate card, call card processor, apply 2.9% + $0.30 fee
fee = amount * 0.029 + 0.30
result = card_processor.charge(details["card_number"], amount + fee)
return {"status": "success", "fee": fee, "transaction_id": result.id}
elif method == "paypal":
# different fee structure, different API shape
fee = amount * 0.034
result = paypal_client.create_payment(details["email"], amount + fee)
return {"status": "success", "fee": fee, "transaction_id": result.payment_id}
elif method == "crypto":
fee = amount * 0.01
result = crypto_gateway.send(details["wallet_address"], amount + fee)
return {"status": "success", "fee": fee, "transaction_id": result.tx_hash}
else:
raise ValueError(f"Unsupported payment method: {method}")
Three problems compound as this grows. Adding a fourth provider means editing a function that already works for three others — a change with blast radius it shouldn’t have. Testing one payment method means importing and mocking every dependency the other branches need, even though they’re irrelevant to the test. And the fee logic, the API call shape, and the response mapping are all tangled together per branch, so a bug in PayPal’s response mapping can’t be fixed without re-reading the credit card logic to make sure you’re editing the right block.
Define the contract first
The Strategy pattern’s actual job is drawing a boundary: one interface, many interchangeable implementations behind it. In Python, a Protocol is the right tool — it gives you structural typing (anything with a matching method satisfies the interface) without forcing every strategy to inherit from a shared base class:
from typing import Protocol
class PaymentResult:
def __init__(self, status: str, fee: float, transaction_id: str):
self.status = status
self.fee = fee
self.transaction_id = transaction_id
class PaymentStrategy(Protocol):
def pay(self, amount: float, details: dict) -> PaymentResult: ...
That’s the entire contract. Anything that implements pay(amount, details) -> PaymentResult is a valid strategy, whether it lives in this module, a separate package, or a third-party plugin someone adds later.
One class per payment method
Each strategy owns its own fee calculation, its own API call, and its own response mapping — completely isolated from the others:
class CreditCardStrategy:
def __init__(self, processor):
self.processor = processor
def pay(self, amount: float, details: dict) -> PaymentResult:
fee = round(amount * 0.029 + 0.30, 2)
result = self.processor.charge(details["card_number"], amount + fee)
return PaymentResult("success", fee, result.id)
class PayPalStrategy:
def __init__(self, client):
self.client = client
def pay(self, amount: float, details: dict) -> PaymentResult:
fee = round(amount * 0.034, 2)
result = self.client.create_payment(details["email"], amount + fee)
return PaymentResult("success", fee, result.payment_id)
class CryptoStrategy:
def __init__(self, gateway):
self.gateway = gateway
def pay(self, amount: float, details: dict) -> PaymentResult:
fee = round(amount * 0.01, 2)
result = self.gateway.send(details["wallet_address"], amount + fee)
return PaymentResult("success", fee, result.tx_hash)
Notice each strategy takes its external dependency (processor, client, gateway) as a constructor argument instead of importing a global client. That’s what makes the next section — testing — trivial instead of requiring a mocking framework and three patched imports.
Selecting a strategy: a registry, not a conditional
Don’t replace the if/elif in process_payment with an if/elif that picks a strategy — that just moves the maintenance problem one level up. A dict keyed by method name gives you the same lookup with none of the branching:
class Checkout:
def __init__(self, strategies: dict[str, PaymentStrategy]):
self.strategies = strategies
def process_payment(self, method: str, amount: float, details: dict) -> PaymentResult:
try:
strategy = self.strategies[method]
except KeyError:
raise ValueError(
f"Unsupported payment method '{method}'. Available: {list(self.strategies)}"
) from None
return strategy.pay(amount, details)
checkout = Checkout({
"credit_card": CreditCardStrategy(card_processor),
"paypal": PayPalStrategy(paypal_client),
"crypto": CryptoStrategy(crypto_gateway),
})
result = checkout.process_payment("paypal", 49.99, {"email": "buyer@example.com"})
Adding a fourth provider — Apple Pay, say — is now a two-line change: write ApplePayStrategy, register it in the dict. Checkout.process_payment never changes, which means it never needs to be re-reviewed, re-tested, or re-deployed just because a new payment method showed up.
Composing cross-cutting concerns without touching every strategy
Every strategy above will eventually need the same things: logging, retry on transient failure, maybe a fraud-check hook. Don’t add that logic inside each pay() method — that’s the same duplication problem as the original if/elif, just spread across classes instead of branches. Wrap it once, around any strategy:
import logging
import time
logger = logging.getLogger("payments")
class LoggingStrategy:
def __init__(self, inner: PaymentStrategy, name: str):
self.inner = inner
self.name = name
def pay(self, amount: float, details: dict) -> PaymentResult:
start = time.monotonic()
try:
result = self.inner.pay(amount, details)
logger.info("%s payment succeeded: %.2fs, fee=%.2f", self.name, time.monotonic() - start, result.fee)
return result
except Exception:
logger.exception("%s payment failed after %.2fs", self.name, time.monotonic() - start)
raise
checkout = Checkout({
"credit_card": LoggingStrategy(CreditCardStrategy(card_processor), "credit_card"),
"paypal": LoggingStrategy(PayPalStrategy(paypal_client), "paypal"),
"crypto": LoggingStrategy(CryptoStrategy(crypto_gateway), "crypto"),
})
This is the Strategy pattern composing cleanly with Decorator: LoggingStrategy satisfies the same PaymentStrategy protocol as the classes it wraps, so Checkout doesn’t know or care that logging is happening at all. Add a retry wrapper the same way, and you can stack them — retry around logging around the actual strategy — without any single class knowing about the others.
Testing strategies in isolation
Because each strategy’s only dependency is the one object passed into its constructor, testing doesn’t require touching Checkout, mocking a global client, or standing up the other two payment methods:
class FakeProcessor:
def charge(self, card_number, total):
return type("Result", (), {"id": "txn_123"})()
def test_credit_card_fee_calculation():
strategy = CreditCardStrategy(FakeProcessor())
result = strategy.pay(100.0, {"card_number": "4111..."})
assert result.fee == 3.20 # 100 * 0.029 + 0.30
assert result.transaction_id == "txn_123"
A bug in PayPal’s fee logic can never surface as a failing credit card test, and vice versa — the test surface for each strategy is exactly as large as that strategy’s own logic, nothing more.
When Strategy is the wrong call
If you have exactly one payment provider and no near-term plan for a second, this is over-engineering. A single function is simpler, has nothing to abstract, and costs nothing to refactor into a Protocol and registry later — the interface above is trivial to retrofit once provider #2 actually lands. Reach for Strategy when you have, or can clearly see coming, at least two interchangeable implementations of the same operation. Introducing the pattern speculatively, for a hypothetical future provider that may never arrive, is indirection with no current payoff.
The takeaway
The Strategy pattern’s value isn’t the Protocol or the registry dict on their own — it’s what they buy you structurally: adding a payment method becomes “write one new class and register it” instead of “find the right spot in a 200-line conditional and hope you don’t break the branch above it.” That difference is small on day one and compounds every time a new provider, a new fee structure, or a new cross-cutting concern shows up — which, in a checkout flow, is basically guaranteed to keep happening.
Deepak Balasubramaniam — Technical Manager, 14 yrs full-stack (Django/React/AWS), Writes on system design & AI-assisted dev

