← All patterns

State

Behavioral · view source on GitHub

Read this in: English · Português · Español

Category: Behavioral

The problem

An object's behavior needs to change depending on some internal condition, and which transitions are even legal depends on the current condition too. Modeling this with a status field plus if/switch statements scattered across every method works until the number of states or transitions grows — at that point every method needs to know every state, illegal transitions are easy to allow by accident, and adding one new state means touching every existing method that switches on it.

The solution

Give each state its own class implementing a shared interface, and let each state decide for itself which transitions are legal from there — usually by returning the next state object, or rejecting the request outright. The context object holds a reference to its current state and delegates to it; it never contains a state-checking conditional itself.

classDiagram
    class Context {
        -state
        +request()
    }
    class State {
        <<interface>>
        +handle() State
    }
    class ConcreteStateA
    class ConcreteStateB
    Context o-- State
    State <|.. ConcreteStateA
    State <|.. ConcreteStateB
    ConcreteStateA --> ConcreteStateB : transitions to

Classic example

classic/TrafficLight holds a TrafficLightState and delegates advance() to it; RedState, GreenState, and YellowState each know only one thing: which state comes next. TrafficLight itself has no if (color == "RED") anywhere. TrafficLightTest walks a full red→green→yellow→red cycle.

Applied example: transaction lifecycle

applied/TransactionState rejects every transition by default; PendingState overrides only startProcessing(), ProcessingState overrides only settle() and fail(), and SettledState/FailedState override nothing at all — they're terminal, so every transition attempt correctly fails. This is the same PENDING → PROCESSING → SETTLED/FAILED lifecycle this repo's Observer module notifies about — the difference is what each pattern is for: Observer fans a status change out to interested listeners once it's already happened; State is what actually decides whether that change is legal in the first place. A real payment gateway needs both, usually layered: State enforces the transition, then something publishes the event Observer's listeners react to. TransactionTest covers both terminal paths (settled, failed) and three illegal-transition cases, including that neither terminal state allows any further transition.

When not to use it

Test coverage

100% instruction coverage (JaCoCo; branch coverage reports as n/a — nothing here branches, every state's methods are unconditional). Reproduce it yourself:

./gradlew :behavioral:state:jacocoTestReport

Report at behavioral/state/build/reports/jacoco/test/html/index.html.

Further reading

Unit tests

src/test/java/com/designpatterns/behavioral/state/classic/TrafficLightTest.java
package com.designpatterns.behavioral.state.classic;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class TrafficLightTest {

    @Test
    void cyclesThroughRedGreenYellowAndBackToRed() {
        TrafficLight light = new TrafficLight();

        assertThat(light.currentColor()).isEqualTo("RED");

        light.advance();
        assertThat(light.currentColor()).isEqualTo("GREEN");

        light.advance();
        assertThat(light.currentColor()).isEqualTo("YELLOW");

        light.advance();
        assertThat(light.currentColor()).isEqualTo("RED");
    }
}
src/test/java/com/designpatterns/behavioral/state/applied/TransactionTest.java
package com.designpatterns.behavioral.state.applied;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class TransactionTest {

    @Test
    void startsPendingAndFollowsTheHappyPathToSettled() {
        Transaction transaction = new Transaction("tx-1");
        assertThat(transaction.id()).isEqualTo("tx-1");
        assertThat(transaction.status()).isEqualTo("PENDING");

        transaction.startProcessing();
        assertThat(transaction.status()).isEqualTo("PROCESSING");

        transaction.settle();
        assertThat(transaction.status()).isEqualTo("SETTLED");
    }

    @Test
    void followsTheAlternatePathToFailed() {
        Transaction transaction = new Transaction("tx-2");

        transaction.startProcessing();
        transaction.fail();

        assertThat(transaction.status()).isEqualTo("FAILED");
    }

    @Test
    void rejectsSettlingBeforeProcessingStarted() {
        Transaction transaction = new Transaction("tx-3");

        assertThatThrownBy(transaction::settle).isInstanceOf(IllegalStateException.class);
        assertThat(transaction.status()).isEqualTo("PENDING");
    }

    @Test
    void rejectsAnyTransitionOnceSettledIsTerminal() {
        Transaction transaction = new Transaction("tx-4");
        transaction.startProcessing();
        transaction.settle();

        assertThatThrownBy(transaction::startProcessing).isInstanceOf(IllegalStateException.class);
        assertThatThrownBy(transaction::settle).isInstanceOf(IllegalStateException.class);
        assertThatThrownBy(transaction::fail).isInstanceOf(IllegalStateException.class);
    }

    @Test
    void rejectsAnyTransitionOnceFailedIsTerminal() {
        Transaction transaction = new Transaction("tx-5");
        transaction.startProcessing();
        transaction.fail();

        assertThatThrownBy(transaction::startProcessing).isInstanceOf(IllegalStateException.class);
    }
}

View full JaCoCo coverage report →