Gain Clarity on the State Design Pattern in Kotlin: A Step-by-Step Guide
Have you ever had to manage an object’s behavior based on its state? You might have ended up writing a series of if-else or when statements to handle different scenarios. Sound familiar? (Especially if you’re working with Android and Kotlin!) If so, it’s time to explore the State Design Pattern—a structured approach to simplify your code, enhance modularity, and improve maintainability.
In this blog, we’ll explore the State Design Pattern in depth, focusing on its use in Kotlin. We’ll discuss its purpose, the benefits it offers, and provide detailed examples with clear explanations. By the end, you’ll have the knowledge and confidence to incorporate it seamlessly into your projects.
State Design Pattern
The State Design Pattern is part of the behavioral design patterns group, focusing on managing an object’s dynamic behavior based on its current state. As described in the Gang of Four’s book, this pattern “enables an object to modify its behavior as its internal state changes, giving the impression that its class has changed.” In short, it allows an object to alter its behavior depending on its internal state.
Key Features of the State Pattern
- State Encapsulation: Each state is encapsulated in its own class.
- Behavioral Changes: Behavior changes dynamically as the object’s state changes.
- No Conditionals: It eliminates long
if-else
orwhen
chains by using polymorphism.
Structure of the State Design Pattern
State pattern encapsulates state-specific behavior into separate classes and delegates state transitions to these objects. Here’s a breakdown of its structure:
State Interface
The State Interface defines the methods that each state will implement. It provides a common contract for all concrete states.
interface State {
fun handle(context: Context)
}
Here,
- The
State
interface declares a single method,handle(context: Context)
, which theContext
calls to delegate behavior. - Each concrete state will define its behavior within this method.
Concrete States
The Concrete States implement the State
interface. Each represents a specific state and its associated behavior.
class ConcreteStateA : State {
override fun handle(context: Context) {
println("State A: Handling request and transitioning to State B")
context.setState(ConcreteStateB()) // Transition to State B
}
}
class ConcreteStateB : State {
override fun handle(context: Context) {
println("State B: Handling request and transitioning to State A")
context.setState(ConcreteStateA()) // Transition to State A
}
}
ConcreteStateA
andConcreteStateB
implement theState
interface and define their unique behavior.- Each state determines the next state and triggers a transition using the
context.setState()
method.
Context
The Context is the class that maintains a reference to the current state and delegates behavior to it.
class Context {
private var currentState: State? = null
fun setState(state: State) {
currentState = state
println("Context: State changed to ${state::class.simpleName}")
}
fun request() {
currentState?.handle(this) ?: println("Context: No state is set")
}
}
- The
Context
class holds a reference to the current state viacurrentState
. - The
setState()
method updates the current state and logs the transition. - The
request()
method delegates the action to the current state’shandle()
method.
Test the Implementation
Finally, we can create a main function to test the transitions between states.
fun main() {
val context = Context()
// Set initial state
context.setState(ConcreteStateA())
// Trigger behavior and transition between states
context.request() // State A handles and transitions to State B
context.request() // State B handles and transitions to State A
context.request() // State A handles and transitions to State B
}
Output
Context: State changed to ConcreteStateA
State A: Handling request and transitioning to State B
Context: State changed to ConcreteStateB
State B: Handling request and transitioning to State A
Context: State changed to ConcreteStateA
State A: Handling request and transitioning to State B
Context: State changed to ConcreteStateB
How These Components Work Together
- The Context is the central point of interaction for the client code. It contains a reference to the current state.
- The State Interface ensures that all states adhere to a consistent set of behaviors.
- The Concrete States implement specific behavior for the Context and may trigger transitions to other states.
- When a client invokes a method on the Context, the Context delegates the behavior to the current state, which executes the appropriate logic.
Real-Time Use Cases
Game Development
The State Design Pattern is widely used in game development. A game character can exist in various states, such as healthy, surviving, or dead. In the healthy state, the character can attack enemies using different weapons. When the character enters the surviving state, its health becomes critical. Once the health reaches zero, the character transitions into the dead state, signaling the end of the game.
Now, let’s first explore how we could implement this use case without using the State Design Pattern, and then compare it with an implementation using the State Pattern for better understanding. This can be done using a series of if-else conditional checks, as demonstrated in the following code snippets.
Player Class
class Player {
fun attack() {
println("Attack")
}
fun fireBomb() {
println("Fire Bomb")
}
fun fireGunblade() {
println("Fire Gunblade")
}
fun fireLaserPistol() {
println("Laser Pistol")
}
fun firePistol() {
println("Fire Pistol")
}
fun survive() {
println("Surviving!")
}
fun dead() {
println("Dead! Game Over")
}
}
Now let us define our game context class which defines the different actions conditionally depends on the state of the player.
GameContext Class (Without State Pattern)
class GameContext {
private val player = Player()
fun gameAction(state: String) {
when (state) {
"healthy" -> {
player.attack()
player.fireBomb()
player.fireGunblade()
player.fireLaserPistol()
}
"survival" -> {
player.survive()
player.firePistol()
}
"dead" -> {
player.dead()
}
else -> {
println("Invalid state")
}
}
}
}
In this implementation of GameContext
, we’re using a when
block (or even if-else
statements) to handle the state. While this approach works well for smaller examples, it can become harder to maintain and less scalable as more states and behaviors are added.
Applying the State Design Pattern
To eliminate the need for multiple conditional checks, let’s refactor the code using the State Design Pattern. We will define separate state classes for each state, and the GameContext
will delegate the actions to the appropriate state object.
Define the State Interface
interface PlayerState {
fun performActions(player: Player)
}
Implement Concrete States
Now, we’ll create concrete state classes for each state: HealthyState
, SurvivalState
, and DeadState
.
class HealthyState : PlayerState {
override fun performActions(player: Player) {
player.attack()
player.fireBomb()
player.fireGunblade()
player.fireLaserPistol()
}
}
class SurvivalState : PlayerState {
override fun performActions(player: Player) {
player.survive()
player.firePistol()
}
}
class DeadState : PlayerState {
override fun performActions(player: Player) {
player.dead()
}
}
Modify the GameContext Class
Finally, the GameContext
class will hold a reference to the current state and delegate the action calls to that state.
class GameContext {
private val player = Player()
private var state: PlayerState = HealthyState() // Default state
fun setState(state: PlayerState) {
this.state = state
}
fun gameAction() {
state.performActions(player)
}
}
Testing the State Design Pattern
Now, let’s demonstrate how we can switch between different states and let the player perform actions based on the current state:
fun main() {
val gameContext = GameContext()
println("Player in Healthy state:")
gameContext.gameAction() // Perform actions in Healthy state
println("\nPlayer in Survival state:")
gameContext.setState(SurvivalState())
gameContext.gameAction() // Perform actions in Survival state
println("\nPlayer in Dead state:")
gameContext.setState(DeadState())
gameContext.gameAction() // Perform actions in Dead state
}
Output
Player in Healthy state:
Attack
Fire Bomb
Fire Gunblade
Laser Pistol
Player in Survival state:
Surviving!
Fire Pistol
Player in Dead state:
Dead! Game Over
Let’s see what benefits we get by using the State Pattern in this case.
- Cleaner Code: The
GameContext
class no longer contains any conditionals. The logic is moved to the state classes, making it easier to manage and extend. - Modular: Each state is encapsulated in its own class, which improves maintainability. If you need to add new states, you just need to implement a new
PlayerState
class. - Extensible: New actions or states can be added without modifying the existing code. You simply create new state classes for additional behaviors.
Before generalizing the benefits of the State Pattern, let’s look at one more use case, which is in a document editor.
A Document Workflow
We’ll create a simple document editor. A document can be in one of three states:
- Draft
- Moderation
- Published
The actions allowed will depend on the state:
- In
Draft
, you can edit or submit for review. - In
Moderation
, you can approve or reject the document. - In
Published
, no changes are allowed.
Without further delay, let’s implement it.
Define a State Interface
The state interface defines the contract for all possible states. Each state will implement this interface.
// State.kt
interface State {
fun edit(document: Document)
fun submitForReview(document: Document)
fun publish(document: Document)
}
Create Concrete State Classes
Each state class represents a specific state and provides its own implementation of the state behavior.
Draft State
// DraftState.kt
class DraftState : State {
override fun edit(document: Document) {
println("Editing the document...")
}
override fun submitForReview(document: Document) {
println("Submitting the document for review...")
document.changeState(ModerationState()) // Transition to Moderation
}
override fun publish(document: Document) {
println("Cannot publish a document in draft state.")
}
}
Moderation State
// ModerationState.kt
class ModerationState : State {
override fun edit(document: Document) {
println("Cannot edit a document under moderation.")
}
override fun submitForReview(document: Document) {
println("Document is already under review.")
}
override fun publish(document: Document) {
println("Publishing the document...")
document.changeState(PublishedState()) // Transition to Published
}
}
Published State
// PublishedState.kt
class PublishedState : State {
override fun edit(document: Document) {
println("Cannot edit a published document.")
}
override fun submitForReview(document: Document) {
println("Cannot submit a published document for review.")
}
override fun publish(document: Document) {
println("Document is already published.")
}
}
Define the Context Class
The context class represents the object whose behavior changes with its state. It maintains a reference to the current state.
// Document.kt
class Document {
private var state: State = DraftState() // Initial state
fun changeState(newState: State) {
state = newState
println("Document state changed to: ${state.javaClass.simpleName}")
}
fun edit() = state.edit(this)
fun submitForReview() = state.submitForReview(this)
fun publish() = state.publish(this)
}
Test the Implementation
Finally, we can create a main
function to test how our document transitions between states.
// Main.kt
fun main() {
val document = Document()
// Current state: Draft
document.edit()
document.submitForReview()
// Current state: Moderation
document.edit()
document.publish()
// Current state: Published
document.submitForReview()
document.edit()
}
Output
Editing the document...
Submitting the document for review...
Document state changed to: ModerationState
Cannot edit a document under moderation.
Publishing the document...
Document state changed to: PublishedState
Cannot submit a published document for review.
Cannot edit a published document.
Benefits of Using the State Design Pattern
- Cleaner Code: Behavior is encapsulated in state-specific classes, eliminating conditionals.
- Scalability: Adding new states is straightforward—just implement the
State
interface. - Encapsulation: Each state manages its behavior, reducing the responsibility of the context class.
- Dynamic Behavior: The object’s behavior changes at runtime by switching states.
When Should You Use the State Pattern?
The State Pattern is ideal when:
- An object’s behavior depends on its state.
- You have complex conditional logic based on state.
- States frequently change, and new states may be added.
However, avoid using it if:
- Your application has only a few states with minimal behavior.
- The state transitions are rare or do not justify the overhead of additional classes.
Conclusion
The State Design Pattern is an excellent way to achieve cleaner, more maintainable, and modular code. By isolating state-specific behaviors within their own classes, we can simplify the logic in our context objects and make our programs easier to extend.
In this blog, we examined the State Pattern through the lens of a document workflow and game development example. I hope this walkthrough provided clarity on implementing it in Kotlin. Now it’s your chance—experiment with this pattern in your own projects and experience its impact firsthand..!