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:
- card payments charge a percentage plus a flat fee
- bank transfers are cheap but usually capped
- 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:
- Card:
2.9% + 0.30 - Bank transfer:
0.8%, capped at7.50 - Digital wallet:
1.7%, or0.85%for VIP customers, with a minimum fee of0.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
PaymentRequestcarries the payment method, amount, currency, and VIP flag.PaymentFeeStrategydefines the shared algorithm contract.- Each concrete strategy is implemented as a lambda or function object.
PaymentFeeServicestores the configured strategies and picks the right one at runtime.FeeQuotereturns a simple response object with base amount, fee, and total.
Request Flow: How the Strategy Gets Chosen
- The client creates a
PaymentRequest. PaymentFeeService.quote(request)readsrequest.paymentMethod.- The service looks up the matching strategy in its registry.
- The selected strategy calculates the fee.
- 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 interfacekeeps 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
- Use Strategy when the algorithm varies but the caller should stay stable.
- Prefer small, focused strategies over one strategy object with its own branching.
- Keep selection logic in one place, often a registry or dependency-injected map.
- Add composition helpers like
withCap()andwithMinimumFee()when pricing rules layer naturally. - 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?
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?
When should you use Strategy instead of plain if-else statements?
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?
withMinimumFee() and withCap() let you layer policy without rewriting the core calculation.
How does dependency injection relate to the Strategy pattern?
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:
- Java 21 implementation
- Java 21 tests
- Kotlin implementation
- Kotlin tests
- Scala 2 implementation
- Scala 2 tests
- Scala 3 implementation
- Scala 3 tests
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.