In the world of software development, there often arises a need to handle business logic depending on the values of input parameters. At first glance, one could address this requirement using simple if-else
or when
constructs, but such implementations can lead to code that is difficult to read and maintain. It conflicts with the principles of object-oriented programming, and extending this logic often requires modifying existing code. In this article, we will explore how to effectively implement the Strategy design pattern in the Kotlin programming language using the Spring framework. This approach allows us to create easily extensible and maintainable applications while adhering to SOLID principles.
The Strategy pattern is a behavioral design pattern that enables selecting an algorithm’s behavior at runtime. It defines a family of algorithms, encapsulates each one, and makes them interchangeable. The Strategy pattern promotes the Open/Closed Principle by allowing new strategies to be added without modifying existing code, which aligns perfectly with the goals of clean and maintainable software architecture.
if-else
Consider the following code, which is an example of an anti-pattern that uses when
to handle different screens:
fun getScreen(viewId: String): ScreenResponse {
when (viewId) {
"cardScreen" -> TODO("Implement logic for cardScreen")
"infoScreen" -> TODO("Implement logic for infoScreen")
"accountScreen" -> TODO("Implement logic for accountScreen")
else -> throw RuntimeException("Filler for $viewId not found")
}
}
As we can see, this code can quickly grow in complexity, making it difficult to manage and expand. When a new screen needs to be added, the developer is forced to modify existing code, which violates the Open/Closed Principle (OCP) from the SOLID design principles—software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
A more elegant solution is to use the Strategy pattern. We can create several services, each responsible for a specific screen. This allows us to add new screens by simply creating new services while leaving the existing code unchanged.
Let’s consider a microservice that returns an SDUI (Server-Driven User Interface) screen to the front end. The client requests the necessary screen by its identifier, and the server returns the corresponding business data.
@RestController
class ScreenController(
private val screenService: ScreenService,
) {
@GetMapping
fun getScreen(@RequestParam viewId: String): ScreenResponse = screenService.getScreen(viewId)
}
To implement the Strategy pattern, we begin by defining an interface Filler
, which will contain a method for filling the screen:
interface Filler {
fun fill(): ScreenResponse
}
Next, we create classes that implement this interface for each screen:
@Service
class CardFiller : Filler {
override fun fill(): ScreenResponse {
return ScreenResponse("Data for the card screen") // Implement logic for the card screen
}
}
@Service
class AccountFiller : Filler {
override fun fill(): ScreenResponse {
return ScreenResponse("Data for the account screen") // Implement logic for the account screen
}
}
@Service
class InfoFiller : Filler {
override fun fill(): ScreenResponse {
return ScreenResponse("Data for the owner information screen") // Implement logic for the info screen
}
}
The main service ScreenService
, which will use our concrete strategies, looks as follows. We will store them in a Map
, where the key is the screen identifier and the value is the strategy object:
@Service
class ScreenService(
private val fillerMap: Map<String, Filler> // Injection of all implementations of the Filler interface
) {
fun getScreen(viewId: String): ScreenResponse {
val filler = fillerMap[viewIDToFiller[viewId]]
return filler?.fill() ?: throw RuntimeException("Filler for $viewId not found")
}
private companion object {
val viewIDToFiller = mapOf(
"cardScreen" to "cardFiller",
"infoScreen" to "infoFiller",
"accountScreen" to "accountFiller"
)
}
}
In this code, we inject all implementations of the Filler
interface into the ScreenService
. We use a Map
for quick access to the desired strategy using the screen identifier as the key. This makes it very easy to add new strategies: just create a new class that implements the Filler
interface and add a corresponding entry in viewIDToFiller
.
By employing the Strategy pattern, we achieve a high degree of modularity and separation of concerns, allowing for easier testing, maintenance, and future expansion of functionality. Each strategy is isolated in its own class, which makes it simpler to change or add functionality without affecting other parts of the application. This results in a more robust and adaptable codebase.
Utilizing the Strategy pattern in conjunction with powerful tools like Kotlin and Spring allows for a significant simplification of code while enhancing its flexibility and extensibility. Instead of manipulating complex if-else
or when
constructs, we organize business logic into separate services, each responsible for its part of the application.
Thus, when adding a new screen, we only need to create a new service and make minimal changes to the existing infrastructure. This practice aligns with object-oriented programming principles and promotes the development of cleaner, more maintainable code. Considering all the flexibility provided by Spring, the implementation of the Strategy pattern becomes not only straightforward but also an effective practice for creating high-quality software solutions.