Strategy — Find the Bug¶
Each section presents a Strategy that looks fine but is broken. Find the bug yourself, then check.
Table of Contents¶
- Bug 1: Loop closure captures by reference
- Bug 2: Context picks strategy with
instanceof - Bug 3: Mutable shared strategy state
- Bug 4: Strategy reaches into Context internals
- Bug 5: Hot-swap without volatile
- Bug 6: Strategy throws checked exception not in interface
- Bug 7: Default strategy is null
- Bug 8: Hot-swap mid-call inconsistency
- Bug 9: Comparator violates transitivity
- Bug 10: Strategy mutates the input it receives
- Bug 11: Registry leaks classloaders
- Bug 12: Strategy holds resources without close
- Practice Tips
Bug 1: Loop closure captures by reference¶
discounts = []
for rate in [0.1, 0.2, 0.3]:
discounts.append(lambda x: x * (1 - rate)) # built three "strategies"
print(discounts[0](100), discounts[1](100), discounts[2](100))
# Expected: 90, 80, 70
# Actual: 70, 70, 70
Reveal
**Bug:** Late-binding closure. All three lambdas capture the *same* `rate` variable. By the time any of them runs, `rate == 0.3`. **Fix:** bind via default argument. Or use `functools.partial`: **Lesson:** Closures capture variables, not values. In strategy factories built in loops, this trap is common in Python and (with `var`) JavaScript. Languages with `let` / `final` (JS, Java) sidestep it.Bug 2: Context picks strategy with instanceof¶
public final class Checkout {
public void pay(Object payment) {
if (payment instanceof CreditCard) {
new CardStrategy().pay((CreditCard) payment);
} else if (payment instanceof PayPalAccount) {
new PayPalStrategy().pay((PayPalAccount) payment);
} else {
throw new IllegalArgumentException();
}
}
}
It works. Adding Crypto requires editing Checkout.
Reveal
**Bug:** OCP violation. `Checkout` knows about every strategy. Adding a new payment type modifies `Checkout` — exactly what Strategy is supposed to prevent. **Fix:** make payment / strategy polymorphic. Or wire by registry / DI so the call site doesn't branch. **Lesson:** `instanceof` chains are Strategy waiting to be born. Replace with polymorphic dispatch.Bug 3: Mutable shared strategy state¶
public final class CountingStrategy implements Strategy {
private int callCount = 0;
public Result run(Input i) {
callCount++;
return Result.of(callCount);
}
public int count() { return callCount; }
}
// In two threads:
strategy.run(input); // both threads share the same instance
Reveal
**Bug:** Race on `callCount`. Without synchronization, increments are lost; the count is wrong. **Fix:** make the field atomic, or scope per thread. Or — better — make the strategy stateless and move counting outside. **Lesson:** Strategies shared across threads must be either stateless or properly synchronized. Default to stateless.Bug 4: Strategy reaches into Context internals¶
public final class Cart {
List<Item> items;
PricingStrategy strategy;
public Money total() { return strategy.compute(this); }
}
public final class StudentPricing implements PricingStrategy {
public Money compute(Cart cart) {
int subtotal = cart.items.stream().mapToInt(Item::cents).sum(); // direct access
return new Money(subtotal).discount(0.15);
}
}
Reveal
**Bug:** Strategy accesses `cart.items` directly. Now the strategy is coupled to `Cart`'s field layout. `Cart` can't refactor without breaking strategies. **Fix:** pass *what the strategy needs*, not the Context.public interface PricingStrategy {
Money compute(int subtotalCents);
}
public final class StudentPricing implements PricingStrategy {
public Money compute(int subtotalCents) {
return new Money(subtotalCents).discount(0.15);
}
}
public final class Cart {
public Money total() { return strategy.compute(subtotalCents()); }
private int subtotalCents() { return items.stream().mapToInt(Item::cents).sum(); }
}
Bug 5: Hot-swap without volatile¶
public final class Context {
private Strategy strategy;
public void setStrategy(Strategy s) { this.strategy = s; }
public Result run(Input i) { return strategy.run(i); }
}
Tests pass. In production, sometimes a swap takes minutes to be observed by a worker thread.
Reveal
**Bug:** Without `volatile` (or some other memory barrier), a thread can cache the old reference indefinitely. Updates from another thread aren't guaranteed to be visible. **Fix:** mark the field `volatile`. Or use `AtomicReferenceBug 6: Strategy throws checked exception not in interface¶
public interface Strategy {
Result run(Input i); // doesn't declare exceptions
}
public final class FlakyStrategy implements Strategy {
public Result run(Input i) {
try {
return Result.of(http.get(i.url()));
} catch (IOException e) {
throw new RuntimeException(e); // wrapped to comply with interface
}
}
}
Callers see opaque RuntimeException. They can't catch IOException cleanly.
Reveal
**Bug:** The exception type is hidden behind `RuntimeException`. Callers can't react to the *kind* of failure. **Fix:** define a typed exception in the contract.public class StrategyException extends RuntimeException {
public StrategyException(String msg, Throwable cause) { super(msg, cause); }
}
public interface Strategy {
Result run(Input i) throws StrategyException;
}
Bug 7: Default strategy is null¶
public final class Cart {
private DiscountStrategy discount; // not initialized
public Money total() {
int subtotal = subtotalCents();
return discount.apply(new Money(subtotal)); // NPE
}
}
Reveal
**Bug:** No default. Calling `total()` before `setDiscount()` throws NPE. **Fix:** provide a default identity strategy. Or fail fast in the constructor: **Lesson:** A Strategy field that can be null is a hidden state machine. Pick: enforce non-null at construction, or always provide a working default.Bug 8: Hot-swap mid-call inconsistency¶
public final class Context {
private volatile Strategy strategy;
public void process(Input a, Input b) {
Result r1 = strategy.run(a); // strategy = X
// ... another thread swaps strategy to Y here ...
Result r2 = strategy.run(b); // strategy = Y
merge(r1, r2); // mismatched results
}
}
Reveal
**Bug:** The strategy can change between calls within one operation. Even with `volatile`, the second call sees a different strategy. **Fix:** snapshot once. Now both calls go through the same strategy. **Lesson:** `volatile` gives per-read visibility. For multi-step operations, snapshot the strategy reference once.Bug 9: Comparator violates transitivity¶
Comparator<Order> byPriorityThenPrice = (a, b) -> {
if (a.isUrgent()) return -1;
if (b.isUrgent()) return 1;
return Integer.compare(a.price(), b.price());
};
A list of urgent orders compares unstably; sometimes throws IllegalArgumentException: Comparison method violates its general contract.
Reveal
**Bug:** Two urgent orders both return `-1` against each other (`compare(a, b) = -1` AND `compare(b, a) = -1`). Violates antisymmetry, breaks the contract, JDK's TimSort detects this. **Fix:** handle the case where *both* are urgent. Or use the chaining helpers: **Lesson:** Strategy interfaces have contracts. A `Comparator` must be transitive, antisymmetric, and consistent with equals (where applicable). Violating these explodes at runtime.Bug 10: Strategy mutates the input it receives¶
Caller passes a list; later inspects the original — it's been sorted. Surprise.
Reveal
**Bug:** Strategy mutates the caller's input. Even though `sort()` is idiomatic, returning the sorted list while ALSO mutating the input violates least-surprise. **Fix:** decide once. Pure (recommended): Or document explicitly: **Lesson:** Mutability is fine — but be explicit. Strategies that quietly mutate inputs are the source of the worst kind of bugs.Bug 11: Registry leaks classloaders¶
public final class StrategyRegistry {
private static final Map<String, Strategy> map = new HashMap<>();
public static void register(String name, Strategy s) {
map.put(name, s);
}
}
In a containerized app server, redeploying the WAR leaves old Strategy classes in map. Their classloader can't be GC'd. Memory grows.
Reveal
**Bug:** Static map holds references to objects from the (now-undeployed) classloader. The classloader can't be unloaded. Each redeploy adds another set. **Fix:** instance-scoped registry, not static. The bean is GC'd when the context is destroyed; classloader is unloaded. **Lesson:** Static singletons in container environments leak. Prefer instance-scoped registries managed by the DI container.Bug 12: Strategy holds resources without close¶
public final class HttpStrategy implements Strategy {
private final HttpClient client = HttpClient.newHttpClient();
public Result run(Input i) {
return Result.of(client.send(i.request()));
}
}
Strategies are created per request: a new HttpClient each time. After hours, the app exhausts file descriptors.
Reveal
**Bug:** `HttpClient` owns a connection pool, internal threads, sockets. It's not a one-shot value. Creating many of them leaks resources. **Fix:** share a single client across calls; close explicitly when the strategy is replaced. Or make `HttpStrategy` itself a singleton: one client per app, swap a wrapping config but keep the inner client alive. **Lesson:** Strategy is conceptually lightweight, but if it owns resources, lifecycle management is required. Plan for swap, plan for shutdown.Practice Tips¶
- Look for
instanceofchains. They're Strategy waiting to happen — and often hide the same bug as Bug 2. - Audit shared mutable state. Stateless Strategy is the safe default; stateful must be deliberate.
- Check Comparator implementations. Bug 9 (transitivity) is one of the most common production bugs — JDK throws at runtime.
- Trace exception types across the boundary. Bug 6 hides failure modes. Document and type them.
- Inspect lifecycle. A strategy that owns resources but lacks close → memory / fd leak.
- Test concurrent swaps. Even with
volatile, mid-call swaps surprise you. Snapshot once.