Strategy Design Pattern in Kotlin: Unlock Flexible Code Architecture

Table of Contents

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 or when 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

Kotlin
interface Strategy {
    fun doOperation(num1: Int, num2: Int): Int
}

Create Concrete Classes Implementing the Interface

Kotlin
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

Kotlin
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

Kotlin
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

Kotlin
10 + 5 = 15
10 - 5 = 5
10 * 5 = 50

Here,

  • The Strategy interface defines the contract for the algorithm.
  • OperationAdd, OperationSubtract, and OperationMultiply are concrete classes that implement the Strategy interface.
  • The Context class uses a Strategy to perform an operation.
  • In main(), we change the Strategy at runtime by passing different Strategy objects to the Context.

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.

Kotlin
// 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.

Kotlin
// 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.

Kotlin
// 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.

Kotlin
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

Kotlin
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.

Kotlin
// 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.

Kotlin
// 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.

Kotlin
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..!

Skill Up: Software & AI Updates!

Receive our latest insights and updates directly to your inbox

Related Posts

error: Content is protected !!