Writing clean, maintainable, and scalable code is every developer’s goal, and design patterns play a big role in making that happen. One pattern that stands out for its flexibility and simplicity is the Strategy Design Pattern. It lets you define a group of algorithms, keep them separate, and easily switch between them when needed.
In this blog, we’ll explore how the Strategy Design Pattern works in Kotlin, break down its implementation step by step, and look at some real-world examples to show how it can make your code more organized and easier to work with.
Strategy Design Pattern
The Strategy Pattern falls under the category of behavioral design patterns, and its purpose is straightforward: it enables us to define multiple algorithms and switch between them dynamically, without modifying the client code. Instead of duplicating code or repeatedly writing the same logic, this pattern allows you to define a family of algorithms and choose the one that best fits the client’s needs.
This pattern aligns with two key principles of software design:
- Encapsulation: Each algorithm is encapsulated in its own class.
- Open/Closed Principle: The code is open for extension (new strategies can be added) but closed for modification (existing code remains unchanged).
The beauty of the Strategy Pattern lies in its simplicity and flexibility. It enables you to add new features or extend functionality without requiring significant changes to existing code. Additionally, it allows your program to swap behaviors dynamically at runtime, making it highly adaptable to changing requirements with minimal effort.
When to Use the Strategy Pattern?
You should consider using the Strategy Pattern when:
- You have multiple ways to accomplish a task and want the flexibility to switch between them easily.
- You want to avoid cluttered classes with lots of
if
orwhen
statements. - You need to keep the algorithm’s implementation separate from the rest of your code.
- You want your code to be easily extended with new features or behaviors without changing existing code.
Here are a few real-life scenarios where the Strategy Pattern works really well:
- Payment gateways: Letting users choose between different payment methods, like credit cards, PayPal, or bank transfers.
- Sorting algorithms: Allowing users to switch between sorting methods, like quick sort or bubble sort, based on their preference.
- Discount calculations: Handling various types of discounts in a shopping cart, such as percentage-based, fixed amount, or special promotions.
We will soon look at the code implementation, but before that, let’s first understand the structure of the Strategy Pattern and its key components.
Strategy Design Pattern Structure
Let’s break the pattern into its core components:
Strategy
- Defines a common interface for all the supported algorithms.
- The context uses this interface to call the algorithm defined by a specific strategy.
ConcreteStrategy
- Implements the algorithm as outlined by the Strategy interface.
Context
- Is configured with a ConcreteStrategy object.
- Holds a reference to a Strategy object.
- May offer an interface that allows the Strategy to access its internal data or state, but only when necessary.
Let’s now implement the Strategy Design Pattern for a simple calculation operation.
Create an Interface
interface Strategy {
fun doOperation(num1: Int, num2: Int): Int
}
Create Concrete Classes Implementing the Interface
class OperationAdd : Strategy {
override fun doOperation(num1: Int, num2: Int): Int {
return num1 + num2
}
}
class OperationSubtract : Strategy {
override fun doOperation(num1: Int, num2: Int): Int {
return num1 - num2
}
}
class OperationMultiply : Strategy {
override fun doOperation(num1: Int, num2: Int): Int {
return num1 * num2
}
}
Create Context Class
class Context(private var strategy: Strategy) {
fun executeStrategy(num1: Int, num2: Int): Int {
return strategy.doOperation(num1, num2)
}
}
Use the Context to See Change in Behaviour When It Changes Its Strategy
fun main() {
var context = Context(OperationAdd())
println("10 + 5 = ${context.executeStrategy(10, 5)}")
context = Context(OperationSubtract())
println("10 - 5 = ${context.executeStrategy(10, 5)}")
context = Context(OperationMultiply())
println("10 * 5 = ${context.executeStrategy(10, 5)}")
}
Output
10 + 5 = 15
10 - 5 = 5
10 * 5 = 50
Here,
- The
Strategy
interface defines the contract for the algorithm. OperationAdd
,OperationSubtract
, andOperationMultiply
are concrete classes that implement theStrategy
interface.- The
Context
class uses aStrategy
to perform an operation. - In
main()
, we change theStrategy
at runtime by passing differentStrategy
objects to theContext
.
Real-World Example: Payment System
I hope you’ve grasped the basic concept and are now ready for a real-world code implementation. Let’s apply the Strategy Pattern to build a payment processing system where users can choose from different methods: Credit Card, PayPal, or Cryptocurrency.
Define the Strategy Interface
The PaymentStrategy
interface will define a common method for all payment strategies.
// Strategy Interface
interface PaymentStrategy {
fun pay(amount: Double)
}
This is our abstraction. Each payment method will provide its specific implementation of the pay
method.
Create Concrete Strategies
Now, let’s implement the PaymentStrategy
interface for different payment methods.
// Concrete Strategy: Credit Card Payment
class CreditCardPayment(private val cardNumber: String) : PaymentStrategy {
override fun pay(amount: Double) {
println("Paid $$amount using Credit Card (Card Number: $cardNumber)")
}
}
// Concrete Strategy: PayPal Payment
class PayPalPayment(private val email: String) : PaymentStrategy {
override fun pay(amount: Double) {
println("Paid $$amount using PayPal (Email: $email)")
}
}
// Concrete Strategy: Cryptocurrency Payment
class CryptoPayment(private val walletAddress: String) : PaymentStrategy {
override fun pay(amount: Double) {
println("Paid $$amount using Cryptocurrency (Wallet: $walletAddress)")
}
}
Each class provides its unique implementation of the pay
method. Notice how we’re encapsulating the logic specific to each payment method.
Create the Context
The PaymentContext
class will use the selected PaymentStrategy
to process payments.
// Context Class
class PaymentContext(private var paymentStrategy: PaymentStrategy) {
// Allows dynamic switching of strategy
fun setPaymentStrategy(strategy: PaymentStrategy) {
paymentStrategy = strategy
}
// Delegates the payment to the selected strategy
fun executePayment(amount: Double) {
paymentStrategy.pay(amount)
}
}
The PaymentContext
class acts as a bridge between the client code and the various payment strategies. It allows us to switch strategies on the fly using the setPaymentStrategy
method.
Putting everything together
Now let’s see how we can use the Strategy Pattern.
fun main() {
// Create specific payment strategies
val creditCardPayment = CreditCardPayment("1234-5678-9876-5432")
val paypalPayment = PayPalPayment("[email protected]")
val cryptoPayment = CryptoPayment("1A2b3C4d5E6F")
// Create the context with an initial strategy
val paymentContext = PaymentContext(creditCardPayment)
// Execute payment using Credit Card
paymentContext.executePayment(100.0)
// Switch to PayPal strategy and execute payment
paymentContext.setPaymentStrategy(paypalPayment)
paymentContext.executePayment(200.0)
// Switch to Cryptocurrency strategy and execute payment
paymentContext.setPaymentStrategy(cryptoPayment)
paymentContext.executePayment(300.0)
}
Output
Paid $100.0 using Credit Card (Card Number: 1234-5678-9876-5432)
Paid $200.0 using PayPal (Email: user@paypal.com)
Paid $300.0 using Cryptocurrency (Wallet: 1A2b3C4d5E6F)
Basically, in India, we rarely use crypto for payments, but UPI payments are everywhere. Let’s add that here. The reason I’m providing this context is to demonstrate how extensible this pattern is, in line with the Open/Closed Principle.
// New Concrete Strategy: UPI Payment
class UPIPayment(private val upiId: String) : PaymentStrategy {
override fun pay(amount: Double) {
println("Paid $$amount using UPI (UPI ID: $upiId)")
}
}
We can now use it like this.
// Add UPI payment
paymentContext.setPaymentStrategy(UPIPayment("user@upi"))
paymentContext.executePayment(400.0)
// Output
// Paid $400.0 using UPI (UPI ID: user@upi)
One more thing I’d like to highlight here is that the Strategy Pattern works well with Kotlin’s functional features. For simpler use cases, we can replace strategies with Kotlin lambdas to further reduce boilerplate code.
Here is a simple modification to test it.
class PaymentContextWithLambda {
private var paymentStrategy: (Double) -> Unit = {}
fun setPaymentStrategy(strategy: (Double) -> Unit) {
paymentStrategy = strategy
}
fun executePayment(amount: Double) {
paymentStrategy(amount)
}
}
fun main() {
val paymentContext = PaymentContextWithLambda()
// Using lambdas as strategies
paymentContext.setPaymentStrategy { amount -> println("Paid $$amount using Credit Card") }
paymentContext.executePayment(500.0)
paymentContext.setPaymentStrategy { amount -> println("Paid $$amount using PayPal") }
paymentContext.executePayment(600.0)
}
// Output
// Paid $500.0 using Credit Card
// Paid $600.0 using PayPal
If we closely look at everything here, we’ll find…
Encapsulation of Algorithms
The PaymentStrategy
interface encapsulates the algorithms (payment methods), ensuring each has a unique implementation in its respective class.
Dynamic Behavior
The PaymentContext
class allows you to switch payment methods dynamically by calling the setPaymentStrategy
method.
Open/Closed Principle
Adding a new payment method doesn’t require modifying existing classes. Instead, you just create a new implementation of PaymentStrategy
.
Reusability
The strategy classes (CreditCardPayment
, PayPalPayment
, etc.) can be reused in different contexts.
Benefits of Using the Strategy Pattern
Eliminates Conditional Logic:
No more lengthy if-else
or when
statements for selecting an algorithm.
Promotes Single Responsibility Principle:
Each algorithm resides in its class, simplifying maintenance and testing.
Improves Flexibility:
You can change strategies at runtime without impacting the client code.
Encourages Code Reuse:
Strategies can be reused across different projects or modules.
Drawbacks of the Strategy Pattern
Increased Number of Classes:
Each algorithm requires its own class, which may clutter the codebase.
Context Dependency:
The context relies on the strategy being set correctly before use, which might lead to runtime errors if not handled carefully.
When Not to Use the Strategy Pattern
Avoid the Strategy Pattern if:
- The algorithms are unlikely to change or expand.
- There’s no need for runtime flexibility.
Conclusion
The Strategy Design Pattern is an elegant way to handle situations where multiple algorithms or behaviors are needed. Kotlin’s modern syntax makes implementing this pattern straightforward and flexible.
By encapsulating algorithms into individual classes or leveraging Kotlin’s lambda expressions, we can write code that’s not only clean and modular but also adheres to key principles like the Open/Closed Principle.
So next time you find yourself writing a series of if
or when
statements to handle various behaviors, consider using the Strategy Pattern.
Happy coding..! Enjoy exploring strategies..!