Strategy Design Pattern with Spring Boot + Kotlin - Cleaner code with no boilerplate
The Strategy Pattern
If you already know the strategy pattern just jump to "The Library" section.
What is it?
According to Wikipedia, the strategy pattern is a behavioral software design pattern that enables selecting an algorithm at runtime. Instead of implementing a single algorithm directly, code receives run-time instructions as to which in a family of algorithms to use.
In simple words, the strategy pattern is used to specify how something should be done by providing a specific algorithm. For example, a strategy for payment could be Credit Card, PayPal, or any other payment provider.
Why should we use it?
The Strategy Pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. Strategy lets the algorithm vary independently from clients that use it (source).
In short, it makes our code cleaner, easier to maintain, and reusable.
The Strategy Pattern in Code
Implementations of the pattern consist of an interface defining the strategy, multiple concrete implementations of the strategy and a context using the strategy.
By Vanderjoe - Own work, CC BY-SA 4.0, https://commons.wikimedia.org/w/index.php?curid=60733582
Code Example
Let's look at the following simplified scenario:
We have an application with users and we need to send them verification codes for 2FA.
The application supports multiple communication options for receiving the verification code: Email, SMS, Notifications...
Each user selects its preferred communication method.
First we will define our strategy interface:
interface VerificationCodeCommunicationStrategy {
fun sendVerificationCode(user: User, code: String)
}
Now let's add our implementations:
class EmailCommunicationStrategy : VerificationCodeCommunicationStrategy {
override fun sendVerificationCode(user: User, code: String) {
emailClient.sendEmail(user.email, "Verification code", "Your verification is $code.")
}
}
class SmsCommunicationStrategy : VerificationCodeCommunicationStrategy {
override fun sendVerificationCode(user: User, code: String) {
smsClient.sendSMS(user.phone, "Your verification is $code.")
}
}
// ...
All that's left is to use the strategy.
enum class CommunicationMethod {
Email, SMS, Notification
}
class VerificationCodeService {
val verificationCodeStrategyMap: Map<CommunicationMethod, VerificationCodeCommunicationStrategy> = mapOf(
CommunicationMethod.Email to EmailCommunicationStrategy(),
CommunicationMethod.SMS to SmsCommunicationStrategy(),
// ...
)
fun sendVerificationCode(user: User) {
val code = generateVerificationCode()
val verificationCodeStrategy = verificationCodeStrategyMap[user.preferredCommunicationMethod]
verificationCodeStrategy?.sendVerificationCode(user, code)
}
}
We can see how the code is clean and easy to maintain. Adding a new communication method would be easy and will not need to change existing code other than adding it to the map of strategies.
The Library
We use the strategy pattern a-looot in our projects. Moreover, like all developers we are lazy and want to make our life as easy as possible. This is why we created our library.
Code Example WITHOUT the library
Let's revise the previous example for a regular Spring Boot implementation.
When using the strategy pattern in a Spring Boot application the context and strategy implementations are usually Spring components.
We will start with the strategies:
interface VerificationCodeCommunicationStrategy {
fun sendVerificationCode(user: User, code: String)
}
@Component
class EmailCommunicationStrategy : VerificationCodeCommunicationStrategy {
override fun sendVerificationCode(user: User, code: String) {
emailClient.sendEmail(user.email, "Verification code", "Your verification is $code.")
}
}
@Component
class SmsCommunicationStrategy : VerificationCodeCommunicationStrategy {
override fun sendVerificationCode(user: User, code: String) {
smsClient.sendSMS(user.phone, "Your verification is $code.")
}
}
// ...
The interface remains the same and the implementations receive the @Component
annotation.
Now let's move on to the verification service.
@Service
class VerificationCodeServiceImpl(
private val emailCommunicationStrategy: EmailCommunicationStrategy,
private val smsCommunicationStrategy: SmsCommunicationStrategy
) {
private val verificationCodeStrategyMap: Map<CommunicationMethod, VerificationCodeCommunicationStrategy> = mapOf(
CommunicationMethod.Email to emailCommunicationStrategy,
CommunicationMethod.SMS to smsCommunicationStrategy
)
fun sendVerificationCode(user: User) {
val code = generateVerificationCode()
val verificationCodeStrategy = verificationCodeStrategyMap[user.preferredCommunicationMethod]
verificationCodeStrategy?.sendVerificationCode(user, code)
}
}
We need to add the strategy implementations to the constructor and build the map manually. That's no fun!
Code Example WITH the library
The entire objective of the library is to make our life easier and the code cleaner and less bug prone. It does it by simply building the strategy map automatically.
We will start by modifying the strategy interface.
interface VerificationCodeCommunicationStrategy {
@get:ComponentMapKey
val method: CommunicationMethod
fun sendVerificationCode(user: User, code: String)
}
As you can see we have added a new field method
with the annotation @ComponentMapKey
. This informs the library which field is the key for the strategy map.
Let's update the implementations according to the modified interface.
@Component
class EmailCommunicationStrategy : VerificationCodeCommunicationStrategy {
override val method = CommunicationMethod.Email
override fun sendVerificationCode(user: User, code: String) {
emailClient.sendEmail(user.email, "Verification code", "Your verification is $code.")
}
}
@Component
class SmsCommunicationStrategy : VerificationCodeCommunicationStrategy {
override val method = CommunicationMethod.SMS
override fun sendVerificationCode(user: User, code: String) {
smsClient.sendSMS(user.phone, "Your verification is $code.")
}
}
// ...
Now let's modify the service to use the library.
@Service
class VerificationCodeServiceImpl {
@ComponentMap
private lateinit var verificationCodeStrategyMap: Map<CommunicationMethod, VerificationCodeCommunicationStrategy>
fun sendVerificationCode(user: User) {
val code = generateVerificationCode()
val verificationCodeStrategy = verificationCodeStrategyMap[user.preferredCommunicationMethod]
verificationCodeStrategy?.sendVerificationCode(user, code)
}
}
That's it! All we had to do is to add the @ComponentMap
annotation to the strategy map and voila! The map is built automatically from the components. No boiler-plate code. To add a new strategy all you need to do is to create a new implementation and it will be "magically" added to all relevant strategy maps.
Final Words
This library is the most used library in our projects. Although it's not life changing, it's just useful, helps us keep our code clean and prevents lots of recurring bugs from forgetting to add new strategies to their appropriate maps.
If you would like to use it you can start with the quick start guide.
We develop open source because we love it and want to contribute to the cause. If you would like to support us please consider starring the repository.