Saturday, 31 May 2025

Stop Writing If-Else Pyramids: Use the State Pattern Instead

 Introduction

As software engineers, we often find ourselves writing code that responds to different conditions and executes varying logic based on the system’s current state. A common way of handling such variability is by using nested if-else or switch statements. While this works fine for simple scenarios, it can quickly become unwieldy, hard to maintain, and error-prone as complexity grows.

In this article, we’ll discuss why if-else trees are problematic in complex systems and how the State Design Pattern offers a cleaner, more scalable, and maintainable solution. We’ll go through real-world examples, refactor legacy if-else trees into the State pattern, and understand how this pattern can enhance code readability, reduce bugs, and enable more flexible software architecture.

Table of Contents

  1. The Problem with If-Else Trees

  2. Introducing the State Pattern

  3. Anatomy of the State Pattern

  4. When to Use the State Pattern

  5. Refactoring If-Else Trees with State Pattern: A Real Example

  6. Benefits of Using the State Pattern

  7. State Pattern in Functional Languages

  8. Anti-Patterns and Pitfalls

  9. Testability and Maintainability Improvements

  10. Conclusion

1. The Problem with If-Else Trees

The Classic Monster

Consider a simple vending machine:

public class VendingMachine {
    public void handleAction(String state, String action) {
        if (state.equals("IDLE")) {
            if (action.equals("insert_coin")) {
                System.out.println("Coin inserted.");
                // move to WAITING_FOR_SELECTION
            } else {
                System.out.println("Invalid action.");
            }
        } else if (state.equals("WAITING_FOR_SELECTION")) {
            if (action.equals("select_item")) {
                System.out.println("Item selected.");
                // move to DISPENSING
            } else {
                System.out.println("Invalid action.");
            }
        } else if (state.equals("DISPENSING")) {
            if (action.equals("dispense_item")) {
                System.out.println("Dispensing item...");
                // move to IDLE
            } else {
                System.out.println("Invalid action.");
            }
        }
    }
}

What’s Wrong?

  • Low Scalability: Adding a new state or action requires editing multiple conditional branches.

  • Poor Readability: Business logic gets buried under control flow noise.

  • Brittle Code: Mistakes in string literals or order of conditionals can lead to subtle bugs.

  • Code Duplication: Similar validation logic is repeated.

2. Introducing the State Pattern

The State Pattern is a behavioral design pattern that lets an object alter its behavior when its internal state changes. It appears as if the object has changed its class.

Definition (Gang of Four):

"Allow an object to alter its behavior when its internal state changes. The object will appear to change its class."

This pattern encapsulates state-specific behavior into separate state classes, and the context class delegates behavior based on its current state.

3. Anatomy of the State Pattern

Participants

  • Context: Maintains an instance of a ConcreteState and delegates the work.

  • State Interface: Declares method(s) that ConcreteStates will implement.

  • Concrete States: Implement state-specific behavior.

UML Diagram

State Design Pattern


4. When to Use the State Pattern

You should consider using the State pattern when:

  • An object’s behavior depends on its state.

  • You have multiple conditional branches (if-else or switch-case) based on a state field.

  • You want to avoid long methods with complex conditional logic.

  • The behavior changes frequently or requires extension.

5. Refactoring If-Else Trees with State Pattern: A Real Example

Let’s revisit our vending machine and refactor it using the State pattern in Java.

Step 1: Define the State Interface

public interface State {
    void insertCoin(VendingMachine machine);
    void selectItem(VendingMachine machine);
    void dispenseItem(VendingMachine machine);
}

Step 2: Create Concrete States

public class IdleState implements State {
    public void insertCoin(VendingMachine machine) {
        System.out.println("Coin inserted.");
        machine.setState(new WaitingForSelectionState());
    }

    public void selectItem(VendingMachine machine) {
        System.out.println("Insert coin first.");
    }

    public void dispenseItem(VendingMachine machine) {
        System.out.println("Insert coin and select item first.");
    }
}
public class WaitingForSelectionState implements State {
    public void insertCoin(VendingMachine machine) {
        System.out.println("Coin already inserted.");
    }

    public void selectItem(VendingMachine machine) {
        System.out.println("Item selected.");
        machine.setState(new DispensingState());
    }

    public void dispenseItem(VendingMachine machine) {
        System.out.println("Select item first.");
    }
}
public class DispensingState implements State {
    public void insertCoin(VendingMachine machine) {
        System.out.println("Wait for current dispensing to complete.");
    }

    public void selectItem(VendingMachine machine) {
        System.out.println("Already dispensing.");
    }

    public void dispenseItem(VendingMachine machine) {
        System.out.println("Dispensing item...");
        machine.setState(new IdleState());
    }
}

Step 3: Create the Context Class

public class VendingMachine {
    private State state;

    public VendingMachine() {
        this.state = new IdleState(); // initial state
    }

    public void setState(State state) {
        this.state = state;
    }

    public void insertCoin() {
        state.insertCoin(this);
    }

    public void selectItem() {
        state.selectItem(this);
    }

    public void dispenseItem() {
        state.dispenseItem(this);
    }
}

Usage

public class Main {
    public static void main(String[] args) {
        VendingMachine machine = new VendingMachine();

        machine.insertCoin();     // Coin inserted.
        machine.selectItem();     // Item selected.
        machine.dispenseItem();   // Dispensing item...
    }
}

6. Benefits of Using the State Pattern

✅ Improved Readability

Each state’s behavior is encapsulated in a separate class, making the logic easy to follow.

✅ Open/Closed Principle

You can add new states without changing the context or existing state logic.

✅ Elimination of Conditionals

No more deep nesting of if-else trees based on state.

✅ Easier Testing

Each state can be tested independently without requiring the full system.


7. State Pattern in Functional Languages

While classical object-oriented languages implement the State pattern using interfaces and polymorphism, functional languages achieve similar benefits using function composition and pattern matching.

Example in Kotlin

sealed class VendingMachineState {
    object Idle : VendingMachineState()
    object Waiting : VendingMachineState()
    object Dispensing : VendingMachineState()
}

fun handle(state: VendingMachineState, action: String): VendingMachineState {
    return when (state) {
        is VendingMachineState.Idle -> {
            if (action == "insert_coin") {
                println("Coin inserted.")
                VendingMachineState.Waiting
            } else {
                println("Invalid.")
                state
            }
        }
        is VendingMachineState.Waiting -> {
            if (action == "select_item") {
                println("Item selected.")
                VendingMachineState.Dispensing
            } else {
                println("Invalid.")
                state
            }
        }
        is VendingMachineState.Dispensing -> {
            if (action == "dispense_item") {
                println("Dispensed.")
                VendingMachineState.Idle
            } else {
                println("Invalid.")
                state
            }
        }
    }
}

8. Anti-Patterns and Pitfalls

❌ Over-Engineering

Don't use the State pattern if you only have one or two states with minor differences. The added complexity may not be worth it.

❌ Tight Coupling Between States

Avoid making states aware of each other’s internals. Let the Context manage transitions.

❌ Mutable Global State

Ensure state transitions are deterministic and not affected by hidden mutable fields.


9. Testability and Maintainability Improvements

The State pattern simplifies unit testing:

  • You can create a test per state class.

  • Mocking or stubbing other parts of the system is easier since each state is small and focused.

Example: Testing IdleState

@Test public void testInsertCoinInIdleState() { VendingMachine machine = new VendingMachine(); machine.insertCoin(); assertTrue(machine.getState() instanceof WaitingForSelectionState); }

10. Conclusion

The next time you find yourself writing a complex if-else or switch-case tree based on an object's state, consider using the State pattern instead. It’s a powerful tool in your design toolbox that can make your code:

  • Cleaner

  • More extensible

  • Easier to test

  • Aligned with SOLID principles

By encapsulating behaviors in discrete state classes, you enable your codebase to grow organically while remaining robust and readable.

So stop writing if-else jungles — embrace the State pattern, and write better, more maintainable software!

Further Reading

  • Design Patterns: Elements of Reusable Object-Oriented Software by Gamma et al.

  • Martin Fowler’s Refactoring and Patterns of Enterprise Application Architecture

  • “State Machines and Statecharts” by David Harel