Strategy
Part of the free Low-Level Design course on LogicWiz, in the chapter: Behavioural patterns I. The class-diagram exercises run in the app.
Week 7: The delivery fee that lived in one switch
"Ops changed the fee rules again, and the rain surcharge broke free delivery above 199 rupees. Who edits this switch next?"
Priya, reviewing DeliveryFeeCalculator
The switch that keeps growing
SwiftKart charges a delivery fee on every order. The rules started simple. Then ops added a rain surcharge, a late-night fee and free delivery for Gold members. Here is the code Arjun inherited:
public class DeliveryFeeCalculator {
public int fee(Order order, String mode) {
switch (mode) {
case "STANDARD":
return order.total() >= 199 ? 0 : 25;
case "RAIN":
return 25 + 20;
case "LATE_NIGHT":
return 25 + 15;
case "GOLD":
return 0;
default:
throw new IllegalArgumentException(mode);
}
}
}
Look at the RAIN case. Someone copied the base fee of 25 and forgot the "free above 199" rule. On a rainy evening, a customer with a 600 rupee cart paid 45 rupees for delivery. Support got the calls.
The bug is small. The shape is the real problem:
- Every new fee rule means editing this one class, and retesting every case in it.
- The rules cannot be tested alone. To test the rain rule you call the whole switch.
- Ops wants to try a festival fee in Bengaluru only. That means one more
case, and one more chance to break the others.
In Week 4 you met the open/closed principle: a class should be open to new behaviour without editing its old code. This switch fails it every week.
Put each rule in its own class
Ask what changes and what stays the same. Checkout always needs "the fee for this order". Only the way the fee is worked out changes. So give that way a name and an interface, and put each version in its own class.
public interface FeeStrategy {
int fee(Order order);
}
public class StandardFee implements FeeStrategy {
public int fee(Order order) {
return order.total() >= 199 ? 0 : 25;
}
}
public class RainFee implements FeeStrategy {
public int fee(Order order) {
return order.total() >= 199 ? 20 : 45;
}
}
public class GoldMemberFee implements FeeStrategy {
public int fee(Order order) {
return 0;
}
}
The class that uses a strategy is called the context. Here it is Checkout. It holds one FeeStrategy and asks it for the fee. It does not know which rule it has.
public class Checkout {
private FeeStrategy feeStrategy;
public Checkout(FeeStrategy feeStrategy) {
this.feeStrategy = feeStrategy;
}
public void setFeeStrategy(FeeStrategy feeStrategy) {
this.feeStrategy = feeStrategy;
}
public int total(Order order) {
return order.total() + feeStrategy.fee(order);
}
}
This shape has a name: the Strategy pattern. It is a family of interchangeable algorithms, each in its own class behind one interface. The context holds one through composition (a field), and you can swap it at runtime.
classDiagram
class FeeStrategy {
<<interface>>
+fee(order) int
}
Checkout --> FeeStrategy
FeeStrategy <|.. StandardFee
FeeStrategy <|.. RainFee
FeeStrategy <|.. GoldMemberFee
Now the rain rule lives in RainFee, and its test is three lines. A new festival fee is a new class. Checkout does not change.
Discounts have the same shape. A DiscountStrategy interface with FlatOff (50 rupees off) and PercentOff (10% off, at most 100 rupees) lets the coupon code pick the rule. You will meet this again in the parking lot problem, where the pricing per hour is a strategy.
💡 Tip:
FeeStrategyhas one method, so in Java 8 and later a strategy can be a lambda:FeeStrategy free = order -> 0;. Use a lambda for a one-line rule and a class when the rule needs fields or tests of its own.
Choosing the strategy at runtime
Someone still has to decide which fee applies. Arjun pointed this out in the review: "You moved the switch, you did not remove it. There is still an if somewhere."
He is right that a decision remains. The difference is what the decision does. Before, the switch both chose the rule and calculated the fee. Now one small class only chooses:
public class FeeStrategySelector {
private final FeeStrategy standard = new StandardFee();
private final FeeStrategy rain = new RainFee();
private final FeeStrategy gold = new GoldMemberFee();
public FeeStrategy pick(Customer customer, Weather weather) {
if (customer.isGold()) {
return gold;
}
if (weather.isRaining()) {
return rain;
}
return standard;
}
}
The choice now happens in one place, at the edge of the system. The calculations live in classes you can test one by one. When the order is placed, the service calls pick once and passes the result into Checkout. Some teams replace the if chain with a Map<String, FeeStrategy> keyed by a config value, so ops can change the rule without a code change.
Two details are worth saying in an interview:
- These strategies hold no state.
RainFeehas no fields, so one object can serve every order at the same time. The selector creates each one once and reuses it. - Testing gets simpler. To test
Checkout, pass a fake strategy that always returns 10. The test checks that the fee is added, without caring which rule produced it. To testRainFee, callfeewith two orders: one below 199 rupees and one above.
This is dependency inversion from Week 4 at work. Checkout depends on the FeeStrategy abstraction, and the concrete rule is passed in from outside.
When a switch is better
Strategy adds an interface and a class per rule. That cost is worth paying only when the rules really vary. Ask these questions before you reach for it:
flowchart TD
A["Does the behaviour itself differ, not only a number?"] -->|"No"| T["Use data: a config table or map of values"]
A -->|"Yes"| B["Will new versions be added, or chosen at runtime?"]
B -->|"No"| S["Keep one switch or if, in one place"]
B -->|"Yes"| P["Use a strategy interface"]
- If only the numbers change (25 rupees in Pune, 30 in Mumbai), that is data. Put it in a table, not in five classes.
- If there are two cases that have not changed in a year, a plain
ifis easier to read. - If the switch appears in many places on the same type, that points to polymorphism on the type itself (Week 2), not to a strategy.
⚠️ Warning: In a machine-coding round, do not wrap every
ifin a strategy. Use it where the interviewer is likely to say "now add another rule": pricing, fees, discounts, matching riders.
Practice: replace the switch
The starter below is Arjun's calculator with the switch inside. Pull the fee rule out into an interface with at least two implementations, and make the calculator hold the interface instead of the switch.
📌 Summary: Strategy puts each version of a rule in its own class behind one interface, and the context can swap it at runtime. If only numbers change, use data. For two fixed cases, keep the switch.
What Arjun learns next
The fee is sorted. Next, Priya points at OrderService: one status change sends an SMS, an email and a push message, and the service knows every one of them. That is the observer pattern.
Check your understanding
- The delivery fee is 25 rupees in Pune and 30 in Mumbai. The rule is otherwise the same. What is the best design?
- Arjun says: "We still have an if in FeeStrategySelector, so strategy changed nothing." What is the best reply?
- How does the context (Checkout) get its behaviour in the strategy pattern?
- A payment screen has two options, card and UPI, unchanged for two years, and checked in one place. Should you add a strategy?