State Design Pattern in Java
The state design pattern in Java lets an object change its behaviour when its internal state changes, by moving each state's behaviour into its own class. The object, called the context, delegates every action to its current state object, and the state object decides what the action does and which state comes next.
It matters because objects with a lifecycle (orders, payments, loan applications, connections) otherwise fill up with if and switch checks on a status field. Every method repeats the same checks, and one wrong condition lets a shipped order be cancelled. The State pattern gives each state one place for its rules. It is a behavioral design pattern, and it is how state machines are usually implemented in object-oriented code.
1. What Is the State Design Pattern?
The GoF intent is: "Allow an object to alter its behavior when its internal state changes. The object will appear to change its class."
Instead of a status field checked everywhere, the context holds a reference to a state object. Every state implements the same interface, which has one method per action the context supports. When the client calls order.ship(), the order simply calls state.ship(this). A PaidState ships the order and switches the context to ShippedState; a NewState refuses.
From the outside, the same Order object behaves differently depending on its state, as if its class had changed. Inside, each state class contains only the rules for that state, which follows the Single Responsibility Principle. Adding a state means adding a class and updating the states that lead to it, rather than editing every method of the context.
2. Key Components
| Component | Responsibility | In the Basic Example |
|---|---|---|
| Context | Holds the current state and delegates actions to it | Order |
| State | Interface with one method per action | OrderState |
| Concrete States | Implement the allowed actions and choose the next state | NewState, PaidState, ShippedState, DeliveredState, CancelledState |
| Client | Calls actions on the context, never on states | main |
The client calls the context. The context forwards the call, passing itself, to the current state. The state does its work and, if the action causes a transition, tells the context which state to switch to.
3. When Is State Useful?
- Order lifecycles: new, paid, shipped, delivered and cancelled orders accept different actions.
- Resilience: a circuit breaker behaves completely differently when closed, open or half-open.
- Payments: authorise, capture and refund are valid only in specific states, and gateway webhooks can arrive twice.
4. UML Class Diagram
The diagram shows the Basic Example.
Order keeps one current OrderState and delegates to it; the states decide transitions by calling back changeState().Orderholds exactly one current state (solid arrow, multiplicity 1).- Five concrete states implement
OrderState(dashed lines, hollow triangle). - Each state method receives the order, and states call
changeState()to move it on (dashed dependency on the left). changeState()is package-private (~), so clients cannot jump the order into an arbitrary state.
5. Step-by-Step Execution Flow
- A new
Orderstarts withNewState. - The client calls
order.pay(); the order callsstate.pay(this). NewState.pay()allows it and callsorder.changeState(new PaidState()).- The client calls
order.ship();PaidStatemoves the order toShippedState. - The client calls
order.cancel().ShippedStatedoes not overridecancel(), so the interface's default throws "cannot cancel an order that is SHIPPED". - The order stays shipped; the client reports the rejection.
6. Real-World Use Cases
- E-commerce: order, return and shipment lifecycles where each status allows different actions.
- Payments and banking: payment states (created, authorised, captured, refunded), loan applications and card lifecycles (active, blocked, expired).
- Notifications: delivery tracking such as queued, sent, delivered, bounced.
- Resilience libraries: Resilience4j's circuit breaker has
CLOSED,OPENandHALF_OPENstates with different behaviour in each. - Spring: the Spring Statemachine project provides a framework for declaring states, events and transitions. In the JDK,
Thread.Statenames a thread's lifecycle states, though as an enum, not as behaviour-carrying state objects.
7. Problem Without the Pattern
An order with a status enum and checks in every method:
public class OrderWithoutState {
enum Status { NEW, PAID, SHIPPED, DELIVERED, CANCELLED }
// Every method re-checks the status with its own if/switch
static final class Order {
private Status status = Status.NEW;
void pay() {
if (status != Status.NEW) throw new IllegalStateException("cannot pay when " + status);
status = Status.PAID;
}
void ship() {
if (status != Status.PAID) throw new IllegalStateException("cannot ship when " + status);
status = Status.SHIPPED;
}
void deliver() {
if (status != Status.SHIPPED) throw new IllegalStateException("cannot deliver when " + status);
status = Status.DELIVERED;
}
void cancel() {
if (status == Status.DELIVERED) throw new IllegalStateException("cannot cancel when " + status);
status = Status.CANCELLED; // bug: SHIPPED orders can also be cancelled
}
}
public static void main(String[] args) {
Order order = new Order();
order.pay();
order.ship();
order.cancel(); // the parcel is already with the courier
System.out.println("Status: " + order.status + " (a shipped order was cancelled)");
}
}
Status: CANCELLED (a shipped order was cancelled)
- Scattered rules: the transition rules for one status are spread across every method.
- Easy mistakes:
cancel()lists the forbidden states instead of the allowed ones, so a shipped order is cancelled. - Growth problem: a new status, such as
RETURNED, means revisiting every method. - State-specific work like issuing a refund ends up as more conditions inside these methods.
8. Solution Using the Pattern
Give each status its own class implementing an OrderState interface. The interface's default methods reject every action, and each state overrides only the actions it allows. The order delegates to its current state.
Allowed transitions are now opt-in: a shipped order cannot be cancelled because ShippedState never says it can. State-specific work, such as the refund when a paid order is cancelled, lives in the state where it belongs. A RETURNED state is one new class plus a returnItem() override in DeliveredState.
9. State Design Pattern in Java: Code Examples
Each example is a single file that runs on Java 21 with java FileName.java.
Basic Example: Order Lifecycle
Problem: fix section 7 so that each order status allows only its own actions.
public class OrderStateDemo {
// State: every action an order supports; by default an action is not allowed
interface OrderState {
String name();
default void pay(Order order) { throw notAllowed("pay"); }
default void ship(Order order) { throw notAllowed("ship"); }
default void deliver(Order order) { throw notAllowed("deliver"); }
default void cancel(Order order) { throw notAllowed("cancel"); }
private IllegalStateException notAllowed(String action) {
return new IllegalStateException("cannot " + action + " an order that is " + name());
}
}
// Concrete states: each one overrides only the actions it allows
static final class NewState implements OrderState {
public String name() { return "NEW"; }
public void pay(Order order) { order.changeState(new PaidState()); }
public void cancel(Order order) { order.changeState(new CancelledState()); }
}
static final class PaidState implements OrderState {
public String name() { return "PAID"; }
public void ship(Order order) { order.changeState(new ShippedState()); }
public void cancel(Order order) {
System.out.println(" refund issued for " + order.id());
order.changeState(new CancelledState());
}
}
static final class ShippedState implements OrderState {
public String name() { return "SHIPPED"; }
public void deliver(Order order) { order.changeState(new DeliveredState()); }
}
static final class DeliveredState implements OrderState {
public String name() { return "DELIVERED"; }
}
static final class CancelledState implements OrderState {
public String name() { return "CANCELLED"; }
}
// Context: delegates every action to its current state
static final class Order {
private final String id;
private OrderState state = new NewState();
Order(String id) { this.id = id; }
String id() { return id; }
String status() { return state.name(); }
void pay() { state.pay(this); }
void ship() { state.ship(this); }
void deliver() { state.deliver(this); }
void cancel() { state.cancel(this); }
void changeState(OrderState next) { // package-private: only states call it
System.out.println(" " + id + ": " + state.name() + " -> " + next.name());
state = next;
}
}
static void attempt(Order order, String action, Runnable step) {
try {
step.run();
} catch (IllegalStateException e) {
System.out.println(" " + order.id() + ": " + action + " rejected (" + e.getMessage() + ")");
}
}
public static void main(String[] args) {
Order first = new Order("ORD-11");
first.pay();
first.ship();
attempt(first, "cancel", first::cancel); // the bug from before is now impossible
first.deliver();
System.out.println("ORD-11 final status: " + first.status());
Order second = new Order("ORD-12");
second.pay();
second.cancel(); // allowed while PAID, with a refund
attempt(second, "ship", second::ship);
System.out.println("ORD-12 final status: " + second.status());
}
}
Important classes and methods
OrderStateuses default methods that reject actions, plus a private helper method (Java 9+) to build the message.- Each concrete state overrides only what it allows;
DeliveredStateandCancelledStateare terminal and override nothing. Ordercontains no conditions at all; every action is one line of delegation.
Expected output
ORD-11: NEW -> PAID
ORD-11: PAID -> SHIPPED
ORD-11: cancel rejected (cannot cancel an order that is SHIPPED)
ORD-11: SHIPPED -> DELIVERED
ORD-11 final status: DELIVERED
ORD-12: NEW -> PAID
refund issued for ORD-12
ORD-12: PAID -> CANCELLED
ORD-12: ship rejected (cannot ship an order that is CANCELLED)
ORD-12 final status: CANCELLED
How it runs
- ORD-11 moves from new to paid to shipped; the cancel attempt is rejected; it is then delivered.
- ORD-12 is cancelled while paid, so
PaidStateissues a refund first. - Shipping a cancelled order is rejected by
CancelledState.
Practical Example: Circuit Breaker for a Payment Gateway
Problem: when the payment gateway keeps timing out, the service should stop calling it for a while, fail fast, and then test it carefully before resuming normal traffic.
import java.io.Serial;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.function.Supplier;
public class CircuitBreakerDemo {
static final class CallNotPermittedException extends RuntimeException {
@Serial
private static final long serialVersionUID = 1L;
CallNotPermittedException(String message) { super(message); }
}
// State
interface BreakerState {
String name();
<T> T call(CircuitBreaker breaker, Supplier<T> action);
}
// CLOSED: calls go through; too many consecutive failures open the circuit
static final class Closed implements BreakerState {
private int failures = 0;
public String name() { return "CLOSED"; }
public <T> T call(CircuitBreaker breaker, Supplier<T> action) {
try {
T result = action.get();
failures = 0;
return result;
} catch (RuntimeException e) {
if (++failures >= breaker.failureThreshold) {
breaker.transitionTo(new Open(breaker.clock.instant()));
}
throw e;
}
}
}
// OPEN: fail fast, without calling the service, until the wait time is over
static final class Open implements BreakerState {
private final Instant openedAt;
Open(Instant openedAt) { this.openedAt = openedAt; }
public String name() { return "OPEN"; }
public <T> T call(CircuitBreaker breaker, Supplier<T> action) {
if (breaker.clock.instant().isBefore(openedAt.plus(breaker.openDuration))) {
throw new CallNotPermittedException("circuit open, call not attempted");
}
breaker.transitionTo(new HalfOpen());
return breaker.call(action); // let the new state handle this call
}
}
// HALF_OPEN: one trial call decides whether to close or open again
static final class HalfOpen implements BreakerState {
public String name() { return "HALF_OPEN"; }
public <T> T call(CircuitBreaker breaker, Supplier<T> action) {
try {
T result = action.get();
breaker.transitionTo(new Closed());
return result;
} catch (RuntimeException e) {
breaker.transitionTo(new Open(breaker.clock.instant()));
throw e;
}
}
}
// Context
static final class CircuitBreaker {
final int failureThreshold;
final Duration openDuration;
final Clock clock;
private BreakerState state = new Closed();
CircuitBreaker(int failureThreshold, Duration openDuration, Clock clock) {
this.failureThreshold = failureThreshold;
this.openDuration = openDuration;
this.clock = clock;
}
synchronized <T> T call(Supplier<T> action) { return state.call(this, action); } // one caller at a time
void transitionTo(BreakerState next) {
System.out.println(" [breaker] " + state.name() + " -> " + next.name());
state = next;
}
String state() { return state.name(); }
}
static final class TestClock extends Clock {
private Instant now = Instant.parse("2026-11-02T10:00:00Z");
void advance(Duration d) { now = now.plus(d); }
@Override public Instant instant() { return now; }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
}
public static void main(String[] args) {
TestClock clock = new TestClock();
CircuitBreaker breaker = new CircuitBreaker(3, Duration.ofSeconds(30), clock);
boolean[] gatewayUp = {true};
Supplier<String> callGateway = () -> {
if (!gatewayUp[0]) throw new IllegalStateException("gateway timeout");
return "200 OK";
};
String[] script = {"up", "down", "down", "down", "down", "wait", "up", "up"};
int n = 0;
for (String step : script) {
if (step.equals("wait")) {
clock.advance(Duration.ofSeconds(31));
System.out.println("(31 seconds pass)");
continue;
}
gatewayUp[0] = step.equals("up");
n++;
try {
System.out.println("call " + n + ": " + breaker.call(callGateway) + " [" + breaker.state() + "]");
} catch (RuntimeException e) {
System.out.println("call " + n + ": failed - " + e.getMessage() + " [" + breaker.state() + "]");
}
}
}
}
Important classes and methods
Closedcounts consecutive failures and opens the circuit at the threshold.Openrejects calls without touching the gateway until 30 seconds have passed, then hands over toHalfOpen.HalfOpenallows one trial call: success closes the circuit, failure opens it again.call()issynchronized, so state changes are never interleaved between threads. Production libraries use finer-grained, lock-free designs.
Expected output
call 1: 200 OK [CLOSED]
call 2: failed - gateway timeout [CLOSED]
call 3: failed - gateway timeout [CLOSED]
[breaker] CLOSED -> OPEN
call 4: failed - gateway timeout [OPEN]
call 5: failed - circuit open, call not attempted [OPEN]
(31 seconds pass)
[breaker] OPEN -> HALF_OPEN
[breaker] HALF_OPEN -> CLOSED
call 6: 200 OK [CLOSED]
call 7: 200 OK [CLOSED]
How it runs
- The third consecutive failure (call 4) opens the circuit.
- Call 5 fails immediately without calling the gateway.
- After 31 seconds the next call moves to half-open, succeeds, and closes the circuit.
Real-World Enterprise Example: Concurrency-Safe Payment State Machine
Problem: payment states change through API calls and gateway webhooks. Webhooks are often delivered twice, sometimes at the same moment, and a payment must never be captured twice.
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
public class PaymentStateMachineDemo {
// Events that arrive from the API and from gateway webhooks
sealed interface PaymentEvent permits Authorize, Capture, Refund, Fail { }
record Authorize(long amount) implements PaymentEvent { }
record Capture() implements PaymentEvent { }
record Refund(String reason) implements PaymentEvent { }
record Fail(String reason) implements PaymentEvent { }
// States as enum constants: stateless, shared and easy to store in a database column
enum PaymentState {
CREATED {
PaymentState on(PaymentEvent event) {
return switch (event) {
case Authorize a -> AUTHORIZED;
case Fail f -> FAILED;
default -> reject(event);
};
}
},
AUTHORIZED {
PaymentState on(PaymentEvent event) {
return switch (event) {
case Capture c -> CAPTURED;
case Fail f -> FAILED;
default -> reject(event);
};
}
},
CAPTURED {
PaymentState on(PaymentEvent event) {
return event instanceof Refund ? REFUNDED : reject(event);
}
},
REFUNDED {
PaymentState on(PaymentEvent event) { return reject(event); } // terminal
},
FAILED {
PaymentState on(PaymentEvent event) { return reject(event); } // terminal
};
abstract PaymentState on(PaymentEvent event);
PaymentState reject(PaymentEvent event) {
throw new IllegalStateException(name() + " cannot accept " + event.getClass().getSimpleName());
}
}
// Context: applies events with compare-and-set, so concurrent events cannot both succeed
static final class Payment {
private final String id;
private final AtomicReference<PaymentState> state = new AtomicReference<>(PaymentState.CREATED);
private final List<String> history = new CopyOnWriteArrayList<>();
Payment(String id) { this.id = id; }
PaymentState apply(PaymentEvent event) {
while (true) {
PaymentState current = state.get();
PaymentState next = current.on(event); // throws if the transition is illegal
if (state.compareAndSet(current, next)) {
history.add(current + " -> " + next + " on " + event.getClass().getSimpleName());
return next;
}
// another thread changed the state first: re-read and re-check
}
}
PaymentState state() { return state.get(); }
List<String> history() { return List.copyOf(history); }
String id() { return id; }
}
public static void main(String[] args) throws InterruptedException {
Payment payment = new Payment("PAY-901");
payment.apply(new Authorize(2_499));
// The gateway sends the same "captured" webhook twice, at the same moment
AtomicInteger accepted = new AtomicInteger();
AtomicInteger rejected = new AtomicInteger();
CountDownLatch start = new CountDownLatch(1);
try (ExecutorService webhooks = Executors.newFixedThreadPool(2)) {
for (int i = 0; i < 2; i++) {
webhooks.submit(() -> {
start.await();
try {
payment.apply(new Capture());
accepted.incrementAndGet();
} catch (IllegalStateException e) {
rejected.incrementAndGet();
}
return null;
});
}
start.countDown(); // release both threads together
}
System.out.println("Concurrent captures: accepted=" + accepted + ", rejected=" + rejected);
payment.apply(new Refund("customer returned item"));
try {
payment.apply(new Capture());
} catch (IllegalStateException e) {
System.out.println("Late webhook ignored: " + e.getMessage());
}
System.out.println(payment.id() + " is " + payment.state());
payment.history().forEach(entry -> System.out.println(" " + entry));
}
}
Important classes and methods
PaymentStateis an enum whose constants overrideon(event). Enum states are stateless singletons, and their names map directly to a database column.- Events form a sealed interface, and the transitions use Java 21 pattern matching for
switch. Payment.apply()usesAtomicReference.compareAndSet(): if another thread changed the state first, it re-reads the state and re-checks the transition.
Expected output
Concurrent captures: accepted=1, rejected=1
Late webhook ignored: REFUNDED cannot accept Capture
PAY-901 is REFUNDED
CREATED -> AUTHORIZED on Authorize
AUTHORIZED -> CAPTURED on Capture
CAPTURED -> REFUNDED on Refund
How it runs
- Both webhook threads read
AUTHORIZEDand computeCAPTURED, but only one compare-and-set succeeds. - The other re-reads
CAPTURED, which rejectsCapture, so it is counted as rejected. - After the refund, a late capture webhook is rejected by the terminal
REFUNDEDstate.
With a database, the same guarantee comes from an optimistic-lock version column or UPDATE ... WHERE status = 'AUTHORIZED'.
10. Best Practices and Common Mistakes
Best practices
- Reject by default, and let each state opt in to the actions it allows.
- Make states stateless where possible (enums or shared singletons), and keep data in the context.
- Keep the transition method non-public, so only states can change state.
- Thread safety: concurrent events can race. Use a lock or compare-and-set in memory, and optimistic locking or conditional updates in the database.
Common mistakes
- Letting clients set the state directly, which bypasses the rules.
- States that know too much, reaching deep into the context instead of calling a few methods.
- Forgetting persistence: storing only the class name, then failing to rebuild state after a restart.
- Overengineering: state classes for a two-value flag that a boolean handles clearly.
11. When to Use and When Not to Use
Use it when an object has several states with clearly different behaviour, when transitions have rules that must be enforced, or when status conditions are repeated across many methods.
Avoid it for objects with two or three states and trivial behaviour, where an enum and a small switch are clearer, or when states and transitions are configured by business users at runtime, where a table-driven state machine or a workflow engine fits better.
12. Advantages and Disadvantages
Advantages
- No scattered conditionals: each state's rules live in one class.
- Explicit, enforceable transitions.
- Easy to add states without touching unrelated ones.
- State-specific work sits next to the rules that trigger it.
Disadvantages
- More classes, one per state.
- The full transition map is spread across states, so a diagram helps.
- Persistence and concurrency need extra design.
13. Comparison with Similar Patterns
| Aspect | State | Strategy | Command | Enum with switch |
|---|---|---|---|---|
| Who changes the behaviour | The states themselves, through transitions | The client, by injecting a strategy | Not applicable: commands are requests | Code that sets the field |
| Objects know each other | States know their successors | Strategies are independent | Commands know their receiver | Not applicable |
| Typical lifetime | Changes many times per object | Usually fixed per object | One per request | Changes per object |
| Good for | Lifecycles with rich behaviour | Interchangeable algorithms | Undo, queues, logs | Few states, little behaviour |
State and Strategy have the same class structure; the difference is who switches. Choose State when the object moves through a lifecycle on its own, and Strategy when a caller picks an algorithm. Events that drive a state machine are often commands, and an enum with a switch remains the simplest choice for small, behaviour-light lifecycles. The Template Method pattern is unrelated in purpose but often confused because both use subclasses.
14. Summary
- The state design pattern in Java moves each state's behaviour into its own class, and the context delegates to the current state.
- Purpose: replace scattered status checks with explicit, per-state rules.
- Components: Context, State interface, Concrete States, Client.
- States perform actions and decide transitions by telling the context which state comes next.
- Used for orders, payments, circuit breakers, loans and delivery tracking.
- Benefits: no duplicated conditionals, enforceable transitions, easy extension.
- Reject by default, keep states stateless, and guard transitions against concurrent events.
15. Java Design Pattern Interview Questions and Answers
Q1. State and Strategy have the same UML. How do you explain the difference in an interview?
The structure is identical: a context delegating to an interface. The difference is intent and control. In Strategy, the client chooses the behaviour and it rarely changes; in State, the state objects change the context's state themselves as events happen, so behaviour changes over the object's life. State objects usually know about each other, while strategies do not.
Q2. Should a transition be decided by the state classes or by the context?
Both work. Letting states decide keeps each state's rules together and is the classic approach. Putting a transition table in the context, for example a map from (state, event) to next state, makes the whole lifecycle visible in one place. Larger systems often use a table or a framework such as Spring Statemachine, while states keep their behaviour.
Q3. How do you store and restore a state object in a database?
Store an identifier, typically an enum name in a status column, and map it back to the state object when loading. Enum-based states make this trivial with @Enumerated(EnumType.STRING) in JPA. Avoid storing the ordinal, because adding a constant in the middle would silently change the meaning of existing rows.
@Enumerated(EnumType.STRING)
@Column(name = "status")
private PaymentState state;Q4. Two service instances process the same payment webhook at the same time. How do you prevent a double transition?
In-memory locks do not help across instances, so the database must decide. Use a conditional update such as UPDATE payments SET status = 'CAPTURED' WHERE id = ? AND status = 'AUTHORIZED' and check the affected row count, or use a JPA @Version column for optimistic locking. The loser sees zero rows updated or an OptimisticLockException and treats the event as a duplicate.
Q5. When would you use a plain enum and switch instead of the State pattern?
When there are only a few states, the behaviour per state is a line or two, and transitions are simple. Java 21's switch expressions over enums are exhaustive, so the compiler flags a missing case. Move to state classes when the switches start appearing in many methods or when states need their own logic and data.