Strategy Pattern: Choosing Algorithms at Runtime

java java21 scala scala2 scala3 kotlin design-patterns behavioral-patterns strategy-pattern

Imagine your checkout flow supports card payments, bank transfers, digital wallets, and buy-now-pay-later providers. At first, a switch in one service feels harmless. A few pricing exceptions later, that same service becomes the place where every fee rule, campaign tweak, and partner integration goes to die. That is exactly where the Strategy pattern helps.

The Problem: One Service Knows Too Many Algorithms

Different payment methods often need different fee rules:

  1. card payments charge a percentage plus a flat fee
  2. bank transfers are cheap but usually capped
  3. digital wallets may have VIP discounts and a minimum fee

If all of that logic lives in one method, the checkout service becomes harder to test, harder to extend, and harder to explain in an interview.

Key Concepts

Concept In this example Why it matters
Strategy PaymentFeeStrategy Encapsulates one fee algorithm behind one contract
Context PaymentFeeService Delegates to the selected strategy instead of branching everywhere
Input PaymentRequest Keeps payment method, amount, and VIP status together
Composition withMinimumFee() / withCap() Lets you layer small pricing rules without rewriting core algorithms

Real Use Case: Payment Fees in a Checkout Platform

Suppose your platform must quote fees before a customer confirms payment:

  1. Card: 2.9% + 0.30
  2. Bank transfer: 0.8%, capped at 7.50
  3. Digital wallet: 1.7%, or 0.85% for VIP customers, with a minimum fee of 0.25

That is a strong Strategy fit because the calling code wants one thing — a fee quote — while the algorithm depends on runtime input.

Component Walkthrough: What Each Part Does

  1. PaymentRequest carries the payment method, amount, currency, and VIP flag.
  2. PaymentFeeStrategy defines the shared algorithm contract.
  3. Each concrete strategy is implemented as a lambda or function object.
  4. PaymentFeeService stores the configured strategies and picks the right one at runtime.
  5. FeeQuote returns a simple response object with base amount, fee, and total.

Request Flow: How the Strategy Gets Chosen

  1. The client creates a PaymentRequest.
  2. PaymentFeeService.quote(request) reads request.paymentMethod.
  3. The service looks up the matching strategy in its registry.
  4. The selected strategy calculates the fee.
  5. The service wraps the result in a FeeQuote.

The important part is what does not happen: the client does not know whether the fee came from card logic, bank-transfer logic, or a promotional lambda.

The Solution: Strategy Across JVM Languages

This example uses a payment-fee registry so the context can switch algorithms without a growing if/else chain.

@FunctionalInterface
interface PaymentFeeStrategy {
    BigDecimal calculateFee(PaymentRequest request);

    default PaymentFeeStrategy withMinimumFee(BigDecimal minimumFee) {
        return request -> PaymentFeeService.scale(calculateFee(request).max(minimumFee));
    }
}

public static PaymentFeeService defaultService() {
    PaymentFeeStrategy card = request ->
            scale(request.amount().multiply(money("0.029")).add(money("0.30")));
    PaymentFeeStrategy bankTransfer = request ->
            scale(request.amount().multiply(money("0.008")));
    PaymentFeeStrategy digitalWallet = request ->
            scale(request.vipCustomer()
                    ? request.amount().multiply(money("0.0085"))
                    : request.amount().multiply(money("0.017")));

    return new PaymentFeeService(Map.of(
            PaymentMethod.CARD, card,
            PaymentMethod.BANK_TRANSFER, bankTransfer.withCap(money("7.50")),
            PaymentMethod.DIGITAL_WALLET, digitalWallet.withMinimumFee(money("0.25"))));
}
fun interface PaymentFeeStrategy {
    fun calculateFee(request: PaymentRequest): BigDecimal

    fun withMinimumFee(minimumFee: BigDecimal): PaymentFeeStrategy =
        PaymentFeeStrategy { request -> calculateFee(request).max(minimumFee).scaled() }
}

fun defaultService(): PaymentFeeService {
    val card = PaymentFeeStrategy { request ->
        request.amount.multiply(money("0.029")).add(money("0.30")).scaled()
    }
    val bankTransfer = PaymentFeeStrategy { request ->
        request.amount.multiply(money("0.008")).scaled()
    }
    val digitalWallet = PaymentFeeStrategy { request ->
        val rate = if (request.vipCustomer) money("0.0085") else money("0.017")
        request.amount.multiply(rate).scaled()
    }

    return PaymentFeeService(
        mapOf(
            PaymentMethod.CARD to card,
            PaymentMethod.BANK_TRANSFER to bankTransfer.withCap(money("7.50")),
            PaymentMethod.DIGITAL_WALLET to digitalWallet.withMinimumFee(money("0.25")),
        ),
    )
}
trait PaymentFeeStrategy {
  def calculateFee(request: PaymentRequest): BigDecimal

  def withMinimumFee(minimumFee: BigDecimal): PaymentFeeStrategy =
    PaymentFeeStrategy(request => PaymentFeeService.scale(calculateFee(request).max(minimumFee)))
}

object PaymentFeeService {
  def defaultService: PaymentFeeService = {
    val card =
      PaymentFeeStrategy(request => scale(request.amount * BigDecimal("0.029") + BigDecimal("0.30")))
    val bankTransfer =
      PaymentFeeStrategy(request => scale(request.amount * BigDecimal("0.008")))
    val digitalWallet = PaymentFeeStrategy { request =>
      val rate = if (request.vipCustomer) BigDecimal("0.0085") else BigDecimal("0.017")
      scale(request.amount * rate)
    }

    new PaymentFeeService(
      Map(
        CARD           -> card,
        BANK_TRANSFER  -> bankTransfer.withCap(BigDecimal("7.50")),
        DIGITAL_WALLET -> digitalWallet.withMinimumFee(BigDecimal("0.25"))
      )
    )
  }
}
trait PaymentFeeStrategy:
  def calculateFee(request: PaymentRequest): BigDecimal

  def withMinimumFee(minimumFee: BigDecimal): PaymentFeeStrategy =
    PaymentFeeStrategy(request => PaymentFeeService.scale(calculateFee(request).max(minimumFee)))

object PaymentFeeService:
  def defaultService: PaymentFeeService =
    val card =
      PaymentFeeStrategy(request => scale(request.amount * BigDecimal("0.029") + BigDecimal("0.30")))
    val bankTransfer =
      PaymentFeeStrategy(request => scale(request.amount * BigDecimal("0.008")))
    val digitalWallet = PaymentFeeStrategy: request =>
      val rate = if request.vipCustomer then BigDecimal("0.0085") else BigDecimal("0.017")
      scale(request.amount * rate)

    new PaymentFeeService(
      Map(
        PaymentMethod.CARD           -> card,
        PaymentMethod.BANK_TRANSFER  -> bankTransfer.withCap(BigDecimal("7.50")),
        PaymentMethod.DIGITAL_WALLET -> digitalWallet.withMinimumFee(BigDecimal("0.25"))
      )
    )

Scala Developer Mental Model

  • Java 21 uses a functional interface, so Strategy can look object-oriented or lambda-based.
  • Scala 2/3 already treats functions as values, so Strategy often feels like “pass a function with a meaningful domain name”.
  • Kotlin sits in the middle: fun interface keeps the Strategy vocabulary while still feeling lightweight.

Testing the Strategy: Proving Runtime Swapping Works

The most useful Strategy test is not just “does card math work?” It is “can I swap in a different algorithm without changing the calling code?”

@Test
void shouldAllowLambdaBasedCustomStrategies() {
    var service = new PaymentFeeService(Map.of(
            PaymentMethod.BUY_NOW_PAY_LATER,
            request -> new BigDecimal("9.00")));
    var request = new PaymentRequest(PaymentMethod.BUY_NOW_PAY_LATER, new BigDecimal("200.00"), "GBP", false);

    var quote = service.quote(request);

    assertEquals(new BigDecimal("9.00"), quote.fee());
    assertEquals(new BigDecimal("209.00"), quote.totalAmount());
}
@Test
fun shouldAllowLambdaBasedCustomStrategies() {
    val service = PaymentFeeService(
        mapOf(
            PaymentMethod.BUY_NOW_PAY_LATER to PaymentFeeStrategy { BigDecimal("9.00") },
        ),
    )
    val request = PaymentRequest(PaymentMethod.BUY_NOW_PAY_LATER, BigDecimal("200.00"), "GBP", false)

    val quote = service.quote(request)

    assertEquals(BigDecimal("9.00"), quote.fee)
    assertEquals(BigDecimal("209.00"), quote.totalAmount)
}
test("Strategy should allow lambda-based custom strategies") {
  val service = new PaymentFeeService(
    Map(BUY_NOW_PAY_LATER -> PaymentFeeStrategy(_ => BigDecimal("9.00")))
  )
  val quote = service.quote(
    PaymentRequest(BUY_NOW_PAY_LATER, BigDecimal("200.00"), "GBP", vipCustomer = false)
  )

  quote.fee shouldBe BigDecimal("9.00")
  quote.totalAmount shouldBe BigDecimal("209.00")
}
test("Strategy should allow lambda-based custom strategies") {
  val service = new PaymentFeeService(
    Map(PaymentMethod.BUY_NOW_PAY_LATER -> PaymentFeeStrategy(_ => BigDecimal("9.00")))
  )
  val quote = service.quote(
    PaymentRequest(PaymentMethod.BUY_NOW_PAY_LATER, BigDecimal("200.00"), "GBP", vipCustomer = false)
  )

  quote.fee shouldBe BigDecimal("9.00")
  quote.totalAmount shouldBe BigDecimal("209.00")
}

Comparison: Java 21 vs Scala 2 vs Scala 3 vs Kotlin

Language Strategy shape Best part here Trade-off
Java 21 @FunctionalInterface + Map registry Explicit and interview-friendly More ceremony than Scala/Kotlin
Scala 2 trait + function-valued companion Very natural functional composition Older syntax is slightly noisier
Scala 3 trait + indentation syntax Strategy reads almost like DSL code Teams need to be comfortable with Scala 3 style
Kotlin fun interface + concise lambdas Lightweight with clear OO naming BigDecimal math is still Java-flavored

When to Use / Best Practices

  1. Use Strategy when the algorithm varies but the caller should stay stable.
  2. Prefer small, focused strategies over one strategy object with its own branching.
  3. Keep selection logic in one place, often a registry or dependency-injected map.
  4. Add composition helpers like withCap() and withMinimumFee() when pricing rules layer naturally.
  5. Do not force Strategy for one-off conditionals that are unlikely to grow.

Interview Q&A: Strategy Pattern in Practice

How would you implement different payment processing strategies?
Start with one shared contract such as PaymentFeeStrategy, then implement one algorithm per payment method and store them behind a selector. The real benefit is that checkout code calls one method while the fee logic changes independently. For example, card, wallet, and bank-transfer pricing can all live behind the same quote() API.
What is the relationship between the Strategy pattern and lambda expressions?
A lambda is often the smallest possible Strategy implementation. The pattern is still the same: one interchangeable algorithm behind one contract. In Java 21, Kotlin, and Scala, a simple pricing rule can be written as a lambda instead of a named class, which keeps the code shorter without losing the design idea.
When should you use Strategy instead of plain if-else statements?
Use Strategy when the branching is likely to grow, needs separate tests, or changes for business reasons over time. A tiny two-branch condition is fine as an if. But once each branch has its own rules, caps, discounts, or integrations, Strategy keeps the system easier to extend safely.
Can you compose strategies together?
Yes, and composition is often where Strategy becomes really useful. You can take one base algorithm and wrap it with extra rules such as minimum fees, caps, or promotional adjustments. In this example, withMinimumFee() and withCap() let you layer policy without rewriting the core calculation.
How does dependency injection relate to the Strategy pattern?
Dependency injection is often the delivery mechanism for strategies. Instead of hard-coding which algorithm to use, the context receives a map or list of strategies from configuration. That means adding a new payment method can become a wiring change plus one new strategy, rather than editing the main service every time.

Conclusion

The Strategy pattern is one of the clearest places where Java and Scala thinking overlap. If you are coming from Scala, think of Strategy as “give the program a named function with a stable contract”. If you are coming from Java, think of lambdas as a lighter way to implement a familiar object-oriented pattern.

Code Samples

All examples in this post are runnable. Find them in the repository:


This post is part of the Design Patterns in JVM Languages - Your Guide to the Top 10. Nearby related posts from the same guide: Facade Pattern: Simplifying Complex Systems and Decorator Pattern: Wrapping Objects with Style.