PideYa's delivery fees are calculated three different ways: by distance in mature markets, flat rate where we're just entering, and free when a promotion applies. And the DispatchCenter left a similar debt: the courier-assignment criterion (the nearest? the least busy? by turns?) was hard-wired inside the mediator. Two symptoms of the same disease: alternative algorithms buried in conditionals inside their users. Strategy is the canonical answer — and probably the behavioral pattern you'll apply most often in your career: a family of algorithms, each in its own class, interchangeable behind a common interface, chosen from outside. If the entire course had to be summed up in one pattern, it would be this one: it is "program to interfaces" and OCP in their purest form.
Contents
- The problem in PideYa: algorithms buried in conditionals
- Intent and structure of the pattern
- Complete Java implementation: delivery fees
- Second case: courier assignment
- Selecting the strategy: config, context, and registry
- Strategy with lambdas and
Function - Strategy vs. State vs. Bridge: the table
- When to use it and when not to
- Relationship with other patterns
- Common mistakes
- Exercises and conclusion
The problem in PideYa: algorithms buried in conditionals
The delivery-fee calculation, current version, inside the CheckoutFacade:
private BigDecimal calculateDeliveryFee(Order order) {
if (freeDeliveryPromoActive && order.getTotal().compareTo(PROMO_MINIMUM) >= 0) {
return BigDecimal.ZERO;
} else if (market.equals("MX")) { // new market: flat rate
return FLAT_RATE_MX;
} else {
double km = geo.distance(order.getRestaurant(), order.getDeliveryAddress());
return BASE.add(PER_KM.multiply(BigDecimal.valueOf(km)));
}
}The pains, familiar by now but with their own nuance:
- Every new policy modifies the checkout (goodbye OCP): the "reduced rate at off-peak hours" the business is asking for means another
else ifin a class that isn't about delivery at all. - The policies can't be tested in isolation: testing the distance formula drags in the whole facade with its five dependencies.
- They can't be combined or configured: Spain wants distance, Mexico flat rate, and marketing wants to switch "free" on per campaign and per city — with
ifs, every combination is more branches. - The algorithm is not a concept in the code: "delivery policy" exists in business conversations, but no class carries that name. The classic sign of a missing abstraction.
The move, the module's move: reify. Make each way of calculating an object with a common interface, and have the checkout depend only on the interface.
Intent and structure of the pattern
Intent (GoF): define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from the clients that use it.
classDiagram
class DeliveryFeeCalculation {
<<interface>>
+calculate(order: Order) BigDecimal
}
class DistanceFee {
-geo: GeoService
+calculate(order) BigDecimal
}
class FlatRateFee {
-rate: BigDecimal
+calculate(order) BigDecimal
}
class FreePromoFee {
+calculate(order) BigDecimal
}
class CheckoutFacade {
-deliveryFee: DeliveryFeeCalculation
+confirmOrder(...)
}
DeliveryFeeCalculation <|.. DistanceFee
DeliveryFeeCalculation <|.. FlatRateFee
DeliveryFeeCalculation <|.. FreePromoFee
CheckoutFacade o-- DeliveryFeeCalculation : uses the injected one
| GoF role | In PideYa |
|---|---|
| Strategy (the algorithm's interface) | DeliveryFeeCalculation |
| ConcreteStrategy | DistanceFee, FlatRateFee, FreePromoFee |
| Context (uses a strategy through the interface) | CheckoutFacade |
Structurally it's the simplest pattern in the module — interface, implementations, field. Its depth lies in the surrounding decisions: what signature to give the interface, who chooses the strategy and when. That's where we're headed.
Complete Java implementation: delivery fees
The interface. The key decision is the signature: it must give all strategies what they need, without coupling them to what they don't. Order as the parameter is a good middle ground: the distance one will read addresses, the flat rate will ignore almost everything:
The strategies. Each with its own injected dependencies — the distance one needs the geo service; the others don't even know it exists:
public class DistanceFee implements DeliveryFeeCalculation {
private final GeoService geo;
private final BigDecimal base;
private final BigDecimal perKm;
public DistanceFee(GeoService geo, BigDecimal base, BigDecimal perKm) {
this.geo = geo;
this.base = base;
this.perKm = perKm;
}
@Override
public BigDecimal calculate(Order order) {
double km = geo.distance(order.getRestaurant().getAddress(),
order.getDeliveryAddress());
return base.add(perKm.multiply(BigDecimal.valueOf(km)))
.setScale(2, RoundingMode.HALF_UP);
}
}
public class FlatRateFee implements DeliveryFeeCalculation {
private final BigDecimal rate;
public FlatRateFee(BigDecimal rate) { this.rate = rate; }
@Override
public BigDecimal calculate(Order order) { return rate; }
}
public class FreePromoFee implements DeliveryFeeCalculation {
@Override
public BigDecimal calculate(Order order) { return BigDecimal.ZERO; }
}The context, which now knows only the interface:
public class CheckoutFacade {
private final DeliveryFeeCalculation deliveryFee; // injected (DIP)
// ... rest of the module-3 collaborators ...
public OrderConfirmation confirmOrder(Cart cart, ...) {
// validate (the 04-02 chain) → taxes → ...
BigDecimal fee = deliveryFee.calculate(orderInProgress);
// ... charge, build with Order.Builder, notify ...
}
}Each strategy is tested on its own in three lines; the "off-peak" one will be a new class touching nothing; and "delivery policy" is now a named concept in the code.
Second case: courier assignment
The Mediator debt, settled. Note that the natural signature here receives candidates and order — the strategy decides, the center executes:
public interface AssignmentStrategy {
Optional<Courier> choose(List<Courier> available, Order order);
}
public class NearestCourier implements AssignmentStrategy {
private final GeoService geo;
public NearestCourier(GeoService geo) { this.geo = geo; }
@Override
public Optional<Courier> choose(List<Courier> available, Order order) {
return available.stream()
.min(Comparator.comparingDouble(
c -> geo.distance(c.getPosition(), order.getRestaurant().getAddress())));
}
}
public class LeastBusyCourier implements AssignmentStrategy {
@Override
public Optional<Courier> choose(List<Courier> available, Order order) {
return available.stream()
.min(Comparator.comparingInt(Courier::getDeliveriesToday));
}
}
public class RoundRobinAssignment implements AssignmentStrategy {
private final AtomicInteger turn = new AtomicInteger(); // a strategy WITH state!
@Override
public Optional<Courier> choose(List<Courier> available, Order order) {
if (available.isEmpty()) return Optional.empty();
return Optional.of(available.get(turn.getAndIncrement() % available.size()));
}
}And the DispatchCenter replaces its findAvailable with strategy.choose(candidates, order) — the mediator orchestrates, the strategy decides, just as we promised in its lesson. The round-robin illustrates an important nuance: strategies can have state of their own (the turn); when they do, be careful about sharing the instance between contexts that shouldn't share that state.
Selecting the strategy: config, context, and registry
Who chooses the strategy? It is the question of the pattern, with three escalating answers:
1. By configuration (composition root). The most common: at startup, per market — remember the Abstract Factory: the per-market family can manufacture the delivery strategy too, alongside the gateway and the taxes:
DeliveryFeeCalculation fee = switch (PideYaConfig.getInstance().getMarket()) {
case "ES" -> new DistanceFee(geo, new BigDecimal("1.50"), new BigDecimal("0.80"));
case "MX" -> new FlatRateFee(new BigDecimal("35.00"));
default -> throw new IllegalStateException();
};(Yes, there is a switch — but exactly one, at the application's edge, not repeated at every point of use. That's the deal.)
2. By request context, at runtime. If an active promotion makes delivery free, someone decides per order. That "someone" can be a small selector — which is, itself, a higher-order strategy:
public class FeeSelector implements DeliveryFeeCalculation {
private final PromotionService promos;
private final DeliveryFeeCalculation normal;
@Override
public BigDecimal calculate(Order order) {
return promos.hasFreeDelivery(order)
? BigDecimal.ZERO
: normal.calculate(order);
}
}3. By registry, NotifierRegistry-style. The same move as the Factory Method with lambdas: a name → strategy map, and the choice becomes data (hot-configurable, extendable without touching the selector):
public class FeeStrategyRegistry {
private final Map<String, DeliveryFeeCalculation> strategies = new HashMap<>();
public void register(String name, DeliveryFeeCalculation strategy) {
strategies.put(name, strategy);
}
public DeliveryFeeCalculation get(String name) {
DeliveryFeeCalculation s = strategies.get(name);
if (s == null) throw new IllegalArgumentException("Unknown policy: " + name);
return s;
}
}
// startup:
registry.register("distance", new DistanceFee(geo, base, perKm));
registry.register("flat", new FlatRateFee(rate));
registry.register("free", new FreePromoFee());
// usage: the restaurant's policy is a String on its profile
DeliveryFeeCalculation fee = registry.get(restaurant.getDeliveryPolicy());Strategy with lambdas and Function
With a single method on the interface, Strategy is a functional interface — and strategies with no dependencies or state fit in lambdas:
@FunctionalInterface
public interface DeliveryFeeCalculation {
BigDecimal calculate(Order order);
}
DeliveryFeeCalculation flat = order -> new BigDecimal("35.00");
DeliveryFeeCalculation free = order -> BigDecimal.ZERO;In fact, you've been using Strategy with lambdas for years: Comparator is a sorting strategy (list.sort(comparing(Dish::getPrice))), the Predicates in filter(...) are selection strategies, and Function/BiFunction serve as a generic Strategy interface without declaring anything (Function<Order, BigDecimal>). The criterion, identical to Command's: lambda for small algorithms with no dependencies; a class when the strategy has injected collaborators, state, or deserves its own tests. DistanceFee (with GeoService and rounding) is a class; the flat rate lives happily in a lambda. One nuance in favor of a named interface over a bare Function: DeliveryFeeCalculation says what it is in signatures and can evolve (a description() method for the receipt breakdown) without breaking anything.
Strategy vs. State vs. Bridge: the table
The two matchups this lesson had to settle, together — because the diagram of all three is nearly identical (a context/abstraction delegating to an interface) and the difference is one of intent:
| Criterion | Strategy | State | Bridge |
|---|---|---|---|
| What it encapsulates | A complete algorithm | The behavior of one life-cycle stage | The implementation of an abstraction (a technical "how") |
| Who changes the object | The client/configuration, from outside | The states themselves, when transitioning | Usually nobody: fixed at construction |
| Do the implementations know each other? | No: each strategy ignores its siblings | Yes: each state knows which ones it transitions to | No |
| Typical cardinality | One context, one algorithm at a time | One context, one state at a time, changing in sequence | Two full hierarchies evolving separately |
| Question it answers | How do I perform this calculation? | What can I do right now? | What mechanism does this family rest on? |
| In PideYa | DeliveryFeeCalculation, AssignmentStrategy |
OrderState |
Notification × DeliveryChannel |
The quick test when in doubt between Strategy and State: ask who changes the delegated object and why. Chosen by the outside according to preference/configuration, with variants that don't know each other? Strategy. Changes on its own, as a consequence of the operations, following a transition graph? State.
When to use it and when not to
Use it when:
- There exist (or are concretely foreseen) several ways of doing the same thing, and the choice varies by configuration, market, customer, or context.
- A conditional over "the calculation mode" repeats itself or grows inside classes that shouldn't know those details.
- You want to test algorithms in isolation from their contexts, or hide from the context data/dependencies that only the algorithm needs.
Avoid it when:
- There is only one algorithm and the "future variants" are speculation: interface + one implementation + injection for nothing is textbook over-engineering (lesson 01-06). Extract the strategy when the second real variant arrives.
- The variants differ in one small step of a shared flow, not in the whole algorithm: that's a better fit for the next lesson's pattern.
- The variation fits in a parameter (Mexico's vs. Colombia's flat rate is a
BigDecimal, not a new class).
Relationship with other patterns
- Template Method (next lesson): the same problem — varying parts of an algorithm — solved with inheritance instead of composition; full head-to-head there.
- State and Bridge: the table above.
- Command: Command reifies that something was requested (with undo, queueing, auditing); Strategy, how something is done. Head-to-head in the comparison.
- Abstract Factory: manufactures the strategy coherent with each family/market.
- Decorator: the
FeeSelectorthat wraps another strategy adds a decision without changing the interface — decorator mechanics over strategies. - Flyweight: stateless strategies, shared as single instances.
Common mistakes
- The context that asks
instanceofof its strategy ("if it's the distance one, then also..."): it breaks the whole contract; if the context needs to know which one it is, the interface is badly designed (a method expressing that variation is missing). - Anemic signatures that keep growing:
calculate(BigDecimal total)falls short when the distance strategy arrives, and each extension breaks every implementation. Pass the domain object (Order) or a parameter object from the start. - Strategies that mutate the context or the order: a calculation strategy with side effects (marking the order, writing to the DB) is a landmine; keep them pure whenever the domain allows.
- Sharing stateful strategies across threads or contexts without thinking: the round-robin with its counter is shared on purpose; make it thread-safe (our
AtomicInteger) or don't share it. - An enum-switch in disguise: a
switch (strategyType)inside the context calling different methods is not Strategy, it's what Strategy came to eliminate. The context doesn't choose between branches: it receives the already-chosen object.
Exercises
Exercise 1: off-peak strategy
Implement OffPeakFee: outside the peak slots (1–3 pm and 8–10 pm), it applies 50% of another strategy's calculation (the market's normal one); at peak times it delegates without a discount. Hint: it's built by wrapping another DeliveryFeeCalculation. Which structural pattern are you reusing?
Exercise 2: strategy as a lambda... or not?
For each policy, decide lambda or class, and why: (a) free delivery; (b) assignment by shortest distance (uses GeoService); (c) "the courier with the best average rating, breaking ties by fewest deliveries today"; (d) sorting the receipt's dishes alphabetically.
Exercise 3: State/Strategy face-off in someone else's code
A colleague models payment methods like this: order.setPaymentBehavior(new CardPayment()), and inside CardPayment.process() there is an order.setPaymentBehavior(new PaymentCompleted()). Is it Strategy or State? What gives the answer away, and what would you recommend?
Solutions
Solution 1:
public class OffPeakFee implements DeliveryFeeCalculation {
private static final List<LocalTime[]> PEAK = List.of(
new LocalTime[]{LocalTime.of(13, 0), LocalTime.of(15, 0)},
new LocalTime[]{LocalTime.of(20, 0), LocalTime.of(22, 0)});
private final DeliveryFeeCalculation base;
private final Supplier<LocalTime> clock; // injected so tests can control the time
public OffPeakFee(DeliveryFeeCalculation base, Supplier<LocalTime> clock) {
this.base = base;
this.clock = clock;
}
@Override
public BigDecimal calculate(Order order) {
BigDecimal normal = base.calculate(order);
return isPeak(clock.get())
? normal
: normal.multiply(new BigDecimal("0.5")).setScale(2, RoundingMode.HALF_UP);
}
private boolean isPeak(LocalTime now) {
return PEAK.stream().anyMatch(s -> !now.isBefore(s[0]) && now.isBefore(s[1]));
}
}The reused pattern is Decorator: same interface, wraps a DeliveryFeeCalculation, and modifies its result — patterns compose across families with complete naturalness. (And note the injected clock: without it, the tests would depend on the time of day you run them.)
Solution 2: (a) lambda (order -> BigDecimal.ZERO): no state, no dependencies; (b) class: an injected dependency (GeoService) and it deserves its own tests; (c) class (or at the very least a named Comparator built with comparing(...).thenComparing(...)): the composite business rule deserves a name and tests; (d) lambda/method reference (comparing(OrderLine::getDescription)): it's a trivial Comparator — which, remember, is already off-the-shelf Strategy in the JDK.
Solution 3: it is State disguised as Strategy: the delegated object replaces itself from within (CardPayment transitions to PaymentCompleted), which is exactly State's signature move — the "strategies" know each other and form a transition graph. The table's question gives it away: the outside doesn't choose it; it changes as a consequence of the operations. Recommendation: rename it and design it as an explicit state machine (an PaymentState interface, transitions through a single gate like transitionTo, a state diagram) — with Strategy names, the reader will look for external interchangeability where there is a life cycle, and vice versa.
Conclusion
Strategy reifies the algorithm: DeliveryFeeCalculation and AssignmentStrategy turn policies buried in conditionals into families of interchangeable classes — injected by configuration, chosen by context, or served from a NotifierRegistry-style registry, with lambdas for the lightweight variants and classes for those carrying dependencies. The CheckoutFacade and the DispatchCenter end up as they should: orchestrating, without knowing how anything is calculated. And you take away the table that dismantles the two classic confusions: against State (who changes the object and why?) and against Bridge (a complete algorithm, or the technical foundation of an abstraction?).
One more border remains to be drawn, the one announced under "when not to": what if the variants are not complete algorithms, but individual steps of a flow that is always the same? PideYa's daily closing reports load data, aggregate, format, and distribute — identical skeleton, different steps depending on the format. For that, GoF has its inheritance pattern par excellence, with its famous Hollywood principle: "don't call us; we'll call you". See you in Template Method.
Software Design Patterns Course
Module 1: Introduction to Design Patterns
- What Are Design Patterns?
- History and Origin of Design Patterns
- Design Principles: SOLID and Other Foundations
- Essential UML for Understanding Patterns
- Classification of Design Patterns
- Advantages and Disadvantages of Using Design Patterns
Module 2: Creational Patterns
- Introduction to Creational Patterns
- Singleton
- Factory Method
- Abstract Factory
- Builder
- Prototype
- Comparing and Choosing Creational Patterns
Module 3: Structural Patterns
- Introduction to Structural Patterns
- Adapter
- Bridge
- Composite
- Decorator
- Facade
- Flyweight
- Proxy
- Comparing and Choosing Structural Patterns
Module 4: Behavioral Patterns
- Introduction to Behavioral Patterns
- Chain of Responsibility
- Command
- Interpreter
- Iterator
- Mediator
- Memento
- Observer
- State
- Strategy
- Template Method
- Visitor
- Comparing and Choosing Behavioral Patterns
Module 5: Applying Design Patterns
- How to Select the Right Pattern
- Practical Examples of Pattern Usage
- Design Patterns in Real Projects
- Refactoring with Design Patterns
- Anti-Patterns: When Patterns Become a Problem
Module 6: Advanced Design Patterns
- Design Patterns in Modern Architectures
- Design Patterns in Microservices
- Design Patterns in Distributed Systems
- Concurrency Patterns
- Design Patterns in Agile Development
