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
- If the "states" don't actually have different behavior — they're just labels on otherwise identical objects — a plain enum field is simpler and this pattern is unnecessary ceremony.
- For a small, fixed number of states with simple transitions, a single class with a
switchcan be perfectly readable; State earns its complexity once the per-state behavior itself (not just which state comes next) genuinely differs. - Don't confuse this with Strategy: Strategy's variants are chosen once by the caller and don't change each other; State's variants transition among themselves as part of the object's own lifecycle, and the object doesn't know in advance which state it will be in next.
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
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. — Chapter 5 formalizes State, explicitly contrasting it with Strategy (same class diagram shape, different intent — see "When not to use it" above).
- Harel, D. (1987). "Statecharts: A Visual Formalism for Complex Systems." Science of Computer
Programming, 8(3), 231–274. — the formal finite-state-machine theory this pattern is an
object-oriented implementation technique for;
TransactionState's reject-by-default base class is exactly how you implement Harel's illegal-transition semantics without an explicit transition table.
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);
}
}