- The problem: checkout payments with branching logic
- What goes wrong with this approach
- A ânaturalâ improvement: inheritance
- Why inheritance still breaks down in real systems
- The strategy pattern: isolate what changes
- Implement strategies
- Compose in the context (Checkout)
- Quick comparison table
- Extending the design
- Conclusion
Goal: Replace growing
if/elseblocks with Strategy + composition so adding a new payment method doesnât require editing existing logic.
The problem: checkout payments with branching logic
Youâre building a checkout system for an e-commerce site. Users can pay with Stripe or PayPal (and later: Card, Apple Pay, UPI, etc.).
The business logic changes by payment method: fees differ, currency conversion may apply, and flows can vary.
A first attempt often looks like this:
class Checkout:
def __init__(self, payment_method):
self.payment_method = payment_method
def pay(self, amount):
if self.payment_method == "stripe":
fee = 1.5 + 0.03 * amount
total = amount + fee
print(f"Stripe: Charging ${total:.2f} (including fees: ${fee:.2f})")
elif self.payment_method == "paypal":
usd_amount = amount * 0.012 # fake conversion rate
fee = 0.30
total = usd_amount + fee
print(f"PayPal: Charging ${total:.2f} USD (converted + ${fee:.2f} fee)")
else:
raise ValueError("Unsupported payment method")
if __name__ == "__main__":
Checkout("stripe").pay(100)
Checkout("paypal").pay(100)
What goes wrong with this approach
The problem isnât that the code âdoesnât workââitâs that it doesnât scale.
As soon as you add Apple Pay or UPI, the if/else block grows and becomes:
- Harder to read
- Harder to test
- Easier to break when you modify existing branches
Design smells
- High coupling:
Checkout.pay()knows too much about each gatewayâs rules. - Low cohesion: one method is orchestrating + implementing multiple behaviors (SRP violation).
- OCP violation:1 every new gateway means editing
Checkout.pay().
Every new branch = modify existing code = regression risk.
A ânaturalâ improvement: inheritance
A common refactor is to introduce a base type and one class per gateway:
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
@abstractmethod
def pay(self, amount: float) -> None:
...
class StripeProcessor(PaymentProcessor):
def pay(self, amount: float) -> None:
fee = 1.5 + 0.03 * amount
total = amount + fee
print(f"Stripe: Charging ${total:.2f} (including fees: ${fee:.2f})")
class PayPalProcessor(PaymentProcessor):
def pay(self, amount: float) -> None:
usd_amount = amount * 0.012
fee = 0.30
total = usd_amount + fee
print(f"PayPal: Charging ${total:.2f} USD (converted + ${fee:.2f} fee)")
class Checkout:
def __init__(self, payment_processor: PaymentProcessor):
self.payment_processor = payment_processor
def pay(self, amount: float) -> None:
self.payment_processor.pay(amount)
if __name__ == "__main__":
Checkout(StripeProcessor()).pay(100)
Checkout(PayPalProcessor()).pay(100)
This is better: Checkout depends on an interface, not concrete implementations.
Why inheritance still breaks down in real systems
Real payment systems donât stop at pay(amount).
They include variations like:
- Cards: network selection (Visa, Mastercard, AmEx), 3DS, SCA flows
- Fees: flat + %, tiered, region-based
- Currency conversion and taxes
- Cross-cutting logic: discounts, fraud checks, promotions
If you try to model all of that in one base class, it becomes a âfat interfaceâ:
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
@abstractmethod
def pay(self, amount: float) -> None:
...
@abstractmethod
def authorise(self, amount: float) -> None:
...
@abstractmethod
def calc_fee(self, amount: float) -> float:
...
@abstractmethod
def convert_currency(self, amount: float) -> float:
...
@abstractmethod
def apply_discount(self, amount: float) -> float:
...
@abstractmethod
def choose_network(self) -> str:
...
Now you get:
- ISP violation: subclasses are forced to implement methods they donât need.
- Subclass explosion: every combo becomes a class.
- Back to OCP risk: base class keeps changing.
A Stripe processor shouldnât have to âchoose a card networkââbut this design forces it.
ISP (Interface Segregation Principle):2 clients should not be forced to depend on methods they do not use.
The strategy pattern: isolate what changes

Step 1: Identify stable vs. changing parts
In our payment example:
- Stable (Context): the checkout flow (orchestration)
- Changing (Strategies): fee calculation, network selection, currency conversion, etc.
Step 2: Create small interfaces for the varying parts
Keep interfaces small and focused:
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
@abstractmethod
def pay(self, amount: float) -> None:
...
class NetworkSelector(ABC):
@abstractmethod
def choose_network(self) -> str:
...
Why âprogram to an interfaceâ matters3
Bad (calling concrete methods):
network_selector = VisaNetwork()
network_selector.choose_visa()
network_selector = AmexNetwork()
network_selector.choose_amex()
Good (calling a shared contract):
network_selector = VisaSelector()
network_selector.choose_network()
network_selector = AmexSelector()
network_selector.choose_network()
Implement strategies
Concrete strategies do one job well:
# Network selection strategies
class VisaSelector(NetworkSelector):
def choose_network(self) -> str:
return "Visa"
class AmexSelector(NetworkSelector):
def choose_network(self) -> str:
return "Amex"
class NoNetwork(NetworkSelector):
def choose_network(self) -> str:
return "N/A"
# Payment strategies
class StripeProcessor(PaymentProcessor):
def pay(self, amount: float) -> None:
fee = 1.5 + 0.03 * amount
print(f"Stripe: Charging ${amount + fee:.2f}")
class PayPalProcessor(PaymentProcessor):
def pay(self, amount: float) -> None:
usd_amount = amount * 0.012
fee = 0.30
print(f"PayPal: Charging ${usd_amount + fee:.2f} USD")
class CardProcessor(PaymentProcessor):
def __init__(self, network_selector: NetworkSelector):
self.network_selector = network_selector
def pay(self, amount: float) -> None:
net = self.network_selector.choose_network()
print(f"{net} card: charging ${amount:.2f}")
Compose in the context (Checkout)
Checkout is the Context. It delegates work to the currently selected strategy.
from typing import Optional
class Checkout:
def __init__(self, payment_strategy: Optional[PaymentProcessor] = None):
self._payment_strategy: Optional[PaymentProcessor] = None
if payment_strategy is not None:
self.payment_strategy = payment_strategy
@property
def payment_strategy(self) -> PaymentProcessor:
if self._payment_strategy is None:
raise ValueError("Payment strategy not set.")
return self._payment_strategy
@payment_strategy.setter
def payment_strategy(self, strategy: PaymentProcessor) -> None:
if not isinstance(strategy, PaymentProcessor):
raise TypeError("payment_strategy must implement PaymentProcessor.")
self._payment_strategy = strategy
def pay(self, amount: float) -> None:
self.payment_strategy.pay(amount)
Now strategy switching becomes a runtime decision:
checkout = Checkout()
checkout.payment_strategy = StripeProcessor()
checkout.pay(100)
checkout.payment_strategy = CardProcessor(VisaSelector())
checkout.pay(50)
Use constructor injection when the strategy is required and stable. Allow swapping only if your domain needs it (e.g., âuser changes payment method before checkout submitâ).
Quick comparison table
| Approach | Pros | Cons | Best for |
|---|---|---|---|
if/else dispatch | Simple to start | Grows messy, OCP breaks | Tiny scripts, 2â3 stable cases |
| Inheritance-only | Polymorphism, cleaner Checkout | Fat base class, subclass explosion | When variations are few & aligned |
| Strategy + composition | Extensible, testable, OCP-friendly | More classes/objects | Real systems with evolving behaviors |
Extending the design
Want discounts, taxes, fraud checks?
Add more small interfaces and compose them:
DiscountPolicy.apply(amount) -> amountFeePolicy.fee(amount) -> feeCurrencyConverter.convert(amount) -> amountFraudCheck.verify(order) -> bool
Each concern stays isolated, and you can mix-and-match.
Conclusion
When to use Strategy
Use it when you have multiple behaviors that:
- do the same job with different internal logic
- need to change independently over time
- should be selectable at runtime (user choice, region, config)
- are starting to create large
if/elseblocks
When not to use Strategy
Skip it if:
- you have one behavior and itâs unlikely to change
- the abstraction adds more complexity than value
Notes
-
Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (Prentice Hall, 2002). ↩
-
Interface Segregation Principle (ISP): keep interfaces small so implementers donât inherit methods they donât need. ↩
-
Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994)Â ↩