Published

- 5 min read

Strategy Pattern in Java and Spring: Add PayLater Without Touching Checkout

One interface, one class per payment, and a Spring map that wires them all for you.

img of Strategy Pattern in Java and Spring: Add PayLater Without Touching Checkout

Your interviewer asks you to add a new payment type, PayLater, without touching any existing code. How would you do it?

This question comes up in low-level design (LLD) rounds because it tests one idea: can you design code that grows by adding classes instead of editing old ones? The answer is the Strategy pattern, and if you write Java, you’ve already used it.

The if-else version

Most of us start like this: one pay method with a branch for each payment type.

   void pay(Type t, long amt) {
  if (t == Type.CARD) {
    card(amt);
  } else if (t == Type.PAYPAL) {
    payPal(amt);
  } else if (t == Type.WALLET) {
    wallet(amt);
  }
}

It works. Now add PayLater:

   void pay(Type t, long amt) {
  if (t == Type.CARD) {
    card(amt);
  } else if (t == Type.PAYPAL) {
    payPal(amt);
  } else if (t == Type.WALLET) {
    wallet(amt);
  } else if (t == Type.PAY_LATER) {
    payLater(amt);
  }
}

Every new payment type means editing this class, and everything in it ships again: the card, PayPal and wallet paths you never meant to change need retesting too. The class is never finished. In design terms it breaks the open/closed principle: code should be open for extension but closed for modification.

The Strategy pattern

Move each branch into its own class, behind one interface:

   interface PaymentStrategy {
  void pay(long amt);
}

amt is in cents, so $49 is pay(4900). Each payment becomes a class that implements it:

   class Card implements PaymentStrategy {
  public void pay(long amt) {
    System.out.println("Card pays " + amt);
  }
}

class PayPal implements PaymentStrategy {
  public void pay(long amt) {
    System.out.println("PayPal pays " + amt);
  }
}

class Wallet implements PaymentStrategy {
  public void pay(long amt) {
    System.out.println("Wallet pays " + amt);
  }
}

Checkout now holds a strategy and calls pay. It doesn’t care which one:

   class Checkout {
  PaymentStrategy strategy;

  void pay(long amt) {
    strategy.pay(amt);
  }
}

To change the behaviour, swap the object:

   Checkout checkout = new Checkout();
for (PaymentStrategy s :
    List.of(new Card(), new PayPal(), new Wallet())) {
  checkout.strategy = s; // swap the behaviour
  checkout.pay(4900);    // Checkout never changes
}

/CodeVxDev

ionicons-v5-d

strategy

java Strategy.java

9:17:29

Card pays 4900
PayPal pays 4900
Wallet pays 4900

That’s the whole pattern: a family of interchangeable behaviours (the strategies) behind one interface, and a context (Checkout) that uses whichever one it’s given. Adding PayLater is now a new class. Checkout stays as it is, and so do its tests.

You’ve already used it: Comparator

Every Comparator you pass to sort is a strategy. List.sort is the context, and the comparator decides the order:

   list.sort(byPrice);
list.sort(byRating);
   record Item(String name, long price, double rating) {}
Comparator<Item> byPrice =
    Comparator.comparingLong(Item::price);
Comparator<Item> byRating =
    Comparator.comparingDouble(Item::rating).reversed();

/CodeVxDev

ionicons-v5-d

strategy

java Strategy.java

9:17:29

by price: [cable, mouse, keyboard]
by rating: [keyboard, mouse, cable]

Same shape as Checkout: the sorting code never changes; you pass in the rule. The JDK has more: a ThreadPoolExecutor takes a RejectedExecutionHandler (AbortPolicy, CallerRunsPolicy, DiscardPolicy) that decides what happens when the queue is full. Since Java 8, a strategy with one method is often just a lambda.

In Spring, it’s even easier

In a Spring Boot service you don’t wire the strategies yourself. Make each one a bean and ask for a Map:

   @Service
@RequiredArgsConstructor
class Checkout {
  private final
    Map<String, PaymentStrategy> m;

  void pay(String type, long amt) {
    m.get(type).pay(amt);
  }
}

Spring fills the map with every PaymentStrategy bean, keyed by bean name (Lombok’s @RequiredArgsConstructor writes the constructor it injects through). A @Component class is named after the class with a lowercase first letter, so the keys are "card", "payPal" and "wallet".

Adding PayLater? Just one new class:

   @Component
class PayLater implements
    PaymentStrategy {
  public void pay(long amt) { … }
}

Nothing else changes. Spring picks up the new bean and the map gains a "payLater" entry:

/CodeVxDev

ionicons-v5-d

strategy

java SpringWiring.java

9:17:29

bean names: [card, payPal, wallet, payLater]
PayLater pays 4900

That’s your interview answer: Checkout is closed for modification and open for extension, and new behaviour arrives as a new class.

Before you ship it

A few things the short version leaves out:

  • Unknown types. m.get(type) returns null for a type nobody registered, and the next call throws a NullPointerException. Fail with a clear message instead:

       void pay(String type, long amt) {
      PaymentStrategy strategy = m.get(type);
      if (strategy == null) {
        throw new IllegalArgumentException(
            "Unsupported payment type: " + type);
      }
      strategy.pay(amt);
    }
  • Stable keys. Default bean names change when someone renames a class. If the type comes from requests or a database, name the beans explicitly, for example @Component("pay-later").

  • Order. If the strategies have to run in a set order, inject a List<PaymentStrategy> instead; Spring sorts it by @Order.

Spot it in LLD interviews

Whenever a question has many ways to do the same job, think Strategy. The shape never changes: a context, one interface, and a class per rule.

QuestionContextStrategiesInterface
Parking lot pricingParkingLotHourly · Flat · WeekendPricingStrategy
Elevator schedulingElevatorControllerNearest car · SCAN · ZonedDispatchStrategy
Ride faresRideServiceStandard · Surge · SharedFareStrategy
Cart discountsCart% off · Flat off · Buy 1 get 1DiscountStrategy
Splitting billsExpenseEqual · Exact · PercentSplitStrategy
Rate limitingRateLimiterToken bucket · Sliding window · Fixed windowRateLimitStrategy

In the interview, name the interface early, give it one method, and say how a new rule gets added: one new class, and nothing else changes.

When not to use it

Strategy earns its keep when one job has several interchangeable rules and new ones keep arriving. With two cases that never change, an if-else is simpler, and an extra interface is just noise.

/CodeVxDev

ionicons-v5-d

strategy

cat strategy.md

9:17:29

swap behaviour without if-else
use: one rule, many versions
skip: 2 cases that never change

The interview trap

An ATM also changes its behaviour: idle, card inserted, dispensing. Is that Strategy, or not?

A. Strategy    B. Not Strategy

Think about who decides when the behaviour changes. Leave A or B as a comment on the video; the next episode in this series answers it.