Stop Writing Massive when Statements: Master the State Pattern in Kotlin
Tame Your Codebase with Elegant, Scalable, and Type-Safe State Machines
Do you manage complex objects that change their behavior dramatically based on their internal status? If so, you’ve likely battled a common foe: the sprawling when statement that haunts every method in your class.
It looks something like this:
fun handleAction() {
when (state) {
State.A -> { /* logic for A */ }
State.B -> { /* logic for B */ }
State.C -> { /* logic for C */ }
// ... and the list keeps growing!
}
}Every time you add a new state (e.g., State.D), you have to hunt down and update every single function with a new case. This is a maintenance nightmare, a direct violation of the Open/Closed Principle (OCP), and a major code smell. Your logic is scattered by action when it should be grouped by state.
The solution? The State Pattern. And with modern Kotlin, we can implement it with minimal boilerplate and maximum elegance.
The Problem in Action: A Simple Vending Machine
Let’s imagine we are building a simple VendingMachine. It has a few key states and actions:
- States:
IDLE,HAS_MONEY,OUT_OF_STOCK. - Actions:
insertMoney,selectProduct,requestRefund.
Here’s how the problematic, state-checking code looks:
class VendingMachine(private var balance: Int = 0) {
// The state is managed by a simple enum
enum class Status { IDLE, HAS_MONEY, OUT_OF_STOCK }
var machineStatus: Status = Status.IDLE
fun insertMoney(amount: Int) {
// ❌ Problem: The core logic is duplicated here based on state
when (machineStatus) {
Status.IDLE, Status.HAS_MONEY -> {
balance += amount
machineStatus = Status.HAS_MONEY
println("-> Accepted $amount. Balance: $balance.")
}
Status.OUT_OF_STOCK -> {
println("-> ERROR: Out of stock. Refunding $amount.")
}
}
}
fun selectProduct(code: String) {
// ❌ Problem: This 'when' block has to be maintained separately
when (machineStatus) {
Status.HAS_MONEY -> {
// ... successful dispensing logic here ...
println("-> SUCCESS: Dispensing product $code.")
machineStatus = Status.IDLE
}
Status.IDLE -> {
println("-> ERROR: Please insert money first.")
}
Status.OUT_OF_STOCK -> {
println("-> ERROR: Machine is out of order.")
}
}
}
// Imagine 'requestRefund', 'serviceMachine', etc., all with the same 'when' block...
}If we add a new MAINTENANCE state, we break the OCP because we must edit insertMoney, selectProduct, and every other action method.
Phase 1: The Classic State Pattern (Gang of Four)
The State Pattern solves this by making the state an object that holds the behavior. We want to group the behavior by state, not by action.
1. Define the State Interface
We define a common interface (MachineState) that all our state objects must implement. This interface contains every possible action the Context (VendingMachineContext) can perform.
// 1. The State Interface defines all possible actions
interface MachineState {
// The machine context must be passed so the state can read/write data and trigger transitions
fun handleInsertMoney(amount: Int, context: VendingMachineContext)
fun handleSelectProduct(code: String, context: VendingMachineContext)
fun handleRefund(context: VendingMachineContext)
}2. The Concrete States
We implement the behavior for each state. The key takeaway: all logic for a single state is now in one class/object.
// 2. Concrete State: Idle
object IdleState : MachineState {
override fun handleInsertMoney(amount: Int, context: VendingMachineContext) {
context.balance += amount
// ✅ The state object itself controls the transition!
context.transitionTo(HasMoneyState)
println("IDLE -> HAS_MONEY: Accepted $amount.")
}
override fun handleSelectProduct(code: String, context: VendingMachineContext) {
println("IDLE: ERROR. Insert money first.")
}
override fun handleRefund(context: VendingMachineContext) {
println("IDLE: Nothing to refund.")
}
}
// 3. Concrete State: Has Money
object HasMoneyState : MachineState {
override fun handleInsertMoney(amount: Int, context: VendingMachineContext) {
context.balance += amount
println("HAS_MONEY: Added $amount. Total: ${context.balance}.")
// Stays in the current state
}
override fun handleSelectProduct(code: String, context: VendingMachineContext) {
if (context.balance >= 2) { // Example: check price
context.balance -= 2
println("HAS_MONEY -> IDLE: Product $code dispensed.")
context.transitionTo(IdleState)
} else {
println("HAS_MONEY: ERROR. Need more money.")
}
}
override fun handleRefund(context: VendingMachineContext) {
println("HAS_MONEY -> IDLE: Refunding ${context.balance}.")
context.balance = 0
context.transitionTo(IdleState)
}
}3. The Context (The Machine)
The VendingMachineContext (the Context) is now clean and simply delegates the action to its current state object.
// 4. The Context (The Vending Machine)
class VendingMachineContext(var balance: Int = 0) {
// The current state
var currentState: MachineState = IdleState
private set
// ✅ Delegation is now trivial and stateless
fun insertMoney(amount: Int) = currentState.handleInsertMoney(amount, this)
fun selectProduct(code: String) = currentState.handleSelectProduct(code, this)
fun requestRefund() = currentState.handleRefund(this)
// Helper function for state objects to transition the machine
fun transitionTo(newState: MachineState) {
this.currentState = newState
// Note: In production code, logging/side effects from transitions
// should typically be handled by a dedicated Observer or Hook.
}
}If you add a new state, you only create one new class. You do not touch the VendingMachineContext or any of the other state classes. The OCP is restored!
Phase 2: The Enhanced Kotlin-Idiomatic State
The classic pattern is solid, but Kotlin gives us an even more robust and elegant way to structure it using Sealed Interfaces to enforce type-safe commands.
1. Define All Inputs (Commands)
We use a sealed interface to model all possible inputs (or Commands) the machine can receive.
// A Sealed Interface for all machine inputs (Commands)
sealed interface VendingInput {
data class InsertMoney(val amount: Int) : VendingInput
data object SelectProduct : VendingInput
data object RequestRefund : VendingInput
}2. Define the State and Context
The state interface now accepts a single VendingInput. The Context has a single public process method.
// 1. The Modern State Interface
sealed interface VendingState {
// A single method handles all possible inputs based on the current state
fun handleInput(context: VendingMachineContext, input: VendingInput)
}
// 2. The Context Class (almost identical to Phase 1)
class VendingMachineContext(var balance: Int = 0) {
var currentState: VendingState = IdleState
private set
// Public API: A single entry point for all actions
fun process(input: VendingInput) {
currentState.handleInput(this, input)
}
fun transitionTo(newState: VendingState) {
this.currentState = newState
println("DEBUG: Transitioned to: $newState")
}
}3. The Behavior (In the States)
The state object uses a single when statement on the Input type. Because VendingInput is sealed, Kotlin forces us to handle all possible inputs—making our code exhaustive and safe!
// State 1: Idle
object IdleState : VendingState {
override fun handleInput(context: VendingMachineContext, input: VendingInput) {
when (input) {
is VendingInput.InsertMoney -> {
context.balance += input.amount
context.transitionTo(HasMoneyState)
}
// ✅ Compiler ensures all inputs are handled
VendingInput.SelectProduct -> println("Error: Insert money first.")
VendingInput.RequestRefund -> println("Nothing to refund.")
}
}
override fun toString() = "IDLE"
}
// State 2: Has Money
object HasMoneyState : VendingState {
override fun handleInput(context: VendingMachineContext, input: VendingInput) {
when (input) {
is VendingInput.InsertMoney -> {
context.balance += input.amount
println("Added $${input.amount}. Balance: ${context.balance}")
}
VendingInput.SelectProduct -> {
// ... logic to check stock/price ...
context.balance -= 2
context.transitionTo(IdleState)
}
VendingInput.RequestRefund -> {
context.balance = 0
context.transitionTo(IdleState)
}
}
}
override fun toString() = "HAS_MONEY"
}Next Level: Pure Functional State Transitions
For truly robust systems (like MVI or Event Sourcing), you might want to avoid mutation entirely. You can achieve this by having your state functions return the next state, making the system highly deterministic:
// Pure Function State
sealed interface PureVendingState {
// The function takes the current state and input, and returns the next state (or an error)
fun nextState(input: VendingInput): PureVendingState
// ... logic for actions that don't change state
}
// The Context then manages the state property externally:
// state = state.nextState(input)While more complex, this approach is common in reactive architectures where state changes are events that must be tracked precisely.
Frequently Asked Questions (FAQs)
When is the State Pattern necessary, and when is it overkill?
Use the State Pattern when an object’s behavior changes dramatically, and you have conditional logic (when, if/else) for the state spread across multiple methods. If you only have one method with a large when statement, a simple when on a sealed class might suffice. However, if you find yourself writing the same when(state) block in methodA(), methodB(), and methodC(), the State Pattern is the right tool.
How is the State Pattern different from the Strategy Pattern?
They look similar because both use delegation, but their intent is different:
- Strategy Pattern is for interchangeable algorithms (e.g., different sorting methods). The client chooses the strategy.
- State Pattern is for interchangeable behaviors tied to a life cycle (e.g.,
Idlevs.HasMoney). The state object often controls the flow and triggers the transition to the next state.
Why not just use a standard enum class instead of an object for each state?
While Kotlin enums can define behavior per entry, they become rigid and hard to extend. For instance, adding a new action method requires modifying the enum definition itself. The polymorphic object/interface approach scales much better, upholding the Open/Closed Principle. If you need to add a state or action, you extend (add a new state class) rather than modify (change the central enum file).
Conclusion
By applying the State Pattern, we have successfully replaced scattered, fragile conditional logic with clean, cohesive state objects. This makes our VendingMachine (the Context) stable and our code for new states easy to write, test, and maintain.
If your Kotlin codebase is suffering from the “massive when statement disease," give the State Pattern a try. Your future self (and your teammates) will thank you.
What’s Your Take?
- Do you prefer the classic GoF approach (Phase 1) or the modern Kotlin-idiomatic approach using sealed interfaces and commands (Phase 2) for your state machines?
- What is the most complex state machine you’ve ever implemented in a real-world application?
If you find my content useful, feel free to support my work here. Every contribution is greatly appreciated! 🙏

Comments
Post a Comment