Table of contents
- The Moment if-else Starts Growing
- Structure of the Strategy Pattern
- Implementing the Strategy Pattern
- Practical Uses of the Strategy Pattern
- The State Pattern — Same Structure, Different Intent
- Implementing the State Pattern
- Strategy vs State — Decision Criteria
- Practical Pitfalls
- Key Takeaways
The Moment if-else Starts Growing
Suppose you are building a payment feature. At first, you only need to support credit cards. But next month KakaoPay gets added, then NaverPay the month after, and then TossPay requirements keep pouring in.
// Typical code where conditional branches keep growing
public class PaymentService {
public void pay(String method, int amount) {
if (method.equals("CREDIT_CARD")) {
// Credit card payment logic (20 lines)
} else if (method.equals("KAKAO_PAY")) {
// KakaoPay payment logic (25 lines)
} else if (method.equals("NAVER_PAY")) {
// NaverPay payment logic (30 lines)
} else if (method.equals("TOSS_PAY")) {
// TossPay payment logic (15 lines)
}
// What gets added next month?
}
}
This code has three problems.
First, every time a new payment method is added, this method must be modified. This directly violates the OCP (Open-Closed Principle — open for extension, closed for modification).
Second, as the logic in each branch grows, the method balloons to hundreds of lines. During code review, someone will inevitably comment “shouldn’t we split this method up?”
Third, testing is cumbersome. You want to test only the KakaoPay logic, but you have to instantiate the entire PaymentService. Changing one branch might also break another.
The Strategy pattern tackles this problem head-on.
Structure of the Strategy Pattern
The core idea of the Strategy pattern is simple. Separate the varying part into an interface and swap implementations.
Let us look at the three roles that participate in the pattern.
- Strategy: The common interface for algorithms. Defines only the behavior “make a payment”
- ConcreteStrategy: A concrete implementation of the Strategy interface. Credit card payment, KakaoPay payment, etc.
- Context: The side that uses the Strategy. It does not know which strategy will be provided, but only needs to know the Strategy interface
classDiagram
class Context {
-strategy: Strategy
+setStrategy(Strategy)
+executeStrategy()
}
class Strategy {
<<interface>>
+execute()
}
class ConcreteStrategyA {
+execute()
}
class ConcreteStrategyB {
+execute()
}
class ConcreteStrategyC {
+execute()
}
Context --> Strategy : uses
Strategy <|.. ConcreteStrategyA
Strategy <|.. ConcreteStrategyB
Strategy <|.. ConcreteStrategyC
Note the direction of the arrows. The Context does not know the concrete strategy classes. It depends only on the Strategy interface. When a new payment method is added, you just create one more ConcreteStrategy. Not a single line of Context code needs to change.
Implementing the Strategy Pattern
Let us refactor the earlier if-else code using the Strategy pattern. First, define the payment strategy interface.
// Interface abstracting payment behavior
interface PaymentStrategy {
fun pay(amount: Int): PaymentResult
}
data class PaymentResult(
val success: Boolean,
val transactionId: String,
val message: String
)
Separate each payment method into its own implementation.
// Credit card payment strategy
class CreditCardPayment(
private val cardNumber: String,
private val expiryDate: String
) : PaymentStrategy {
override fun pay(amount: Int): PaymentResult {
// Call card company API, request authorization, etc.
println("Requesting payment of $amount via credit card $cardNumber")
return PaymentResult(
success = true,
transactionId = "CC-${System.currentTimeMillis()}",
message = "Credit card payment complete"
)
}
}
// KakaoPay payment strategy
class KakaoPayPayment(
private val kakaoToken: String
) : PaymentStrategy {
override fun pay(amount: Int): PaymentResult {
// Call KakaoPay SDK
println("Requesting payment of $amount via KakaoPay")
return PaymentResult(
success = true,
transactionId = "KP-${System.currentTimeMillis()}",
message = "KakaoPay payment complete"
)
}
}
// NaverPay payment strategy
class NaverPayPayment(
private val naverToken: String
) : PaymentStrategy {
override fun pay(amount: Int): PaymentResult {
println("Requesting payment of $amount via NaverPay")
return PaymentResult(
success = true,
transactionId = "NP-${System.currentTimeMillis()}",
message = "NaverPay payment complete"
)
}
}
The PaymentService, acting as the Context, simply accepts and executes any strategy.
// Context: executes any strategy in the same way
class PaymentService(
private var strategy: PaymentStrategy
) {
fun changeStrategy(newStrategy: PaymentStrategy) {
this.strategy = newStrategy
}
fun processPayment(amount: Int): PaymentResult {
println("Starting payment processing: $amount")
val result = strategy.pay(amount)
if (result.success) {
println("Transaction ID: ${result.transactionId}")
}
return result
}
}
The client code looks like this.
fun main() {
// Pay with credit card
val service = PaymentService(CreditCardPayment("1234-5678", "12/25"))
service.processPayment(50000)
// Swap strategy: switch to KakaoPay
service.changeStrategy(KakaoPayPayment("kakao-token-abc"))
service.processPayment(30000)
}
PaymentService only knows the PaymentStrategy interface. Even if TossPay is added, you only need to create a TossPayPayment class. No existing code needs modification.
Practical Uses of the Strategy Pattern
Beyond payments, there are many places where the Strategy pattern is frequently used: swapping sorting algorithms, choosing file compression methods, switching authentication mechanisms, and more.
A useful pattern in practice is managing strategies with an enum or Map, which allows strategy selection without conditional branching.
// A practical pattern for managing strategies with a Map
class PaymentStrategyFactory {
private val strategies = mutableMapOf<String, PaymentStrategy>()
fun register(type: String, strategy: PaymentStrategy) {
strategies[type] = strategy
}
fun getStrategy(type: String): PaymentStrategy =
strategies[type] ?: throw IllegalArgumentException("Unsupported payment method: $type")
}
// Register at application startup
val factory = PaymentStrategyFactory()
factory.register("CREDIT_CARD", CreditCardPayment("1234-5678", "12/25"))
factory.register("KAKAO_PAY", KakaoPayPayment("kakao-token"))
factory.register("NAVER_PAY", NaverPayPayment("naver-token"))
// Retrieve a strategy by string key at runtime
val strategy = factory.getStrategy("KAKAO_PAY")
strategy.pay(10000)
If you are using the Spring Framework, it gets even simpler. Register each strategy as a @Component and inject them via List<PaymentStrategy>. The framework naturally supports the Strategy pattern.
The State Pattern — Same Structure, Different Intent
Remember the class diagram of the Strategy pattern? A Context depends on an interface and swaps implementations. The State pattern’s diagram looks almost identical. Yet the problem it solves is entirely different.
Strategy determines “which algorithm to use” from the outside. The client selects a strategy and injects it into the Context. State, on the other hand, expresses the idea that “the same request behaves differently depending on the current state.” State transitions happen internally within the object.
Consider order status as an example. An order passes through states like Created, Paid, Shipping, and Delivered. The same cancel() request behaves differently depending on the state. In the Paid state, a refund is possible. During Shipping, a retrieval process is required. After Delivered, it must be handled as a return.
stateDiagram-v2
[*] --> Created : Order created
Created --> Paid : Payment complete
Created --> Cancelled : Order cancelled
Paid --> Shipping : Shipping started
Paid --> Cancelled : Payment cancelled + refund
Shipping --> Delivered : Delivery complete
Shipping --> Cancelled : Shipment recalled + refund
Delivered --> [*]
Cancelled --> [*]
Let us see what implementing this with if-else looks like.
// Code that branches by state — grows more complex as states increase
public class Order {
private String state = "CREATED";
public void cancel() {
if (state.equals("CREATED")) {
state = "CANCELLED";
System.out.println("Order cancelled");
} else if (state.equals("PAID")) {
refund();
state = "CANCELLED";
System.out.println("Payment cancelled + refund");
} else if (state.equals("SHIPPING")) {
recall();
refund();
state = "CANCELLED";
System.out.println("Shipment recalled + refund");
} else if (state.equals("DELIVERED")) {
throw new IllegalStateException("After delivery, it must be handled as a return");
}
}
// Similar branching repeats in ship(), deliver() methods...
}
This is just cancel(), yet ship(), deliver(), and refund() methods all repeat similar state-based branching. Adding a new state means modifying every method.
Implementing the State Pattern
The State pattern separates each state into its own class. Behavior for each state is encapsulated within the corresponding state class.
// Order state interface: defines possible actions in each state
interface OrderState {
fun cancel(order: Order)
fun ship(order: Order)
fun deliver(order: Order)
fun getStateName(): String
}
Let us implement each state. First, the Created state.
// Created state: only cancellation is allowed; shipping and delivery are not
class CreatedState : OrderState {
override fun cancel(order: Order) {
println("Order has been cancelled")
order.changeState(CancelledState())
}
override fun ship(order: Order) {
throw IllegalStateException("Cannot ship an order that has not been paid for")
}
override fun deliver(order: Order) {
throw IllegalStateException("Cannot mark as delivered when not in shipping state")
}
override fun getStateName() = "CREATED"
}
In the Paid state, cancellation includes a refund.
// Paid state: shipping or cancellation (with refund) is possible
class PaidState : OrderState {
override fun cancel(order: Order) {
println("Processing payment cancellation and refund")
order.refund()
order.changeState(CancelledState())
}
override fun ship(order: Order) {
println("Starting shipment")
order.changeState(ShippingState())
}
override fun deliver(order: Order) {
throw IllegalStateException("Cannot mark as delivered when shipping has not started")
}
override fun getStateName() = "PAID"
}
The Shipping and Delivered states follow the same approach.
// Shipping state: delivery completion or recall (cancellation) is possible
class ShippingState : OrderState {
override fun cancel(order: Order) {
println("Requesting shipment recall and processing refund")
order.recall()
order.refund()
order.changeState(CancelledState())
}
override fun ship(order: Order) {
throw IllegalStateException("Already in shipping state")
}
override fun deliver(order: Order) {
println("Delivery has been completed")
order.changeState(DeliveredState())
}
override fun getStateName() = "SHIPPING"
}
// Delivered state: no further state transitions
class DeliveredState : OrderState {
override fun cancel(order: Order) {
throw IllegalStateException("After delivery, it must be handled as a return")
}
override fun ship(order: Order) {
throw IllegalStateException("Already delivered")
}
override fun deliver(order: Order) {
throw IllegalStateException("Already delivered")
}
override fun getStateName() = "DELIVERED"
}
// Cancelled state: all actions are blocked
class CancelledState : OrderState {
override fun cancel(order: Order) {
throw IllegalStateException("Order is already cancelled")
}
override fun ship(order: Order) {
throw IllegalStateException("Cannot ship a cancelled order")
}
override fun deliver(order: Order) {
throw IllegalStateException("Cannot mark a cancelled order as delivered")
}
override fun getStateName() = "CANCELLED"
}
The Order class, acting as the Context, delegates all behavior to its current state.
// Context: delegates all behavior to the current state
class Order(val orderId: String) {
private var state: OrderState = CreatedState()
fun changeState(newState: OrderState) {
println("[$orderId] State transition: ${state.getStateName()} -> ${newState.getStateName()}")
this.state = newState
}
fun cancel() = state.cancel(this)
fun ship() = state.ship(this)
fun deliver() = state.deliver(this)
fun refund() {
println("[$orderId] Processing refund")
}
fun recall() {
println("[$orderId] Requesting shipment recall")
}
fun getCurrentState(): String = state.getStateName()
}
Let us see a usage example.
fun main() {
val order = Order("ORD-001")
order.changeState(PaidState()) // Transition to Paid
order.ship() // Start shipping
order.deliver() // Complete delivery
println("Final state: ${order.getCurrentState()}")
// [ORD-001] State transition: CREATED -> PAID
// Starting shipment
// [ORD-001] State transition: PAID -> SHIPPING
// Delivery has been completed
// [ORD-001] State transition: SHIPPING -> DELIVERED
// Final state: DELIVERED
}
Comparing this with the if-else code, the difference is clear. When a new state is added, you just create a new state class. Existing state classes remain untouched. Each state’s behavior is cohesive within its own class, so the question “what happens if you cancel during shipping?” is answered by simply opening ShippingState.
Strategy vs State — Decision Criteria
The class diagrams of the two patterns are effectively identical. A Context depends on an interface and swaps implementations. It is natural to be confused at first. The crucial difference lies in who changes the implementation.
| Criterion | Strategy | State |
|---|---|---|
| Core question | Which algorithm to use? | What is the current state? |
| Who swaps the implementation | Client (external) selects the strategy | State object (internal) transitions itself |
| When swapping occurs | Whenever the client wants | Automatically when a certain condition is met |
| Relationship between implementations | Independent of each other (KakaoPay and NaverPay are unrelated) | Connected to each other (Created -> Paid -> Shipping) |
| Typical examples | Payment method selection, sorting algorithm, compression type | Order status, TCP connection state, game character state |
Here are useful questions to ask when deciding.
“Is there a transition order between the implementations?”
If yes, it is State. An order cannot jump from Created directly to Delivered; it must go through Paid -> Shipping. If no, it is Strategy. There is no ordering constraint when switching from credit card to KakaoPay.
“Who decides to swap the implementation?”
If the decision is made externally, it is Strategy — like a user selecting “KakaoPay” on the payment screen. If it changes automatically based on internal logic, it is State — like transitioning to the Paid state automatically when payment completes.
Practical Pitfalls
Strategy’s Trap
Applying the Strategy pattern makes each strategy an independent class, which is great for testing and extension. But when strategies grow too numerous, management becomes difficult. “There are 30 strategies, and to know which one to use, you still need conditional branching somewhere” — this problem inevitably arises.
In such cases, the answer is not to abandon the Strategy pattern, but to separate the strategy selection logic. The PaymentStrategyFactory shown earlier is one example. Centralizing strategy creation and selection in one place reduces management overhead.
State’s Trap
With the State pattern, watch out for transition rules being scattered across multiple state classes. The rule for transitioning from PaidState to ShippingState lives inside PaidState, and the rule for ShippingState to DeliveredState lives inside ShippingState. To see all transition rules at a glance, you have to open every state class.
If states are numerous and transition rules are complex, you might consider a separate transition table that manages state transitions in one place.
// Managing state transition rules in one place
enum class OrderStatus { CREATED, PAID, SHIPPING, DELIVERED, CANCELLED }
enum class OrderEvent { PAY, SHIP, DELIVER, CANCEL }
val transitions: Map<Pair<OrderStatus, OrderEvent>, OrderStatus> = mapOf(
(OrderStatus.CREATED to OrderEvent.PAY) to OrderStatus.PAID,
(OrderStatus.CREATED to OrderEvent.CANCEL) to OrderStatus.CANCELLED,
(OrderStatus.PAID to OrderEvent.SHIP) to OrderStatus.SHIPPING,
(OrderStatus.PAID to OrderEvent.CANCEL) to OrderStatus.CANCELLED,
(OrderStatus.SHIPPING to OrderEvent.DELIVER) to OrderStatus.DELIVERED,
(OrderStatus.SHIPPING to OrderEvent.CANCEL) to OrderStatus.CANCELLED,
)
fun transition(current: OrderStatus, event: OrderEvent): OrderStatus =
transitions[current to event]
?: throw IllegalStateException("Event $event is not allowed in $current state")
This approach is not a pure State pattern, but it has the advantage of making all transition rules visible in one place. If behavior per state is complex, use the State pattern. If transition rules are complex, use a transition table. If both are complex, mix them. The right answer depends on the situation.
Key Takeaways
- The Strategy pattern abstracts algorithms behind an interface and makes implementations swappable. It eliminates if-else branches and is a quintessential way to realize OCP
- The State pattern separates an object’s states into individual classes, solving the problem of behavior varying by state
- The two patterns have nearly identical class diagrams but differ in intent. Strategy selects algorithms externally, while State transitions automatically internally
- Strategy implementations have no ordering between them; State implementations have defined transition sequences
- Both patterns are appropriate to consider adopting when conditional branches start multiplying
In the next part, we look at the Observer pattern for loosely coupling objects, and the Command pattern for encapsulating requests as objects.




Loading comments...