Ease Your Life With Strategy Pattern - Spring Boot & Kotlin
Whenever a product manager says, “We want this to do ‘x’, but ‘x’ is not fully defined and may change in future”, panic no more, strategy to the rescue!
Strategy is a behavioral design pattern that lets you define one interface with many concrete implementations (i.e. algorithms), with each encapsulated in a separate class. By using composition over inheritance, strategy clients couple themselves to an interface and know nothing about the concrete algorithm, thus allowing them to be used interchangeably and easily be replaced at runtime.
Alright, having these fancy words said, let’s dive into short code examples to understand what these words mean! We’ll begin with naive implementation, and we’ll then see some of the strategy magic by applying it to a Service
bean.
Order DeliveryService Validation
In our example, we will have four different order types: TypeA
, TypeB
, TypeC
, and TypeD
. Each has a different delivery validation concern.
Now, imagine that we have a simple order DeliveryService
that has two methods:
deliver
- Logic is shared among all order types.validateDelivery
- Logic depends on order type.
First Solution: Switch Case
Pretty simple, right? Let’s examine the pros and cons.
Pros:
- No Overhead - If a change in logic is not expected, then this solution is pretty straightforward.
Cons:
- Code Readability - Imagine that we have twenty different order types, the
when(order) {...}
statement will involve a significant amount of logic. - Hard to Maintain - Each new order type will force a change in
DeliveryService
logic and tests. The same applies for a change in validation algorithm.
Second Solution: Abstract Class & Inheritance
Let’s define the abstract DeliveryService
class:
deliver
Logic is shared among all order types, thus the implementation will be in the abstract class.validateDelivery
Logic depends on order type, thus it will be declared as abstract.
Each order type will have a corresponding delivery service with concrete validateDelivery
implementation.
Pros:
- Open to Expansion - On new order type, we simply need to implement new delivery service.
- Better Code Readability - There is no
when(order) {...}
statement.
Cons:
- Code Duplication -
TypeA
andTypeB
delivery services share the same validation logic. - Hard to Maintain - If the validation logic changes, we need to change the logic in all delivery services that share it.
- When Statement - We probably just moved the
when(order) {...}
statement toDeliveryService
clients, as they should now choose whichDeliveryService
to use.
Third Solution: Composition Over Inheritance!
Let’s encapsulate what varies and define a new DeliveryValidator
interface with two methods: shouldValidate
and validate
.
Each validation algorithm will have a dedicated class that implements DeliveryValidator
interface and encapsulate its logic.
Inject all DeliveryValidator
interface implementations into DeliveryService
.
Pros:
- Easy to Maintain - Each validation logic is centralized to a single class, and is easy to modify.
- Easy to Expand - To introduce a new validation we just need to create a new class that implements the
DeliveryValidator
interface. - Easy to Tests - Validation tests are agnostic to each other and to
DeliveryService
tests. - No Coupling - Clients are coupled to the interface and know nothing about the concrete validation algorithm.
- Easy to Reuse - Validations can be used by other clients in the system.
- Easy to Change Behavior - Clients can replace the validation algorithm at runtime.
- Better Code Readability - There is no
when(order) { ... }
statement.
Cons:
- Can Add Overhead - If a logic change is not expected, using strategy may be redundant.