OCP in practice — strategy pattern over if-chains
Concept
OCP in practice almost always means replacing a chain of if/elseif or switch statements — what Martin Fowler calls a type-switch — with a Strategy pattern or polymorphic dispatch. The if-chain is the canonical OCP violation because every new case requires opening the central class and editing it.
The transformation is straightforward: extract each branch of the if-chain into its own class implementing a shared interface, then replace the branching logic with a lookup (array map, container binding, or factory method) that returns the right implementation at runtime.
This approach has three concrete benefits beyond OCP:
- Testability: each strategy class is tiny and tests in isolation without needing to set up the environment of the whole branching function
- Clarity: the name of a class (
FlatDiscountStrategy,PercentageDiscountStrategy) is more expressive than a comment in an if-branch - Extension without regression: adding a new strategy cannot accidentally break an existing strategy because they share no code
The Strategy pattern is not always the right tool. For two or three cases that are genuinely stable and will never be extended, a match expression is perfectly readable and not worth abstracting. The heuristic: if the branching logic has already changed twice to add new cases, or if you know more cases are coming, reach for a strategy.
Code Example
<?php
declare(strict_types=1);
// VIOLATION: if-chain that grows with every new discount type
final class OrderPricer
{
public function applyDiscount(float $subtotal, string $discountType, float $value): float
{
if ($discountType === 'percentage') {
return $subtotal * (1 - $value / 100);
} elseif ($discountType === 'flat') {
return max(0, $subtotal - $value);
} elseif ($discountType === 'buy_two_get_one') {
// complex logic...
return $subtotal * 0.667;
}
// Adding 'loyalty_points' means opening this class
return $subtotal;
}
}
// CORRECT: Strategy pattern — each discount is its own class
interface DiscountStrategy
{
public function apply(float $subtotal): float;
public function describe(): string;
}
final class PercentageDiscount implements DiscountStrategy
{
public function __construct(private readonly float $percent) {}
public function apply(float $subtotal): float
{
return $subtotal * (1 - $this->percent / 100);
}
public function describe(): string
{
return "{$this->percent}% off";
}
}
final class FlatDiscount implements DiscountStrategy
{
public function __construct(private readonly float $amount) {}
public function apply(float $subtotal): float
{
return max(0.0, $subtotal - $this->amount);
}
public function describe(): string
{
return "\${$this->amount} off";
}
}
final class BuyTwoGetOneDiscount implements DiscountStrategy
{
public function apply(float $subtotal): float
{
return $subtotal * (2 / 3);
}
public function describe(): string
{
return 'Buy 2, get 1 free';
}
}
// New discount type — zero changes to OrderPricer
final class LoyaltyPointsDiscount implements DiscountStrategy
{
public function __construct(private readonly int $points) {}
public function apply(float $subtotal): float
{
$dollarValue = $this->points / 100; // 100 points = $1
return max(0.0, $subtotal - $dollarValue);
}
public function describe(): string
{
return "{$this->points} loyalty points applied";
}
}
// OrderPricer is CLOSED — it never changes
final class OrderPricer
{
public function calculate(float $subtotal, ?DiscountStrategy $discount = null): float
{
if ($discount === null) {
return $subtotal;
}
return $discount->apply($subtotal);
}
}
// Factory pattern to resolve strategy from a database/config-driven type
final class DiscountStrategyFactory
{
public function fromCoupon(Coupon $coupon): DiscountStrategy
{
return match ($coupon->type) {
'percentage' => new PercentageDiscount($coupon->value),
'flat' => new FlatDiscount($coupon->value),
'buy_two_get_one' => new BuyTwoGetOneDiscount(),
'loyalty_points' => new LoyaltyPointsDiscount((int) $coupon->value),
default => throw new \InvalidArgumentException(
"Unknown discount type: {$coupon->type}"
),
};
}
}
// Usage
$pricer = new OrderPricer();
$factory = new DiscountStrategyFactory();
$coupon = Coupon::findOrFail($request->coupon_id);
$discount = $factory->fromCoupon($coupon);
$total = $pricer->calculate(subtotal: 120.00, discount: $discount);Interview Q&A
Q: The factory's match expression also has to change when a new discount type is added — doesn't that violate OCP?
Yes, and this is an important nuance. You can never completely eliminate the point of registration when adding a new variant. In a pure Strategy + factory setup, the factory becomes the single place where you do need to change code when adding a new type. The key insight is that you have moved the change from a business-logic class (OrderPricer) to a pure wiring/mapping class (the factory). Some architectures push even this further — using a tagged container or an auto-discovery mechanism so that registering a new class in the DI container is the only change needed. Laravel's service providers support this pattern. But in practice, accepting one factory method change is a reasonable trade-off.
Q: How do you handle the case where strategies need different constructor arguments?
This is why factories exist. The factory holds all the knowledge about how to construct each concrete strategy, including what configuration or dependencies each one needs. The caller (OrderPricer, ShippingCalculator) stays clean — it receives a DiscountStrategy interface and calls apply(). Only the factory knows that PercentageDiscount needs a percentage float, that FlatDiscount needs an amount, and that LoyaltyPointsDiscount needs points. You can also use the Laravel container with contextual bindings to resolve strategy classes with their dependencies automatically.
Q: When should you NOT use the strategy pattern for OCP?
When the if-chain is stable (two or three cases, never extended) and the logic is simple, a match expression is more readable with less indirection. Over-abstracting simple logic into strategy classes is itself a smell — you end up with five-line classes scattered across the codebase that require navigation to understand. The engineering judgment call: if the branching has changed twice in git history, if you know it will change again, or if each branch is complex enough to deserve its own test suite, extract to strategies. If it is a match with three cases in a utility method that has never changed, leave it.