1. Overview
In this tutorial, we’ll explore the Strategy design pattern in Kotlin and understand the problem that the pattern solves.
The Strategy pattern is a behavioral design pattern that helps us define different solutions to a problem and make them interchangeable at runtime. It’s one of the most commonly used design patterns in real-world applications.
2. Code Example
Let’s consider a simple shop that sells books. The shop offers different discounts to its customers based on their membership type. For example, the shop offers a 10% discount to regular customers and a 20% discount to premium customers.
When a customer buys a book, the shop needs to calculate the discount based on the customer’s membership type. Let’s first see how we can implement this in Kotlin without using the Strategy pattern.
2.1. Book Class
Let’s start by creating a Book class that represents a book in the shop:
data class Book(val title: String, val price: Double)
For simplicity, the book class has only two properties: title and price.
2.2. Customer Class
Next, let’s create a Customer class that represents a customer in the shop:
data class Customer(val name: String, val membershipType: MembershipType)
The customer class has two properties: name and membershipType. The membershipType property is of type MembershipType, which is an enum that represents the different membership types.
2.3. MembershipType Enum
Next, let’s create an enum called MembershipType that represents the different membership types:
enum class MembershipType {
REGULAR, PREMIUM
}
Here, we have two membership types: REGULAR and PREMIUM.
2.4. DiscountCalculator – Without Strategy Pattern
Next, let’s create a class called DiscountCalculator that calculates the discount based on the customer’s membership type:
class DiscountCalculator {
fun calculateDiscount(book: Book, customer: Customer): Double {
return if (customer.membershipType == MembershipType.REGULAR) {
book.price * 0.1
} else {
book.price * 0.2
}
}
}
When there are two membership types, we can easily calculate the discount using a simple if-else statement. However, if we have more membership types, this approach becomes difficult to maintain. Similarly, if discount calculation logic is complex for each membership type, the if–else statement becomes difficult to maintain.
3. Using the Strategy Pattern
Let’s see how we can use the Strategy pattern to solve the problem we discussed in the previous section.
3.1. The Strategy Interface
Let’s start by creating an interface called DiscountStrategy that defines the contract for the different discount strategies:
interface DiscountStrategy {
fun calculateDiscount(book: Book): Double
}
The DiscountStrategy interface has a single method called calculateDiscount() that takes a Book object as a parameter and returns the discount amount.
3.2. First Strategy – for Regular Customers
Next, let’s create a class called RegularCustomerDiscountStrategy that implements the DiscountStrategy interface:
class RegularCustomerDiscountStrategy : DiscountStrategy {
override fun calculateDiscount(book: Book): Double {
return book.price * 0.1
}
}
3.3. Another Strategy – for Premium Customers
Next, let’s create a class called PremiumCustomerDiscountStrategy that implements the DiscountStrategy interface:
class PremiumCustomerDiscountStrategy : DiscountStrategy {
override fun calculateDiscount(book: Book): Double {
return book.price * 0.2
}
}
3.4. Adding Dynamic Strategy to DiscountCalculator
Now that we have our discount strategies, let’s update the DiscountCalculator class to use the strategies:
class DiscountCalculator(private val discountStrategy: DiscountStrategy) {
fun calculateDiscount(book: Book): Double {
return discountStrategy.calculateDiscount(book)
}
}
The DiscountCalculator class now takes a DiscountStrategy object as a constructor parameter. The calculateDiscount() method of the DiscountCalculator class simply delegates the discount calculation to the DiscountStrategy object.
4. Testing
Now that we have our classes, let’s write some tests to verify that our code works as expected.
First, let’s see how we can inject our strategies:
class DiscountCalculatorTest {
private fun createDiscountCalculator(customer: Customer): DiscountCalculator {
val discountStrategy = when (customer.membershipType) {
MembershipType.REGULAR -> RegularCustomerDiscountStrategy()
MembershipType.PREMIUM -> PremiumCustomerDiscountStrategy()
}
return DiscountCalculator(discountStrategy);
}
}
In the createDiscountCalculator() method, we create a DiscountStrategy object based on the customer’s membership type. We then create a DiscountCalculator object using the DiscountStrategy object. This is similar to how real-life applications would create the DiscountCalculator object and pass a DiscountStrategy object to it.
Next, let’s write tests to test each of our strategies:
@Test
fun `calculate discount for regular customer`() {
val book = Book("Effective Java", 100.0)
val customer = Customer("John Doe", MembershipType.REGULAR)
val discountCalculator = discountCalculator(customer)
val discount = discountCalculator.calculateDiscount(book)
assertEquals(10.0, discount)
}
@Test
fun `calculate discount for premium customer`() {
val book = Book("Effective Java", 100.0)
val customer = Customer("John Doe", MembershipType.PREMIUM)
val discountCalculator = discountCalculator(customer)
val discount = discountCalculator.calculateDiscount(book)
assertEquals(20.0, discount)
}
Here, we have two tests: one for regular customers and one for premium customers. In each test, we create a Book object, a Customer object, and a DiscountCalculator object. We then call the calculateDiscount() method of the DiscountCalculator object and verify that the discount is calculated correctly.
5. Conclusion
In this article, we talked about the Strategy design pattern. We also looked at an example in Kotlin to understand the problem that the Strategy pattern solves. Finally, we looked at a code example for the same.
The complete source code for the examples discussed in this article is available over on GitHub.