Skip to content
ioob.dev
Go back

Design Patterns Part 2 — Strategy and State

· 7 min read
Design Pattern Series (2/8)
  1. Design Patterns Part 1 — Why Learn Patterns
  2. Design Patterns Part 2 — Strategy and State
  3. Design Patterns Part 3 — Observer and Command
  4. Design Patterns Part 4 — Template Method and Chain of Responsibility
  5. Design Patterns Part 5 — Factory Method, Abstract Factory, Builder
  6. Design Patterns Part 6 — Decorator and Proxy
  7. Design Patterns Part 7 — Adapter, Facade, Composite: Patterns for Organizing Structure
  8. Design Patterns Part 8 — Singleton, Iterator, Prototype: Frequently Used but Often Misunderstood
Table of contents

Table of contents

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.

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.

CriterionStrategyState
Core questionWhich algorithm to use?What is the current state?
Who swaps the implementationClient (external) selects the strategyState object (internal) transitions itself
When swapping occursWhenever the client wantsAutomatically when a certain condition is met
Relationship between implementationsIndependent of each other (KakaoPay and NaverPay are unrelated)Connected to each other (Created -> Paid -> Shipping)
Typical examplesPayment method selection, sorting algorithm, compression typeOrder 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


In the next part, we look at the Observer pattern for loosely coupling objects, and the Command pattern for encapsulating requests as objects.

-> Part 3: Observer and Command


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Design Patterns Part 1 — Why Learn Patterns
Next Post
Design Patterns Part 3 — Observer and Command