Java design pattern deep dive
State Pattern in Java: 100 interview questions with professional answers.
Learn how the State pattern lets an object change its behavior when its internal lifecycle stage changes, why it replaces a status field guarded by giant if/else or switch chains with polymorphic ConcreteState classes, and how to apply it safely to order lifecycles, approval workflows, connection state machines, and game character controllers.
What makes a good State answer?
Interviewers want to see that you understand why an object's behavior should live inside per-state classes rather than inside one conditional-laden method, and that you can reason about who owns a transition, not just recite the participant names.
| Approach | Use when | Watch out for |
|---|---|---|
| Classic State pattern (one class per state) | Several states each carry distinct data or fairly involved per-state logic, and each state's behavior should be unit-testable in isolation. | More files and ceremony than a small enum; overkill for two trivial states. |
| Enum with abstract per-constant methods | A fixed, small-to-medium set of states known at compile time with fairly short per-state logic. | Gets unwieldy once a state needs its own fields, constructor parameters, or many collaborators. |
| Giant if/else or switch on a status field | Rarely a good long-term choice; sometimes acceptable for one or two call sites that will never grow. | Every new status forces edits to every branching method; easy to miss one and silently corrupt state. |
| Dedicated state-machine library (e.g. Spring State Machine) | Complex workflows with guards, entry/exit actions, hierarchical states, or a need for visual diagrams and persistence support out of the box. | Extra dependency and learning curve; can be heavier than the problem actually requires. |
Topics
Interview questions and answers
Each answer gives the implementation direction, the trade-off to mention, and the production concern that makes the answer stronger.
1. Explain the State design pattern in Java: what problem does it solve, and what are its three participants?
The State pattern lets an object change its observable behavior when its internal lifecycle stage changes, and it does so by delegating each behavior to a separate object, a ConcreteState, that represents exactly one stage of that lifecycle. Instead of one class holding a status field and branching on it inside every method, the class holds a reference to a State object and forwards calls to it; swapping that reference is what makes the object appear to change behavior.
The three GoF participants are the Context, the object clients actually interact with, which holds a reference to the current State and delegates to it; the State interface, which declares one method per behavior that varies by lifecycle stage; and one ConcreteState class per stage, each implementing those methods for that specific stage and typically deciding which state comes next.
2. Describe exactly how the Context class delegates behavior to whichever ConcreteState it currently holds, and why the Context itself should stay free of state-specific logic.
The Context stores a single field typed as the State interface, for example private OrderState state;, and every public method that the client calls simply forwards to the same-named method on that field: public void ship() { state.ship(this); }. The Context never asks "which state am I in" with an if/else; it trusts polymorphism to route the call to the correct implementation.
class Order {
private OrderState state = new PendingState();
public void ship() { state.ship(this); }
public void deliver() { state.deliver(this); }
public void cancel() { state.cancel(this); }
void setState(OrderState state) { this.state = state; }
}
Keeping the Context free of state-specific logic matters because the moment a developer adds "if state is PENDING then..." back into the Context, the whole point of the pattern, isolating per-stage behavior into its own class, is undone and the giant conditional creeps back in through the side door.
3. Compare a naive implementation that stores lifecycle stage as a status field, such as an int or enum checked in every method, against a State-pattern implementation, and explain exactly why the naive version becomes hard to maintain.
In the naive version, an OrderStatus status field is checked at the top of ship(), cancel(), and deliver(), each with its own switch statement enumerating every status. Adding one new status, say ON_HOLD, means finding and editing every one of those switch statements, and it is easy to miss one, silently leaving the object able to perform an action it should not be allowed to in that stage.
class NaiveOrder {
private OrderStatus status = OrderStatus.PENDING;
void ship() {
switch (status) {
case PENDING -> status = OrderStatus.SHIPPED;
case SHIPPED, DELIVERED, CANCELLED -> throw new IllegalStateException("Cannot ship from " + status);
}
}
// cancel() and deliver() repeat a similar switch, and every new status touches all three
}
The State pattern version instead confines all logic for a given status to one class; adding OnHoldState means writing one new class and does not require editing PendingState, ShippedState, or any existing class, which is a direct application of the open/closed principle.
4. Design the OrderState interface for an order-lifecycle example, listing the methods it should declare and explaining why each ConcreteState receives the Context as a parameter.
The interface declares one method per action the order supports: ship, deliver, and cancel. Each method takes the Context, the Order, as a parameter so the implementing ConcreteState can both read the order's data and call back into it, most importantly to install the next state via order.setState(...) once the transition is legal.
interface OrderState {
void ship(Order order);
void deliver(Order order);
void cancel(Order order);
}
Without passing the Context in, a ConcreteState would have no way to trigger the transition to the next state, since the state itself does not hold a reference back to the order it belongs to (unless a per-context design, discussed elsewhere, deliberately gives it one).
5. Implement PendingState, the initial ConcreteState in the order-lifecycle example, showing how it both performs the shipping action and transitions the Context to the next state.
PendingState represents a freshly placed order that has not yet shipped. Its ship method performs whatever domain action shipping requires (recording a timestamp, calling a carrier API) and then installs ShippedState as the order's new current state. Its cancel method is also legal from here, since an unshipped order can still be cancelled, but deliver is not, since nothing can be delivered before it ships.
class PendingState implements OrderState {
@Override
public void ship(Order order) {
order.recordShippedTimestamp();
order.setState(new ShippedState());
}
@Override
public void deliver(Order order) {
throw new IllegalStateException("Cannot deliver an order that has not shipped yet");
}
@Override
public void cancel(Order order) {
order.recordCancellation("Cancelled before shipping");
order.setState(new CancelledState());
}
}
6. Implement ShippedState in the order-lifecycle example, and explain why cancel() should behave differently here than it did in PendingState.
Once an order has shipped, cancelling it in the same sense as before no longer makes sense, physical goods are already in transit, so cancel() either throws to signal an illegal transition or is redefined to mean "initiate a return," which is a materially different business operation than a pre-shipment cancellation. deliver() becomes legal here and moves the order into its terminal DeliveredState.
class ShippedState implements OrderState {
@Override
public void ship(Order order) {
throw new IllegalStateException("Order has already shipped");
}
@Override
public void deliver(Order order) {
order.recordDeliveredTimestamp();
order.setState(new DeliveredState());
}
@Override
public void cancel(Order order) {
throw new IllegalStateException("Cannot cancel a shipped order; initiate a return instead");
}
}
This is exactly the value the pattern is protecting: the same method name, cancel(), must mean something different, or be forbidden, depending on lifecycle stage, and each ConcreteState is the natural place to express that difference explicitly rather than folding it into one shared method's conditional logic.
7. What is a terminal state in a state machine, and how should DeliveredState and CancelledState be implemented to correctly reject every further transition attempt?
A terminal state is one with no legal outgoing transitions; once the Context enters it, its lifecycle is over. In the order example, DeliveredState and CancelledState are both terminal, so every one of their ship, deliver, and cancel methods should throw, never silently succeed or silently do nothing, since a silent no-op would hide a real bug in the caller from ever surfacing.
class DeliveredState implements OrderState {
@Override public void ship(Order order) { throw new IllegalStateException("Order already delivered"); }
@Override public void deliver(Order order) { throw new IllegalStateException("Order already delivered"); }
@Override public void cancel(Order order) { throw new IllegalStateException("Cannot cancel a delivered order"); }
}
ship() twice will believe the second call succeeded when nothing actually happened.8. Write the complete Order context class for the order-lifecycle example, including how it exposes its current status to callers without leaking the internal State object.
The Context owns the mutable state reference, exposes the same public API its callers always used (ship, deliver, cancel), and additionally exposes a read-only way to inspect the current stage, typically by asking the State object for a display name or enum tag rather than exposing the State object itself, since callers outside the package have no business calling methods on a ConcreteState directly.
class Order {
private final String id;
private OrderState state = new PendingState();
Order(String id) { this.id = id; }
public void ship() { state.ship(this); }
public void deliver() { state.deliver(this); }
public void cancel() { state.cancel(this); }
public String currentStatusName() { return state.getClass().getSimpleName(); }
void setState(OrderState state) { this.state = state; }
void recordShippedTimestamp() { /* ... */ }
void recordDeliveredTimestamp() { /* ... */ }
void recordCancellation(String reason) { /* ... */ }
}
setState is package-private, not public, so only ConcreteState classes in the same package can trigger a transition; external callers can only ask for a state change through the public action methods, which keeps the transition rules enforced in one place.
9. What is the recommended way to handle an illegal transition attempt, such as calling ship() on an already-shipped order, and why is silently ignoring the call the wrong choice?
Throw an unchecked exception, typically IllegalStateException with a message naming both the attempted action and the current state, so the caller learns immediately and specifically what went wrong. This is preferable to a checked exception because most call sites cannot meaningfully recover from an illegal transition inline; it usually indicates a bug in the calling code or a genuine business-rule violation that should surface loudly.
Silently ignoring the call, or quietly returning without effect, is the wrong choice because it hides the mistake from the caller: a duplicate "ship" button click, a retried message from a queue, or a race condition would all appear to succeed even though nothing happened, which is far more dangerous than a loud, immediate failure during development and testing.
10. In a well-designed State pattern implementation, should the ConcreteState decide the next state, or should the Context decide it? Justify your answer.
The ConcreteState should decide the next state, because it is the one piece of code that actually knows the full set of legal transitions out of its own stage. If the Context decided transitions instead, it would need its own conditional logic mapping "current state plus action" to "next state," which reintroduces exactly the giant switch statement the pattern exists to eliminate.
Concretely, this means each ConcreteState method both performs the action's side effect and calls context.setState(nextState) itself, as shown in the PendingState.ship() example above, rather than returning an enum or state object for the Context to interpret and act on.
11. Describe an alternative design where transition methods return the next State object instead of calling setState() directly, and discuss its trade-offs versus the state-calls-setState() approach.
Instead of a void method that mutates the Context internally, each State method can return the next State object, and the Context assigns the result itself: state = state.ship(this);. This makes the State classes easier to unit test in isolation, since a test can call pendingState.ship(order) and simply assert on the returned object without needing to inspect the order's internal field afterward.
interface OrderState {
OrderState ship(Order order); // returns the next state instead of setting it
}
class PendingState implements OrderState {
@Override
public OrderState ship(Order order) {
order.recordShippedTimestamp();
return new ShippedState();
}
}
The trade-off is that the Context must remember to assign the return value on every call site, and it is easy to accidentally discard the returned state if a developer forgets the assignment, silently leaving the Context stuck in its old state despite the action appearing to succeed.
12. Why do State methods typically accept the Context as a parameter rather than the ConcreteState holding a permanent reference back to its Context?
Passing the Context as a parameter to each method lets a single ConcreteState instance be reused across many different Context instances, since the state object never stores which Context it belongs to; it only receives one temporarily, for the duration of that one method call. If instead each ConcreteState held a permanent back-reference to one specific Context, a fresh ConcreteState instance would be required per Context, which usually is not necessary since most ConcreteState classes carry no per-context data of their own.
This parameter-passing style is exactly what enables the common optimization of sharing stateless ConcreteState singletons across every Context in the system, discussed in more detail elsewhere, since the state objects hold no context-specific fields to keep separate.
13. Explain when ConcreteState instances can safely be implemented as stateless singletons shared across many Context instances, and show how to wire this up in Java.
When a ConcreteState class holds no fields of its own, no per-context data, only behavior parameterized by the Context passed into each method, a single shared instance is completely safe to reuse across every Context in the system, because there is nothing in the instance itself that could differ between two orders both currently pending. This avoids allocating a fresh PendingState object every time an order is created.
class PendingState implements OrderState {
static final PendingState INSTANCE = new PendingState();
private PendingState() {}
@Override
public void ship(Order order) {
order.recordShippedTimestamp();
order.setState(ShippedState.INSTANCE);
}
// ...
}
class Order {
private OrderState state = PendingState.INSTANCE;
}
14. Describe a subtle bug that occurs when a developer later adds a mutable, per-context field to a ConcreteState that was originally designed and deployed as a shared stateless singleton.
Suppose ShippedState.INSTANCE is shared across every order, and a developer later adds a field like private LocalDate lastCarrierCheckAt; to track when the order's shipment was last polled, intending it to be per-order data. Because the singleton instance is shared, that field is actually shared too: checking the carrier for order A silently overwrites the value that order B's logic also reads, and the two orders' shipment-tracking data becomes cross-contaminated with no compiler warning anywhere.
class ShippedState implements OrderState {
static final ShippedState INSTANCE = new ShippedState();
private LocalDate lastCarrierCheckAt; // BUG: shared across every order using this singleton
}
15. Under what circumstances should a ConcreteState be instantiated fresh per Context rather than shared as a singleton, and what does that implementation look like?
Whenever a ConcreteState genuinely needs to carry data that is specific to one Context, for example a retry counter for a failed payment attempt, or a captured timestamp for when that specific order entered this stage, it must be a distinct instance per Context rather than a shared singleton, since sharing it would corrupt that data across unrelated Contexts exactly as described in the singleton bug above.
class AwaitingPaymentState implements OrderState {
private int retryCount = 0; // genuinely per-order data, cannot be a shared singleton
@Override
public void retryPayment(Order order) {
retryCount++;
if (retryCount > 3) {
order.setState(new CancelledState());
}
}
}
The rule of thumb is simple: no mutable per-context fields, safe to share as a singleton; any mutable per-context field, must be a fresh instance created at the point of transition into that state.
16. Explain how to implement a lightweight state machine in Java using an enum with abstract methods overridden per constant, as an alternative to a full class-per-state hierarchy.
Java enums can declare an abstract method in the enum body, and each constant supplies its own implementation in a constant-specific class body, which gives you polymorphic per-state behavior without writing a separate top-level class for every state. This works well when the number of states is fixed and known at compile time and each state's logic is reasonably short.
enum OrderStatus {
PENDING {
@Override OrderStatus ship() { return SHIPPED; }
@Override OrderStatus cancel() { return CANCELLED; }
},
SHIPPED {
@Override OrderStatus deliver() { return DELIVERED; }
},
DELIVERED {
// no further transitions; inherits the default throwing behavior below
},
CANCELLED {
// terminal; inherits the default throwing behavior below
};
OrderStatus ship() { throw new IllegalStateException("Cannot ship from " + this); }
OrderStatus deliver() { throw new IllegalStateException("Cannot deliver from " + this); }
OrderStatus cancel() { throw new IllegalStateException("Cannot cancel from " + this); }
}
17. Continue the OrderStatus enum example and show how the Context class uses it, including how the enum-based approach differs from the class-per-state approach in terms of where the current state lives.
The Context now holds an OrderStatus field directly instead of a State-interface reference, and each action method reassigns that field to whatever the enum constant's method returns, rather than calling setState on itself from inside a ConcreteState class.
class Order {
private OrderStatus status = OrderStatus.PENDING;
public void ship() { status = status.ship(); }
public void deliver() { status = status.deliver(); }
public void cancel() { status = status.cancel(); }
public OrderStatus getStatus() { return status; }
}
Structurally this is still the State pattern in spirit, behavior varies by which constant is active, and transitions are centralized inside the enum, but the enum constants themselves cannot hold per-context mutable data the way a full ConcreteState class can, which is the key limitation covered next.
18. At what point does the enum-based state machine approach become unwieldy, and what specifically makes a full class-per-state hierarchy worth the extra ceremony at that point?
The enum approach becomes unwieldy once a state needs its own fields, constructor parameters, or several collaborating helper objects, since enum constants share one constructor signature across the whole enum and cannot easily carry meaningfully different per-constant state without awkward workarounds. It also strains once per-state logic grows long or needs its own unit tests in true isolation, since testing one constant's behavior means invoking it through the shared enum type rather than instantiating a small, focused class.
A full class-per-state hierarchy is worth the added files and interfaces once you need: per-instance mutable data (see Q14 and Q15), constructor-injected collaborators such as a carrier API client used only by ShippedState, or a large number of methods where an enum's constant-specific bodies would become sprawling and hard to navigate in one file.
| Signal | Enum-based state machine | Full class-per-state |
|---|---|---|
| Per-state mutable data | Awkward, effectively shared across all uses of that constant | Natural, just a field on the class |
| Constructor-injected collaborators | Painful, one shared enum constructor | Natural, standard constructor injection |
| Number of states | Best for a small, fixed set | Scales to many, each in its own file |
19. Show how to use an EnumMap keyed by current state, mapping to an EnumSet of legal next states, to validate transitions in an enum-based state machine without duplicating the rules inside every method.
Rather than hardcoding legality checks inside each enum constant's method body, a single static EnumMap<OrderStatus, EnumSet<OrderStatus>> can declare every legal transition in one place, and a shared helper method checks against it before allowing any transition, which keeps the transition table readable as one data structure instead of scattered logic.
enum OrderStatus {
PENDING, SHIPPED, DELIVERED, CANCELLED;
private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = new EnumMap<>(OrderStatus.class);
static {
TRANSITIONS.put(PENDING, EnumSet.of(SHIPPED, CANCELLED));
TRANSITIONS.put(SHIPPED, EnumSet.of(DELIVERED));
TRANSITIONS.put(DELIVERED, EnumSet.noneOf(OrderStatus.class));
TRANSITIONS.put(CANCELLED, EnumSet.noneOf(OrderStatus.class));
}
OrderStatus transitionTo(OrderStatus next) {
if (!TRANSITIONS.get(this).contains(next)) {
throw new IllegalStateException("Cannot go from " + this + " to " + next);
}
return next;
}
}
20. java.lang.Thread.State is a JDK enum describing a thread's lifecycle (NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED). Is this a State pattern implementation, and why might the JDK have chosen a plain enum with no per-state behavior instead?
Thread.State is not really an implementation of the GoF State pattern; it is a plain descriptive enum with no methods at all, used purely so external code can query thread.getState() for observability and debugging. The actual thread-scheduling behavior that differs by lifecycle stage lives deep inside the JVM's native thread-scheduling code, not in per-constant Java method overrides, because that behavior is managed by the operating system's scheduler and the JVM runtime, not by ordinary polymorphic dispatch.
This is a useful distinction to raise in an interview: not every "current lifecycle stage" enum is an application of the State pattern; the State pattern specifically requires that an object's own behavior, its own method implementations, changes with the stage, whereas Thread.State is only a passive, read-only snapshot label.
21. Introduce the comparison between State and Strategy: why do interviewers consider this the single most common question about the State pattern, and what is the one-sentence distinction?
State and Strategy are the two GoF behavioral patterns most often confused because their class diagrams are nearly identical: a Context holds a reference to an interface, and one or more classes implement that interface differently. Interviewers lean on this comparison heavily because reciting the UML tells you nothing; the distinction is entirely about intent and who controls the swap.
The one-sentence distinction: Strategy lets a client explicitly choose, and rarely change, which algorithm a Context uses to do one job, while State lets a Context's own internal lifecycle drive automatic, often frequent, swaps between behavior objects as a natural consequence of the object's own history, not the client's choice.
22. State and Strategy have nearly identical UML diagrams. Walk through exactly why the diagrams look the same and what structural elements, if any, actually differ between the two patterns.
Both patterns show a Context class holding a reference to an interface type, and one or more concrete classes implementing that interface with different bodies for the same method signatures; drawn as a class diagram with no annotations, the two are indistinguishable. Neither pattern's UML captures who calls the "set the current implementation" method, how often it changes, or why it changes, and that missing information is exactly where the real difference lives.
The only structural nuance sometimes present is that ConcreteState classes are more likely to hold a reference to, or receive, the Context so they can trigger the next transition themselves, whereas ConcreteStrategy classes usually do not need to know about the Context at all since they just execute an algorithm and return; but this is a common convention, not a mandatory structural rule enforced by either pattern.
23. In the State versus Strategy comparison, who is responsible for triggering the swap of the active object, the client or the object itself, and how does that answer differ between the two patterns?
With Strategy, the client is responsible: code outside the Context decides "use the QuickSort strategy" or "use the MergeSort strategy" and calls a setter, typically once, at configuration time or per call, and the Context itself never decides to swap strategies on its own initiative.
With State, the object itself is responsible: a ConcreteState's own method, running as a consequence of the Context's own action such as ship(), calls context.setState(nextState) internally. The client that called order.ship() never explicitly says "now switch to ShippedState"; that swap is an implementation detail of the order's own lifecycle, invisible to and uncontrolled by the caller.
24. Write a minimal Strategy example and a minimal State example side by side in Java, and point out precisely where in the code the two patterns' intents diverge despite similar-looking classes.
Notice that the Strategy's setStrategy is called once by external code, sorter, before any sorting happens, and never again by the strategy itself. The State's setState is called from inside PendingState.ship(), by the state object, as a direct consequence of the action just performed, never by whatever code originally invoked order.ship().
// Strategy: client explicitly picks and rarely swaps the algorithm
interface SortStrategy { void sort(int[] data); }
class Sorter {
private SortStrategy strategy;
void setStrategy(SortStrategy strategy) { this.strategy = strategy; } // client calls this
void sort(int[] data) { strategy.sort(data); }
}
// State: the object swaps its own behavior as a side effect of its own action
interface OrderState { void ship(Order order); }
class PendingState implements OrderState {
public void ship(Order order) {
order.setState(new ShippedState()); // the STATE calls this, not the client
}
}
25. What is the common interview trap when discussing State versus Strategy, and how would a strong candidate avoid falling into it?
The common trap is reciting the class-diagram similarity and stopping there, "they both have an interface and a Context that delegates to it," which sounds like an answer but demonstrates no actual understanding of when to reach for either pattern. A related trap is claiming one is strictly "better" or that State is simply "Strategy plus transitions," which oversimplifies the difference in intent down to a structural footnote.
A strong candidate instead grounds the answer in a concrete scenario for each: naming a specific case where the behavior swap is client-initiated and rare (Strategy: choosing a compression algorithm for a file export feature) versus a case where the swap is internally-driven and expected as part of normal operation (State: an order moving through its lifecycle), and explicitly stating that the deciding question is "who initiates the change, and why," not "what does the diagram look like."
26. Compare the State pattern to the Command pattern. Both patterns involve encapsulating behavior in a separate object; explain the key difference in what each object represents.
A Command object encapsulates a request or an action to be performed, typically as a single method, execute(), and is designed to be queued, logged, undone, or invoked later, decoupling the object that triggers an action from the object that performs it. A State object encapsulates the behavior appropriate to one lifecycle stage across potentially many methods, and it is swapped out as a whole unit when the lifecycle advances, not invoked once and discarded like a typical command.
They do combine naturally: a workflow engine might use Command objects to represent each possible transition action (queued, retryable, undoable) while using the State pattern to track and validate which lifecycle stage the workflow is currently in and which Command objects are legal to invoke from it.
27. Compare the State pattern to the Observer pattern. Could a state transition system reasonably use both together, and if so, how?
State is about an object's own behavior changing based on its own internal lifecycle; Observer is about notifying independent, potentially unrelated listener objects whenever something of interest happens, with no implication that the subject's own method dispatch changes at all. They solve different problems, but they combine well: a Context can use State internally to manage legal transitions and per-stage behavior, and additionally implement the Observer pattern to notify external listeners, such as an audit log or a UI badge, whenever a transition actually occurs.
class Order {
private OrderState state = new PendingState();
private final List<OrderListener> listeners = new ArrayList<>();
void setState(OrderState newState) {
OrderState old = this.state;
this.state = newState;
listeners.forEach(l -> l.onTransition(old, newState));
}
}
28. Design a mechanism for firing transition-event notifications whenever a Context's state changes, so external systems such as an audit log or a message queue publisher can react without the ConcreteState classes needing to know about them directly.
Centralize the notification inside the Context's setState method rather than inside each ConcreteState, so a ConcreteState only ever calls order.setState(next) and has no awareness that listeners even exist; the Context is the single place responsible for broadcasting that a transition happened, keeping ConcreteState classes focused purely on lifecycle logic.
void setState(OrderState newState) {
OrderState previous = this.state;
this.state = newState;
eventPublisher.publish(new OrderTransitioned(id, previous.getClass(), newState.getClass()));
}
This also makes it trivial to add new listeners, metrics, audit trails, downstream event consumers, without touching any ConcreteState class, since they all funnel through this one Context method.
29. Give an overview of dedicated state-machine libraries available in the Java ecosystem, such as Spring State Machine, and explain what capabilities they offer beyond a hand-rolled State pattern implementation.
Spring State Machine is the most widely used option in Spring-based Java applications; it lets you declare states, events, and transitions (including guards and entry/exit actions) either via a fluent configuration API or an external DSL, and it provides built-in support for hierarchical and parallel regions, state persistence, and visual diagram export. Other libraries in the broader JVM ecosystem, such as Squirrel Foundation, offer similar declarative transition configuration with less Spring-specific ceremony.
Compared to a hand-rolled State pattern, these libraries add: declarative guard conditions evaluated before a transition is allowed, first-class entry/exit action hooks, built-in state persistence adapters for common databases, and often a way to export the configured machine as a visual diagram for documentation, all of which would otherwise need to be built by hand.
30. Show a minimal Spring State Machine configuration for the order-lifecycle example, defining states, events, and transitions declaratively instead of writing ConcreteState classes by hand.
Spring State Machine configuration typically extends StateMachineConfigurerAdapter and declares the set of states, the initial state, and each transition as a source state, target state, and triggering event, all in one fluent builder chain rather than as separate Java classes per state.
@Configuration
@EnableStateMachine
class OrderStateMachineConfig extends StateMachineConfigurerAdapter<OrderStatus, OrderEvent> {
@Override
public void configure(StateMachineStateConfigurer<OrderStatus, OrderEvent> states) throws Exception {
states.withStates()
.initial(OrderStatus.PENDING)
.states(EnumSet.allOf(OrderStatus.class));
}
@Override
public void configure(StateMachineTransitionConfigurer<OrderStatus, OrderEvent> transitions) throws Exception {
transitions
.withExternal().source(OrderStatus.PENDING).target(OrderStatus.SHIPPED).event(OrderEvent.SHIP).and()
.withExternal().source(OrderStatus.SHIPPED).target(OrderStatus.DELIVERED).event(OrderEvent.DELIVER);
}
}
This trades away the flexibility of arbitrary Java code per state for a declarative, centrally reviewable transition table, which is often preferable once a workflow has many states and stakeholders who need to review the rules without reading Java.
31. Implement a hand-rolled transition table using a Map of (current state, event) pairs to next states, as a lighter-weight alternative to pulling in a full state-machine library.
A record-keyed map lets you express the entire legal-transition table as data, one line per legal move, which is easy to review, unit test exhaustively, and extend without touching any conditional logic. Illegal combinations simply are absent from the map, and a lookup miss becomes the trigger for rejecting the transition.
record TransitionKey(OrderStatus from, OrderEvent event) {}
class TransitionTable {
private static final Map<TransitionKey, OrderStatus> TABLE = Map.of(
new TransitionKey(OrderStatus.PENDING, OrderEvent.SHIP), OrderStatus.SHIPPED,
new TransitionKey(OrderStatus.PENDING, OrderEvent.CANCEL), OrderStatus.CANCELLED,
new TransitionKey(OrderStatus.SHIPPED, OrderEvent.DELIVER), OrderStatus.DELIVERED
);
OrderStatus next(OrderStatus current, OrderEvent event) {
OrderStatus result = TABLE.get(new TransitionKey(current, event));
if (result == null) throw new IllegalStateException("No transition for " + current + " on " + event);
return result;
}
}
32. What is a guarded transition, and how would you add guard conditions, beyond just the current state, to a State pattern or transition-table implementation?
A guarded transition is one that is only legal when both the current state matches and some additional runtime condition holds true, for example an order can only move from SHIPPED to DELIVERED if a delivery confirmation payload was actually provided. Guards let the same state-and-event pair conditionally succeed or fail depending on extra business data, rather than being unconditionally legal or illegal.
class ShippedState implements OrderState {
@Override
public void deliver(Order order, DeliveryConfirmation confirmation) {
if (confirmation == null || !confirmation.isSigned()) {
throw new IllegalStateException("Cannot deliver without a signed confirmation");
}
order.setState(new DeliveredState());
}
}
State-machine libraries like Spring State Machine support guards as first-class configuration (a Guard<S, E> functional callback evaluated before the transition fires); a hand-rolled implementation simply adds the condition check as an ordinary if statement inside the relevant ConcreteState method or transition-table lookup.
33. Explain entry and exit actions in state-machine terminology, and show how to implement onEnter/onExit hooks in a Java State pattern implementation.
An entry action runs automatically the moment a Context arrives in a given state, regardless of which transition led there; an exit action runs automatically the moment it leaves, regardless of which transition it is taking next. This is useful for logic that should always happen on entering or leaving a stage, such as starting a timeout timer on entering AwaitingPaymentState or cancelling that timer on leaving it, without duplicating that logic in every transition method that might lead into or out of the state.
interface OrderState {
default void onEnter(Order order) {}
default void onExit(Order order) {}
}
void setState(OrderState newState) {
state.onExit(this);
this.state = newState;
newState.onEnter(this);
}
34. Model a multi-step approval workflow (Submitted, ManagerReview, FinanceReview, Approved, Rejected) using the State pattern, and describe how it would handle a rejection at any review stage.
Each review stage is its own ConcreteState implementing shared methods like approve(request) and reject(request, reason). ManagerReviewState.approve() transitions to FinanceReviewState, while FinanceReviewState.approve() transitions to the terminal ApprovedState; critically, both review states' reject() methods transition directly to the same terminal RejectedState, regardless of which stage the rejection happened at.
class ManagerReviewState implements ApprovalState {
@Override public void approve(Request r) { r.setState(new FinanceReviewState()); }
@Override public void reject(Request r, String reason) { r.recordReason(reason); r.setState(new RejectedState()); }
}
This design cleanly captures a common real-world rule: many parallel review stages can all reject into one shared terminal state, but approval always advances linearly to the next specific stage, which is exactly the kind of asymmetric transition graph the State pattern expresses naturally.
35. TCP connection states (CLOSED, LISTEN, SYN_SENT, ESTABLISHED, FIN_WAIT, and others) are a textbook state-machine example. How would you model a simplified version of this in Java using the State pattern?
Each TCP state becomes a ConcreteState implementing shared methods for the socket events that can occur, such as receiveSyn(), receiveAck(), and close(). Most states only legally handle a subset of these events; for example ClosedState only accepts an active-open or passive-open, and EstablishedState only accepts data transfer events and a close request, with every other event method throwing or being logged as a protocol violation.
interface TcpState {
TcpState receiveSyn();
TcpState receiveAck();
TcpState close();
}
class ListenState implements TcpState {
@Override public TcpState receiveSyn() { return new SynReceivedState(); }
@Override public TcpState receiveAck() { throw new IllegalStateException("Unexpected ACK in LISTEN"); }
@Override public TcpState close() { return new ClosedState(); }
}
This example is popular in interviews precisely because the real TCP specification defines an exhaustive, unambiguous transition table, making it a clean, well-bounded domain to demonstrate the pattern without inventing artificial business rules.
36. Model a game character controller with Idle, Running, and Jumping states using the State pattern, including how it should handle an attempt to jump while already jumping.
Each state implements shared input-handling methods such as onMoveInput() and onJumpInput(). IdleState.onMoveInput() transitions to RunningState; both IdleState.onJumpInput() and RunningState.onJumpInput() transition to JumpingState. Crucially, JumpingState.onJumpInput() should either be a no-op (ignore the extra jump press) or trigger a deliberate double-jump mechanic, a decision the game designer makes explicitly rather than accidentally, rather than silently re-entering the same jumping state and resetting jump physics unexpectedly.
class JumpingState implements CharacterState {
@Override
public void onJumpInput(Character character) {
// deliberate no-op: mid-air jump presses are ignored unless double-jump is a designed feature
}
@Override
public void onLanded(Character character) {
character.setState(new IdleState());
}
}
37. Describe how the State pattern applies to UI component states, such as a button that can be Normal, Hovered, Pressed, or Disabled, and why this is a common front-end use case for the pattern even outside of Java.
A UI widget's rendering and input-handling logic legitimately differs per visual state: a DisabledState ignores click and hover events entirely and renders in a muted style, while a PressedState renders a highlighted style and, on release, fires the click action and transitions back to Hovered or Normal depending on whether the pointer is still over the widget. Encoding this as a State pattern (or its equivalent in other UI frameworks and languages) keeps rendering logic and input-handling logic for each visual state together and out of one large conditional-laden render method.
This is a common cross-language use case because UI toolkits across ecosystems, not just Java Swing or JavaFX, model widget states this way conceptually, even when the actual implementation is a simple enum or a set of CSS-like pseudo-classes rather than a formal class-per-state hierarchy; the underlying idea, behavior varies by current visual/interaction state, is identical.
38. Explain how a hand-written lexer or parser can use the State pattern to model its scanning modes, such as NormalMode, InsideStringLiteralMode, and InsideCommentMode.
A character-by-character scanner's interpretation of each incoming character depends entirely on its current mode: in NormalMode a " character starts a string literal and transitions to InsideStringLiteralMode, while that same " character inside InsideStringLiteralMode ends the literal and transitions back to NormalMode. Modeling each scanning mode as a ConcreteState keeps the character-dispatch logic for each mode separate and testable in isolation, instead of one large method switching on a mode flag for every character read.
interface LexerState { LexerState consume(char c, StringBuilder buffer); }
class NormalMode implements LexerState {
@Override
public LexerState consume(char c, StringBuilder buffer) {
if (c == '"') return new InsideStringLiteralMode();
buffer.append(c);
return this;
}
}
39. Model a document's editorial lifecycle (Draft, InReview, Published, Archived) using the State pattern, and explain which operations should be legal in each stage.
DraftState allows editing and submitting for review; InReviewState allows approving (to Published) or rejecting back to Draft, but not direct editing, since a document under review should not silently change beneath the reviewer; PublishedState allows archiving but not editing directly, typically requiring an explicit "create new draft revision" operation instead; ArchivedState is terminal and allows only read access.
class InReviewState implements DocumentState {
@Override public void edit(Document d, String newContent) {
throw new IllegalStateException("Cannot edit a document while it is under review");
}
@Override public void approve(Document d) { d.setState(new PublishedState()); }
@Override public void reject(Document d) { d.setState(new DraftState()); }
}
40. Model a media player with Stopped, Playing, and Paused states using the State pattern, and explain why play() should behave differently when called from Stopped versus from Paused.
StoppedState.play() must start playback from the beginning, resetting the playback position to zero, while PausedState.play() must resume playback from wherever it was paused, without resetting position. Both transition to PlayingState, but the side effect performed before that transition is materially different, exactly the kind of same-method-name-different-behavior distinction the State pattern is built to express cleanly.
class StoppedState implements PlayerState {
@Override public void play(MediaPlayer player) {
player.seekTo(0);
player.startPlayback();
player.setState(new PlayingState());
}
}
class PausedState implements PlayerState {
@Override public void play(MediaPlayer player) {
player.resumePlayback(); // no seek; continues from paused position
player.setState(new PlayingState());
}
}
41. Write a JUnit test that verifies an illegal transition, such as calling deliver() on a PendingState order, correctly throws an exception and leaves the order's state unchanged.
A good illegal-transition test asserts two things: that the expected exception type is thrown, and that the Context's current state was not mutated by the failed attempt, since a correct implementation should validate before transitioning, never partially apply a transition and then throw.
@Test
void deliverFromPendingThrowsAndLeavesStateUnchanged() {
Order order = new Order("o-1"); // starts in PendingState
assertThrows(IllegalStateException.class, order::deliver);
assertEquals("PendingState", order.currentStatusName());
}
42. Write a JUnit test that verifies a legal transition, such as shipping a pending order, correctly moves the Context into the expected next state and performs the expected side effect.
A legal-transition test should assert both the resulting state and any observable side effect the transition was supposed to perform, such as a recorded timestamp, since a transition that changes state but forgets its side effect (or vice versa) is just as buggy as one that does neither.
@Test
void shipFromPendingMovesToShippedAndRecordsTimestamp() {
Order order = new Order("o-1");
order.ship();
assertEquals("ShippedState", order.currentStatusName());
assertNotNull(order.getShippedAt());
}
43. Discuss the difference between unit testing individual ConcreteState classes in isolation and integration testing a Context's full lifecycle across multiple transitions, and when each is worth writing.
Unit tests targeting one ConcreteState directly, calling its methods with a mocked or minimal Context, verify that class's specific legal and illegal transitions in isolation, and are cheap and fast, especially useful once a state has enough branching logic of its own to justify dedicated coverage. Integration tests instead drive a real Context through a realistic multi-step sequence, such as pending, then shipped, then delivered, verifying the whole lifecycle behaves correctly end to end, including that side effects from earlier stages persist correctly into later ones.
Both are worth writing: unit tests catch a broken transition rule quickly and pinpoint exactly which class is at fault, while integration tests catch bugs that only manifest across a sequence, such as a field set in one stage being read incorrectly two stages later.
44. Explain how you would mock the State interface itself to test that a Context correctly delegates a call to whatever state is currently installed, without depending on any real ConcreteState's logic.
Inject a mock OrderState directly into the Context (via a package-private setter, constructor, or test-only accessor) and verify that calling the Context's public method invokes the exact same method on the mock with the Context itself as the argument, proving the delegation wiring is correct independent of what any real ConcreteState actually does.
@Test
void shipDelegatesToCurrentState() {
OrderState mockState = mock(OrderState.class);
Order order = new Order("o-1");
order.setState(mockState); // package-private test-only access
order.ship();
verify(mockState).ship(order);
}
45. Describe a race condition that can occur when two threads concurrently call transition methods on the same shared Context instance, such as two threads both calling ship() at nearly the same time.
If ship() reads the current state, checks it is legal, performs a side effect, and then assigns the new state, all without synchronization, two threads racing through that sequence can both read the same starting state before either writes the new one, both proceed past the legality check, and both perform the side effect, for example both threads charge a shipping carrier or both threads decrement inventory, then both write a "next state," with the second write silently overwriting the first and losing track of which side effect actually reflects reality.
// UNSAFE without synchronization
void ship() {
if (!(state instanceof PendingState)) throw new IllegalStateException();
// <-- another thread can interleave here and also pass this check
performShippingSideEffect();
state = new ShippedState();
}
46. Compare using a synchronized method versus a compare-and-swap approach with AtomicReference to make Context state transitions thread-safe, and discuss the trade-offs of each.
A synchronized method serializes every transition attempt through one lock, which is simple to reason about and correct by construction, but it can become a throughput bottleneck under heavy concurrent contention since only one thread can be inside the method at a time, and it can also block briefly if a side effect performed while holding the lock is slow. A compare-and-swap approach using AtomicReference<OrderState> instead lets multiple threads race to atomically swap the reference without blocking, retrying only the swap itself if another thread won first.
| Approach | Use when | Watch out for |
|---|---|---|
| synchronized method | Transition logic is simple and contention is low to moderate | Lock held during any side effect performed inside the method can create a bottleneck |
| AtomicReference + compare-and-swap | High contention, and the side effect can be safely separated from the state swap itself | More complex to reason about; retry loops need care to avoid duplicated side effects |
47. Show a concrete AtomicReference-based compare-and-swap implementation for transitioning a Context's state safely under concurrent access.
The Context stores AtomicReference<OrderState> instead of a plain field, and a transition loop repeatedly reads the current value, checks legality, and attempts compareAndSet, retrying only if another thread's transition won the race in between the read and the swap.
class Order {
private final AtomicReference<OrderState> state = new AtomicReference<>(new PendingState());
void ship() {
while (true) {
OrderState current = state.get();
OrderState next = current.nextOnShip(this); // throws if illegal from 'current'
if (state.compareAndSet(current, next)) {
return;
}
// another thread changed it first; loop and retry
}
}
}
48. What deadlock risks arise when a ConcreteState's transition method acquires a lock and then calls back into other synchronized Context methods, and how would you avoid it?
If ship() is a synchronized Context method, and inside it a ConcreteState's logic calls another synchronized method on a different object that, in turn, tries to call back into this same Context (directly or through an event listener registered synchronously), you can end up with two threads each holding one lock and waiting on the other, a classic circular-wait deadlock, especially in event-driven systems where a transition notification triggers another component's own locked operation.
The safest fix is to keep the synchronized critical section as small as possible, ideally just the state read-check-swap itself, and perform any side effects, event notifications, or calls into other objects outside the lock, after the transition has already been committed, so no lock is held while calling into code you do not control.
49. Describe how to persist a Context's current state to a relational database, and what column design you would use to represent which ConcreteState the row is currently in.
The simplest and most common approach stores a short, stable string or enum-backed code, such as "PENDING", "SHIPPED", in a status column, rather than trying to serialize the ConcreteState object itself, since ConcreteState objects are typically stateless and their identity as a Java class is not something a database row needs to preserve directly.
CREATE TABLE orders (
id UUID PRIMARY KEY,
status VARCHAR(20) NOT NULL, -- 'PENDING' | 'SHIPPED' | 'DELIVERED' | 'CANCELLED'
shipped_at TIMESTAMP NULL,
delivered_at TIMESTAMP NULL
);
Any per-state data that a non-singleton ConcreteState instance would have held in memory, such as a retry counter, should get its own dedicated column too, since that data needs to survive the process restarting, which an in-memory Java field obviously cannot do on its own.
50. Explain how to reconstruct the correct ConcreteState object from a persisted status string when an entity is loaded from the database, and where that mapping logic should live.
A small factory method, often static on the State interface itself or in a dedicated OrderStateFactory, maps each persisted status string to its corresponding ConcreteState instance (or shared singleton, if applicable), and the entity's loading code, whether a JPA @PostLoad callback or a manual repository mapping step, calls that factory instead of embedding the mapping logic inline.
class OrderStateFactory {
static OrderState fromPersistedName(String name) {
return switch (name) {
case "PENDING" -> PendingState.INSTANCE;
case "SHIPPED" -> ShippedState.INSTANCE;
case "DELIVERED" -> DeliveredState.INSTANCE;
case "CANCELLED" -> CancelledState.INSTANCE;
default -> throw new IllegalArgumentException("Unknown persisted status: " + name);
};
}
}
51. What is the safest way to roll out a database schema and code change that introduces a brand-new state into an existing state machine that has production rows sitting in every existing status?
Deploy the new ConcreteState class, its factory mapping entry, and any updated transition rules in a release that does not yet write the new status value anywhere, so existing rows and existing code paths are entirely unaffected; this validates the new state compiles and deploys cleanly with zero behavioral change. Only in a subsequent release do you start writing the new status value from the specific transition that produces it.
This staged rollout avoids a scenario where an older instance of the application, perhaps still running during a rolling deployment, encounters a status string it does not recognize yet and throws in the reconstruction factory, which would otherwise take down reads for any row already in the new state.
52. Why is documenting a state machine's transition diagram considered part of good API design, and what should such documentation include beyond just the list of states?
A state machine's real contract is not the list of possible statuses, it is which transitions between them are legal and under what conditions, and that information is exactly what callers need in order to write correct client code and avoid triggering illegal-transition exceptions in production. Documentation that only lists status values without the transition graph forces every consumer to reverse-engineer the rules by trial and error or by reading the implementation source directly.
Good documentation should include: the full set of states with a one-line description of each, a diagram or table of every legal transition and which action triggers it, any guard conditions attached to a transition, and which states are terminal, so a client can validate its own logic against the documented contract instead of discovering the rules from stack traces.
53. How would you auto-generate a visual state diagram directly from your Java state-machine code or configuration, rather than maintaining a hand-drawn diagram that can drift out of sync with the implementation?
If you use a declarative library like Spring State Machine, its configuration can typically be exported directly to a PlantUML representation of the state diagram, since the states, events, and transitions are already declared as structured data rather than scattered across imperative Java methods. For a hand-rolled transition table (see Q31), the table itself is just data, a map of (state, event) to next-state, so a small utility can walk that map and emit PlantUML or Graphviz DOT syntax describing the same graph.
Generating the diagram directly from the authoritative transition data, rather than drawing it by hand in a separate tool, guarantees the diagram can never silently drift out of sync with the actual enforced rules, since regenerating it after any code change reflects the current reality automatically.
54. Model an e-commerce checkout flow (CartOpen, AddressEntered, PaymentPending, PaymentConfirmed, OrderPlaced) using the State pattern, and explain why allowing users to navigate backward complicates the transition rules.
Forward transitions are straightforward, each state's "continue" action validates that stage's data and advances to the next. Allowing backward navigation, letting a user go from PaymentPending back to AddressEntered to fix a typo, complicates the model because now every state needs a legal "back" transition too, and re-entering a state that already ran side effects, such as reserving inventory, raises the question of whether those side effects need to be undone or are still valid.
class PaymentPendingState implements CheckoutState {
@Override
public void goBack(Checkout checkout) {
checkout.releaseReservedInventoryIfAny(); // must undo the forward side effect
checkout.setState(new AddressEnteredState());
}
}
55. Describe a real bug pattern where forgetting to validate a transition silently corrupts an object into an invalid state, and explain how proper State pattern usage would have prevented it.
Consider a naive setter-based implementation, order.setStatus(OrderStatus.DELIVERED), called directly from a webhook handler without checking the order's current status first. If that webhook fires twice, due to a retried delivery notification, or fires for an order that was actually cancelled moments earlier by a separate process, the order silently becomes DELIVERED despite that transition never having been valid, with no exception, no log entry, and no signal that anything went wrong until a customer complains about a "delivered" order they cancelled.
// BUG: no legality check, any status can jump to any other status
void handleDeliveryWebhook(Order order) {
order.setStatus(OrderStatus.DELIVERED); // silently overwrites CANCELLED
}
A correct State pattern implementation prevents this by construction: there is no public setter that bypasses the ConcreteState's own transition logic, so the webhook handler must call order.deliver(), which routes through CancelledState.deliver() and throws immediately, surfacing the bug the moment it happens instead of silently corrupting the record.
56. Walk through a cross-context corruption bug where a ConcreteState holding mutable per-context data was incorrectly shared as a singleton, and explain how it manifests differently from the naive-setter bug in Q55.
Unlike Q55's bug, where the mistake is an outright missing legality check, this bug happens even with fully correct transition logic: a developer adds a mutable field, say a payment retry counter, to a ConcreteState class that was originally written and deployed as a shared, stateless singleton (see Q13 and Q14), so every Context currently in that state ends up reading and writing the exact same counter value.
class AwaitingPaymentState implements OrderState {
static final AwaitingPaymentState INSTANCE = new AwaitingPaymentState();
private int retryCount = 0; // BUG: shared across every order simultaneously in this state
}
It manifests as bizarrely inconsistent behavior under load: order A's third payment retry might trigger order B's cancellation, because both orders share the exact same in-memory counter, and the bug is much harder to spot than Q55's, since every individual transition is legal and correctly validated; the corruption is purely in shared mutable data, not in the transition rules themselves.
57. What risk does exposing a public setState() method on the Context introduce, and how would you restrict it so only legitimate transition logic can invoke it?
If setState is public, any calling code anywhere, not just the ConcreteState classes responsible for enforcing legal transitions, can bypass every validation rule and force the Context directly into an arbitrary state, exactly recreating the Q55 bug through a different entry point. The whole safety guarantee of the pattern rests on transitions only ever happening through the validated action methods.
class Order {
void setState(OrderState state) { this.state = state; } // package-private, not public
}
Restricting setState to package-private visibility, with ConcreteState classes living in the same package, lets legitimate transition logic call it freely while preventing any code outside that package, including careless application code, from bypassing validated transitions.
58. Describe a bug caused by a missing default or fallback branch in a switch-based status check, and explain how the State pattern's compile-time exhaustiveness (via the interface) avoids this class of bug entirely.
A switch statement over an enum that lacks a default case (or, for an exhaustive switch expression, simply omits handling a newly added enum constant) can silently fall through with no exception at all in the pre-pattern-matching statement form, or throw only at runtime once the missing case is actually hit, often long after the new enum constant itself was added and deployed, since adding an enum constant does not force every existing switch to be revisited.
// BUG: no default, and ON_HOLD was added to the enum after this switch was written
switch (status) {
case PENDING: ship(); break;
case SHIPPED: deliver(); break;
// ON_HOLD silently falls through and does nothing, no compiler error
}
The State pattern avoids this class of bug structurally: adding a new ConcreteState means implementing the State interface's methods, and the Java compiler refuses to compile a class that does not implement every abstract method the interface declares, so there is no way to "forget" a state's behavior the way you can forget a switch branch.
59. When is introducing the full State pattern for a class with only two possible states overengineering, and what simpler alternative would you recommend instead?
A class with exactly two states and one trivial transition between them, such as a feature flag that is simply on or off with no other behavior variance, rarely benefits from a full class-per-state hierarchy; the ceremony of an interface plus two implementing classes adds more indirection than it removes complexity, since there was very little complexity to begin with.
A plain boolean field, or a two-constant enum with no abstract methods at all, checked with one simple if statement, is usually clearer and easier to navigate for genuinely simple two-state cases; reach for the full pattern once a third state appears, once per-state logic grows beyond a line or two, or once you notice the same conditional being duplicated across multiple methods.
60. Summarize the specific conditions under which you would recommend against using the State pattern, even for a class whose behavior does vary by an internal flag.
Avoid the State pattern when: there are only two simple states with no realistic prospect of a third being added, the differing behavior amounts to one or two lines per state rather than genuinely distinct method implementations, no state needs to carry its own data, and the class is unlikely to grow additional per-state methods over time. In all of these cases, a plain enum or boolean checked with a simple conditional communicates the logic more directly than a multi-class hierarchy would.
Also be cautious of introducing the pattern purely because "this is what the interview question expects"; production code should reach for it when the actual complexity signals described elsewhere in this guide, giant conditionals, per-state data, or a growing set of states, are genuinely present, not as a reflexive default for any object with a status field.
61. Explain how the State pattern satisfies the open/closed principle, and give a concrete example of extending a state machine with a new state without modifying any existing ConcreteState class.
The open/closed principle asks that code be open to extension but closed to modification; the State pattern achieves this because adding a new lifecycle stage means writing one new class implementing the existing State interface, without editing any existing ConcreteState's source code at all, so long as the new state only participates in transitions as a target, not by being inserted into the middle of existing logic.
// Adding OnHoldState requires zero changes to PendingState, ShippedState, DeliveredState, or CancelledState
class OnHoldState implements OrderState {
@Override public void ship(Order order) { throw new IllegalStateException("Cannot ship while on hold"); }
@Override public void resume(Order order) { order.setState(new PendingState()); }
}
The one caveat: if the new state needs to become a legal target from an existing state, such as allowing PendingState to transition into OnHoldState, that one existing class does need a small, additive change, a new method or a new branch, but every other existing ConcreteState remains completely untouched.
62. Walk through the complete checklist of changes required to add a brand-new state to an existing, production State pattern implementation, from the interface down to persistence.
Adding a state safely typically requires: writing the new ConcreteState class implementing every State interface method; updating the one or two existing ConcreteState classes that should be able to transition into it, adding the new legal transition; adding the new state to the persistence factory/mapping (Q50); updating any documentation or diagram (Q52/Q53); and adding both a unit test for the new state's own behavior and an integration test covering the newly legal transition path.
| Layer | Change required |
|---|---|
| State interface | Usually none, unless the new state needs a genuinely new action method |
| New ConcreteState class | Always, implementing every interface method |
| Existing ConcreteState(s) | Only the ones that should transition into the new state |
| Persistence factory | Add the new status string mapping |
| Tests and docs | New unit test, updated diagram, updated integration test |
63. Does the polymorphic dispatch used by the State pattern introduce meaningful runtime overhead compared to a simple switch statement, and how would you verify this if performance were a genuine concern?
A virtual method call through an interface reference is, in principle, marginally more expensive than a direct switch on a primitive or enum ordinal, but in practice the JVM's JIT compiler aggressively inlines and devirtualizes monomorphic or bimorphic call sites (where the actual runtime type is stable or one of very few options), which is extremely common for state machines that spend long periods in one state before transitioning. For the vast majority of applications, this difference is immeasurably small next to the cost of whatever the state's actual method body does, database calls, I/O, business logic.
If performance were a genuine, measured concern, for instance in a very high-throughput, low-latency trading system, the correct approach is a proper microbenchmark using JMH comparing the two approaches under realistic call-site polymorphism, not an assumption; guessing at JIT behavior without measuring is a common source of premature, misguided optimization.
64. Explain how the State pattern combines with the Factory pattern to centralize the creation of ConcreteState instances, particularly for singleton states.
Rather than scattering new ShippedState() calls (or singleton field references) across every ConcreteState that needs to transition into it, a small static factory method centralizes state creation behind one name, which makes it trivial to later change how a state is constructed, for example switching from "new instance every time" to "shared singleton," without touching every call site.
class OrderStates {
static OrderState pending() { return PendingState.INSTANCE; }
static OrderState shipped() { return ShippedState.INSTANCE; }
static OrderState delivered() { return DeliveredState.INSTANCE; }
}
// call sites read cleanly and don't care how the instance is actually produced
order.setState(OrderStates.shipped());
65. Explain how the State pattern combines with the Memento pattern to support undo functionality for an object whose behavior is state-dependent.
Memento captures and restores an object's internal state at a point in time without violating encapsulation, which pairs naturally with State: before a transition is applied, the Context can capture a Memento containing the current ConcreteState reference (plus any other relevant fields), push it onto an undo stack, and later restore it by simply reassigning the Context's state field back to the captured ConcreteState, effectively rewinding the lifecycle by one step.
class OrderMemento {
final OrderState savedState;
OrderMemento(OrderState state) { this.savedState = state; }
}
class Order {
private final Deque<OrderMemento> history = new ArrayDeque<>();
void ship() {
history.push(new OrderMemento(state));
state.ship(this);
}
void undo() {
if (!history.isEmpty()) this.state = history.pop().savedState;
}
}
66. What is a hierarchical (nested, or composite) state machine, and how does it differ structurally from a flat State pattern implementation with independent ConcreteState classes?
A hierarchical state machine groups related substates under a parent "superstate" so that behavior and transitions common to the whole group can be defined once on the superstate and inherited by every substate, rather than duplicated across each one. For example, an ActiveState superstate might define a shared onCancel() transition to CancelledState, while its substates AwaitingPaymentState and ProcessingState each add their own specific behavior on top of that shared transition, inheriting it rather than reimplementing it.
abstract class ActiveState implements OrderState {
@Override
public void cancel(Order order) { order.setState(new CancelledState()); } // shared by every active substate
}
class AwaitingPaymentState extends ActiveState { /* adds payment-specific methods */ }
class ProcessingState extends ActiveState { /* adds processing-specific methods */ }
A flat implementation instead has every ConcreteState independently implement every method, even ones that are identical across several states, which is simpler to reason about for small machines but leads to duplicated logic as the number of shared behaviors grows.
67. Describe how an event-driven architecture, where transitions are triggered by messages arriving on a queue rather than direct method calls, changes how you would implement the State pattern.
Instead of a caller directly invoking order.ship(), a message consumer receives an OrderShippedEvent from a queue, looks up the corresponding Context (typically reloading it from a database per Q49/Q50), and then calls the same underlying transition method the direct-call version would have used; the State pattern's core logic, the ConcreteState classes and their transition rules, does not need to change at all, only the entry point that triggers them.
@KafkaListener(topics = "order-events")
void handleShippedEvent(OrderShippedEvent event) {
Order order = orderRepository.load(event.orderId());
order.ship(); // same State-pattern-driven method as a direct synchronous call
orderRepository.save(order);
}
One added consideration in this style is idempotency: since messages can be redelivered, calling ship() twice for the same event must be handled gracefully, either by the terminal-state's own illegal-transition exception being caught and ignored at the consumer level, or by tracking processed event IDs separately.
68. Model the classic circuit breaker states (Closed, Open, HalfOpen) using the State pattern, describing the trigger for each transition.
ClosedState allows calls through normally and counts failures; once failures cross a threshold, it transitions to OpenState, which rejects calls immediately without attempting the underlying operation. After a configured timeout elapses, OpenState transitions to HalfOpenState, which allows a single trial call through; a success from HalfOpenState transitions back to ClosedState, while a failure sends it back to OpenState to wait out another timeout.
interface CircuitBreakerState {
CircuitBreakerState onCallSucceeded();
CircuitBreakerState onCallFailed();
boolean allowsCall();
}
69. Implement the ClosedState and OpenState classes for the circuit breaker example, including the failure-threshold logic that triggers the transition to Open.
ClosedState needs to track a rolling failure count (making it a non-singleton, per-breaker-instance state, per Q15, since that counter is genuinely per-context data), while OpenState needs to remember when it opened so it can know when its timeout has elapsed.
class ClosedState implements CircuitBreakerState {
private int consecutiveFailures = 0;
private static final int THRESHOLD = 5;
@Override public boolean allowsCall() { return true; }
@Override
public CircuitBreakerState onCallFailed() {
consecutiveFailures++;
return consecutiveFailures >= THRESHOLD ? new OpenState(Instant.now()) : this;
}
@Override
public CircuitBreakerState onCallSucceeded() { consecutiveFailures = 0; return this; }
}
class OpenState implements CircuitBreakerState {
private final Instant openedAt;
private static final Duration TIMEOUT = Duration.ofSeconds(30);
OpenState(Instant openedAt) { this.openedAt = openedAt; }
@Override
public boolean allowsCall() {
return Duration.between(openedAt, Instant.now()).compareTo(TIMEOUT) > 0;
}
}
70. Model a user session lifecycle (Anonymous, Authenticated, Expired) using the State pattern, and explain how expiration, a time-based transition, should be triggered.
Unlike most transitions in this guide, which are triggered by an explicit method call, expiration is time-based: the transition should happen whenever the session is next accessed after its expiry time has passed, checked lazily on access rather than requiring a background thread to proactively flip every session's state the instant it expires, which would not scale to large numbers of idle sessions.
class AuthenticatedState implements SessionState {
private final Instant expiresAt;
AuthenticatedState(Instant expiresAt) { this.expiresAt = expiresAt; }
@Override
public SessionState checkAccess(Session session) {
if (Instant.now().isAfter(expiresAt)) {
return new ExpiredState();
}
return this;
}
}
71. Model a shopping cart lifecycle (Empty, Active, CheckedOut, Abandoned) using the State pattern, and explain the design decision around whether adding an item while CheckedOut should be legal.
EmptyState.addItem() transitions to ActiveState; ActiveState.addItem() simply adds to the existing cart and stays in ActiveState. The key design decision is CheckedOutState.addItem(): once a cart has been checked out and payment is being processed, allowing further additions would mutate an order that downstream systems may already be acting on, so this should almost always throw, forcing the caller to explicitly start a new cart for additional items rather than silently reopening a supposedly finalized one.
class CheckedOutState implements CartState {
@Override
public void addItem(Cart cart, Item item) {
throw new IllegalStateException("Cannot modify a cart that has already been checked out");
}
}
72. What is an idempotent transition, and why might you deliberately design certain State pattern transitions to be idempotent rather than throwing on a repeated call?
An idempotent transition is one where calling it again from the state it already produces has no additional effect and does not throw, unlike the strict "always throw on any repeat" rule recommended elsewhere in this guide for genuinely illegal transitions. This is a deliberate, narrower exception: specifically for a retried call that represents the exact same logical operation, such as a webhook redelivery notifying you an order shipped when it is already recorded as shipped, treating the repeat as a harmless no-op is often safer and simpler than requiring every caller to separately track "have I already processed this event."
class ShippedState implements OrderState {
@Override
public void ship(Order order) {
// idempotent: already shipped, redelivery of the same event is a safe no-op, not an error
}
}
The key distinction from a true bug (Q55) is intent: this no-op is a deliberate design decision documented as such, made because the specific transition is known to be safely repeatable, not an accidental missing validation check.
73. What considerations go into designing the method signatures on the State interface itself, particularly around what data each transition method needs access to beyond the Context?
Some transitions genuinely need extra data beyond just the Context, for example deliver(Order order, DeliveryConfirmation confirmation) needs the confirmation payload to validate the guard condition from Q32, while others need nothing beyond the Context itself. Overloading every method with parameters "just in case" bloats the interface and forces every ConcreteState, including ones that ignore the extra data entirely, to accept parameters they do not use.
A cleaner design keeps the State interface's methods matching the actual domain actions one-to-one, passing only the data each specific action genuinely requires, and resists the temptation to pass a single catch-all context or request object into every method for convenience, since that obscures exactly what data each transition actually depends on.
74. Describe an immutable variant of the State pattern, where transition methods return a brand-new Context instance rather than mutating the existing one in place.
Instead of order.setState(next) mutating the existing Order in place, an immutable design has each transition method return an entirely new Order instance with the new state and any updated fields, leaving the original untouched. This trades the convenience of in-place mutation for the safety guarantees of immutability, no shared-mutable-state races (Q45), no accidental aliasing bugs, at the cost of callers needing to remember to use the returned instance.
final class ImmutableOrder {
private final String id;
private final OrderState state;
ImmutableOrder ship() {
return new ImmutableOrder(id, state.nextOnShip(this)); // returns a new instance
}
}
order = order.ship(); // caller must reassign; the old 'order' reference is now stale
75. How would you implement ConcreteState classes as Java records, and what limitations does the record's implicit immutability and constructor impose on this design?
A record works well for a ConcreteState that is genuinely immutable and needs to carry a small amount of per-context data as constructor parameters, since records give you that data plus equals/hashCode/toString for free, useful for logging and test assertions on which exact state instance a Context is in.
record AwaitingPaymentState(int retryCount) implements OrderState {
@Override
public OrderState retryPayment(Order order) {
return retryCount >= 3 ? new CancelledState() : new AwaitingPaymentState(retryCount + 1);
}
}
The limitation is that a record's fields are fixed at construction and cannot be mutated afterward, which is exactly the point for the immutable design in Q74, but it means a record cannot be used for the shared-singleton style from Q13 if that singleton were ever expected to carry evolving mutable data; each transition must produce a brand-new record instance instead.
76. How would sealed interfaces, introduced in modern Java, improve the State pattern's design by making the set of ConcreteState implementations exhaustive and closed?
A plain interface allows any class anywhere to implement it, meaning the compiler has no way to know the complete set of possible states, which limits what tooling (and, as covered next, pattern-matching switches) can verify at compile time. Declaring the State interface sealed with an explicit permits clause restricts implementations to a known, closed set of classes, letting the compiler enforce exhaustiveness elsewhere in the codebase.
sealed interface OrderState permits PendingState, ShippedState, DeliveredState, CancelledState {
void ship(Order order);
void deliver(Order order);
void cancel(Order order);
}
77. Combine a sealed State interface with a modern Java pattern-matching switch expression to implement a display-name lookup, and explain why the compiler can guarantee exhaustiveness here but not with a plain interface.
Because the sealed interface's permits clause lists every legal implementing class, a pattern-matching switch expression over that sealed type can omit a default branch entirely, and the compiler will refuse to compile the switch if a new ConcreteState is ever added to the permits list without a corresponding case being added here, catching a whole class of "forgot to handle the new state somewhere" bugs (see Q58) at compile time instead of runtime.
String displayName(OrderState state) {
return switch (state) {
case PendingState p -> "Pending";
case ShippedState s -> "Shipped";
case DeliveredState d -> "Delivered";
case CancelledState c -> "Cancelled";
// no default needed; compiler verifies every sealed permutation is covered
};
}
78. Given that sealed interfaces plus pattern-matching switches now offer compiler-enforced exhaustiveness, does this weaken the traditional argument for using polymorphic dispatch (the classic State pattern) instead of a switch statement?
It narrows the argument but does not eliminate it. The traditional worry about switch statements was that adding a new case could silently be forgotten somewhere with no compiler error; a sealed interface plus exhaustive pattern-matching switch closes exactly that gap for read-only, single-purpose queries like the display-name lookup in Q77.
Polymorphic dispatch is still preferable when the logic per state is substantial (multiple methods, meaningful internal helper logic, or per-state fields), since bundling all of that inside switch-expression arms scattered across many separate switch statements throughout the codebase reintroduces the "one state's logic is spread across N places" problem the pattern exists to solve; exhaustive switches are best suited to small, centralized, read-only queries over the current state, not as a wholesale replacement for the ConcreteState classes themselves.
79. Design an auditing mechanism that records every state transition an order goes through, including who or what triggered it, for later compliance review.
Similar to the transition-notification hook in Q28, the Context's centralized setState method is the natural place to append an immutable audit record, capturing the previous state, the new state, a timestamp, and, importantly, an actor identifier (a user ID, a service account, or "system" for time-based transitions) passed down from wherever the transition was originally triggered.
void setState(OrderState newState, String triggeredBy) {
auditLog.append(new TransitionRecord(id, state.getClass().getSimpleName(),
newState.getClass().getSimpleName(), Instant.now(), triggeredBy));
this.state = newState;
}
80. What transition-related metrics would you emit from a production State pattern implementation, and what operational questions would each one help answer?
Useful metrics include: a counter of successful transitions tagged by from-state and to-state, which reveals the actual distribution of paths through the lifecycle in production versus what was designed; a counter of rejected illegal-transition attempts tagged by attempted action and current state, which surfaces bugs in calling code or unexpected retry storms; and a timer measuring how long Contexts spend in each state before transitioning out, which flags stuck workflows, for example orders sitting in AwaitingPaymentState far longer than expected.
meterRegistry.counter("order.transition", "from", "PENDING", "to", "SHIPPED").increment();
meterRegistry.counter("order.transition.rejected", "state", "DELIVERED", "action", "ship").increment();
81. Design an undo/redo mechanism for transitions specifically, distinct from the general Memento-based undo in Q65, using a linear history of only the transition events themselves.
Rather than snapshotting the entire Context via Memento, a lighter-weight approach records just the sequence of transitions (from-state, to-state, timestamp) as a linear history list, with a cursor tracking the current position. Undo moves the cursor back and reapplies the recorded "from" state directly; redo moves it forward and reapplies the recorded "to" state, without needing to reconstruct any other Context fields, which works well when the ConcreteState objects themselves carry no data worth restoring beyond their own type.
class TransitionHistory {
private final List<OrderState> history = new ArrayList<>();
private int cursor = 0;
OrderState undo() { return cursor > 0 ? history.get(--cursor) : history.get(0); }
OrderState redo() { return cursor < history.size() - 1 ? history.get(++cursor) : history.get(cursor); }
}
82. Design a timeout-driven transition, where a Context automatically moves to a new state after a period of inactivity, without requiring an external caller to trigger it, such as an AwaitingPaymentState that auto-cancels after 15 minutes.
Two common approaches exist: a scheduled background job that periodically scans for Contexts whose time-in-state has exceeded the threshold and triggers the transition explicitly, which is simple and reliable but introduces some delay equal to the scan interval; or a per-context scheduled task (using a ScheduledExecutorService or a durable job scheduler) registered the moment the timed state is entered, which fires precisely on time but must be reliably cancelled if the Context transitions away before the timeout, and must survive process restarts if using an in-memory scheduler.
void enterAwaitingPayment(Order order) {
scheduler.schedule(() -> {
if (order.currentStatusName().equals("AwaitingPaymentState")) {
order.cancel(); // still waiting after the timeout; auto-cancel
}
}, 15, TimeUnit.MINUTES);
}
ScheduledExecutorService task is lost on process restart; for anything business-critical, prefer a durable, database-backed scheduler or a periodic batch scan instead.83. How does a reactive, event-driven programming style (using something like Project Reactor or RxJava) change how transition triggers are wired up compared to direct, synchronous method calls?
Instead of a caller directly invoking order.ship() and getting an immediate result, transitions become reactions to items flowing through a stream, a Flux<OrderEvent> subscribed with an operator that, for each event, loads the relevant Context, applies the transition, and emits a result or error further downstream, all without blocking the calling thread while doing so.
orderEventFlux
.flatMap(event -> orderRepository.load(event.orderId())
.doOnNext(order -> order.ship())
.flatMap(orderRepository::save))
.subscribe();
The ConcreteState classes and their transition rules remain exactly the same, only the mechanism triggering them changes, from a direct synchronous call to a reactive pipeline stage, which reinforces that the State pattern's core logic is orthogonal to whichever concurrency or I/O model the surrounding application uses.
84. In an event-sourced system, where an entity's current state is derived by replaying its full history of past events rather than being stored directly, how would you reconstruct the correct ConcreteState during replay?
Rather than persisting a status column directly (Q49), an event-sourced Order persists only the sequence of events that happened to it (OrderPlaced, OrderShipped, OrderDelivered); reconstructing the Order means starting from the initial state and folding each event over it in order, applying the same transition logic each event originally triggered, until the current state naturally falls out as the result of the replay.
Order replay(List<OrderEvent> events) {
Order order = new Order(); // starts in PendingState
for (OrderEvent event : events) {
order.apply(event); // routes to the matching transition method, same logic as live transitions
}
return order;
}
85. Explain how the State pattern supports the single responsibility principle, using the order-lifecycle example to show what "one responsibility" means for a ConcreteState class.
A well-designed ConcreteState has exactly one reason to change: the business rules governing that one specific lifecycle stage. ShippedState should change only if the rules for what happens while an order is shipped change, never because PendingState's rules changed, since the two classes' logic is fully separated. This is in direct contrast to the naive status-field implementation, where one shared method mixing every stage's logic has many reasons to change, any stage's rule change touches that same method.
A useful litmus test in code review: if a change to one lifecycle stage's business rule requires editing more than one ConcreteState class (excluding the class it is transitioning into), that is a signal responsibility has leaked across a boundary it should not have.
86. What is an encapsulation-leakage smell in a State pattern implementation, and give a concrete example of a ConcreteState exposing too much of its internal decision-making to the Context or to callers.
An encapsulation leak occurs when the Context, or code outside the State classes entirely, needs to know something about a ConcreteState's internal reasoning in order to work correctly, rather than the ConcreteState fully encapsulating that reasoning behind its method calls. A concrete example: a Context method that calls state.ship(this) and then separately checks if (state instanceof ShippedState) to decide whether to fire a notification, instead of trusting the transition itself, or a dedicated hook, to signal that the notification is needed.
// LEAK: Context reasoning about a specific concrete type after delegating
void ship() {
state.ship(this);
if (state instanceof ShippedState) { // Context shouldn't need to know this
notifyCarrier();
}
}
ShippedState.ship() itself, or into the centralized setState hook from Q28, so the Context never needs an instanceof check on its own current state.87. Should a Context expose a getState() method returning the raw State object, or should it instead expose narrower, purpose-specific query methods like isTerminal() or getStatusName()? Discuss the trade-offs.
Exposing the raw State object via getState() lets external callers invoke methods on it directly, or perform instanceof checks against it, which reintroduces exactly the encapsulation-leakage risk from Q86 at the public API boundary instead of just internally. It also couples external code to your specific ConcreteState class names, making internal refactors (renaming or restructuring states) a breaking change for consumers.
// Prefer this: narrow, purpose-specific queries
public String getStatusName() { return state.getClass().getSimpleName(); }
public boolean isTerminal() { return state.isTerminal(); }
// Over this: leaks the internal State object and its type to every caller
public OrderState getState() { return state; }
Narrower query methods, answering specific questions callers actually need answered, keep the ConcreteState classes as a fully internal implementation detail, free to be refactored, renamed, or restructured without breaking any external consumer.
88. How does the choice between stateless singleton ConcreteState instances and per-context instances affect memory usage and scalability when a system has millions of concurrently active Context instances, such as millions of active orders?
With stateless singletons (Q13), the number of ConcreteState objects in memory is bounded by the number of distinct state types, typically a handful, regardless of how many millions of Contexts reference them, since every Context in a given stage points at the exact same shared instance. With per-context instances (Q15), memory scales linearly with the number of active Contexts, since each one needs its own ConcreteState object, which matters when that count reaches millions.
This is a strong practical argument for preferring stateless singletons whenever a ConcreteState genuinely has no per-context data to carry (the common case), reserving per-context instances only for the specific states (like a payment-retry counter or a circuit breaker's failure count) that truly need it, rather than defaulting to "new instance every time" across the board.
89. At what point does a hand-rolled State pattern implementation stop being sufficient, warranting a move to a dedicated workflow engine or orchestration platform instead?
A hand-rolled State pattern implementation is well suited to a single service's in-process lifecycle with a moderate number of states and transitions, all triggered synchronously or through simple event handlers within one codebase. It starts to strain once the workflow spans multiple independently deployed services with long-running, multi-day steps, needs built-in retry-with-backoff and dead-letter handling for failed steps, needs a visual dashboard for business stakeholders to track in-flight workflow instances, or needs versioned workflow definitions that can evolve while old instances are still mid-flight.
At that point, a dedicated workflow orchestration platform (such as Temporal, AWS Step Functions, or a BPMN engine like Camunda) typically becomes worth its operational overhead, since it provides durable execution, built-in retries, and observability tooling that would otherwise need to be built and maintained by hand on top of a plain State pattern implementation.
90. Summarize the decision framework for choosing between a hand-rolled State pattern, a hand-rolled transition table, and a full state-machine library, tying together the trade-offs discussed throughout this guide.
Choose a hand-rolled class-per-state implementation when several states carry genuinely distinct data or non-trivial logic worth testing in isolation (Q1-Q15). Choose a hand-rolled transition table or enum-based machine (Q16-Q19, Q31) when the state set is small, fixed, and each state's logic is short, valuing a compact, centrally reviewable rule set over per-state class ceremony. Choose a full state-machine library (Q29-Q30) when you need guards, entry/exit hooks, persistence integration, or diagram export out of the box, and are willing to accept the added dependency and learning curve in exchange.
| Signal in your workflow | Best-fit approach |
|---|---|
| Few states, each with real per-state data/logic | Class-per-state (classic State pattern) |
| Many simple states, short logic each | Enum or hand-rolled transition table |
| Need guards, entry/exit hooks, diagrams, persistence built in | Dedicated state-machine library |
| Multi-service, long-running, needs durability/retries | Workflow orchestration platform (beyond State entirely) |
91. Walk through refactoring an existing, real switch-on-status method into a State pattern, step by step, using a simplified version of a genuinely messy legacy method as the starting point.
Start by identifying every method in the class that switches on the same status field, since each of those switch statements' branches will become one method on each new ConcreteState class. Extract a State interface with one method per action found across those switches, create one ConcreteState class per distinct status value, and move each switch's corresponding branch body into the matching ConcreteState's method, one status at a time, running tests after each single status is migrated rather than attempting the whole rewrite in one step.
// Before: one switch per action, repeated for ship(), cancel(), deliver()
void ship() {
switch (status) {
case PENDING -> { /* ship logic */ status = OrderStatus.SHIPPED; }
case SHIPPED, DELIVERED, CANCELLED -> throw new IllegalStateException();
}
}
// After: PENDING's branch becomes PendingState.ship(), and so on for every other branch
Finally, once every branch has been migrated into its corresponding ConcreteState class, delete the old status field and switch statements entirely, replacing the field with the State interface reference, and confirm the full test suite still passes with identical externally observable behavior.
92. What is the "shotgun surgery" code smell, and why is a status-field-driven implementation with logic scattered across many methods a textbook example of it?
Shotgun surgery describes the smell where a single conceptual change, adding one new status, forces small edits scattered across many different methods and sometimes many different classes, rather than being contained to one place. A status field checked independently inside ship(), cancel(), deliver(), a UI-rendering method, and a reporting query is a textbook case: adding OnHoldState means touching all five places, and missing even one produces the silent-bug pattern described in Q58.
The State pattern is a direct antidote to this smell because it inverts the axis of change: instead of one change (a new status) rippling across many methods, one change (a new status) becomes one new class, and every existing method or class remains untouched, which is precisely what "closed to modification, open to extension" means in practice.
93. Describe how a mobile app's screen or view controller might use the State pattern to manage Loading, Loaded, Empty, and Error view states, and why this improves on a set of overlapping boolean flags.
A common anti-pattern in mobile view controllers is a set of independent booleans, isLoading, hasError, isEmpty, which can drift into logically impossible or ambiguous combinations, such as isLoading = true and hasError = true simultaneously, with the rendering code left to guess which one actually wins. Modeling the four outcomes as mutually exclusive ConcreteState classes (or a sealed hierarchy, see Q76) makes invalid combinations structurally impossible, since the view is always in exactly one of the four states, never a confusing combination of several boolean flags.
sealed interface ViewState permits Loading, Loaded, Empty, Error {}
record Loading() implements ViewState {}
record Loaded(List<Item> items) implements ViewState {}
record Empty() implements ViewState {}
record Error(String message) implements ViewState {}
94. Model a network client's connection lifecycle (Disconnected, Connecting, Connected, Reconnecting) using the State pattern, and explain the distinction between the initial Connecting state and the later Reconnecting state.
Although both states are attempting to establish a connection, they typically warrant separate ConcreteState classes because their surrounding behavior differs: ConnectingState is a first-time attempt with no prior connection to preserve, while ReconnectingState usually needs to track retry attempts and apply a backoff delay between attempts, and may need to replay or discard messages that were queued while the connection was down, none of which the initial connection attempt needs to worry about.
class ReconnectingState implements ConnectionState {
private int attempt = 0;
private static final Duration MAX_BACKOFF = Duration.ofSeconds(30);
@Override
public ConnectionState onConnectFailed(Client client) {
attempt++;
Duration backoff = computeBackoff(attempt, MAX_BACKOFF);
client.scheduleReconnectAfter(backoff);
return this;
}
}
95. If ConcreteState objects are managed as Spring beans, what bean scope should they be declared with, and why is the default singleton scope usually correct here rather than being a mistake?
For the common case of stateless ConcreteState classes (Q13), Spring's default singleton scope is exactly correct and desirable: one shared bean instance, wired once by the container, reused across every Context in the application, with no per-context data to worry about corrupting since there is none to begin with. This is not a special case requiring configuration; it is simply relying on Spring's normal default behavior for a class that happens to have no mutable instance fields.
@Component
class PendingState implements OrderState { // Spring singleton by default; correct and intended here
@Override
public void ship(Order order) { /* ... */ }
}
The moment a ConcreteState needs genuinely per-context mutable data (Q15), it should not be a Spring-managed bean of any scope at all; it should be a plain object constructed directly at the point of transition, since Spring's prototype scope (a new bean per injection point) does not match "a new instance per business Context" the way plain new does.
96. Describe a bug caused by mistakenly injecting a Spring singleton-scoped bean as a ConcreteState when it actually needed per-context mutable data, and how it manifests in production under load.
This is the Spring-specific flavor of the singleton-sharing bug from Q14 and Q56: a developer declares a ConcreteState as an @Component (singleton by default) and later adds a mutable field to it, perhaps a request-scoped counter or a cached lookup result, without realizing that Spring's dependency injection is handing out the exact same shared instance to every single Context in the entire application, not a fresh one per use.
@Component
class AwaitingPaymentState implements OrderState {
private int retryCount; // BUG: shared across every order in the whole application via the Spring singleton
}
Under load, this manifests as data from unrelated orders bleeding into each other in ways that are maddeningly hard to reproduce locally with a single test order, since the corruption only appears once multiple orders are concurrently sitting in the same state simultaneously, which is exactly the production-scale condition a local single-request test rarely exercises.
new at the transition point instead.97. Design a comprehensive test matrix that systematically covers every (state, action) pair for a state machine, including both legal and illegal combinations, rather than testing only the happy-path transitions.
A thorough test matrix enumerates every ConcreteState against every action method the State interface declares, asserting for each cell either the expected resulting state (if legal) or the expected exception (if illegal), which catches both a missing legal transition and an accidentally-permitted illegal one, the two failure modes a spot-check of only the "happy path" transitions would miss entirely.
@ParameterizedTest
@CsvSource({
"PENDING, ship, SHIPPED",
"PENDING, cancel, CANCELLED",
"SHIPPED, deliver, DELIVERED",
"SHIPPED, ship, ILLEGAL",
"DELIVERED, cancel, ILLEGAL",
})
void transitionMatrix(String from, String action, String expected) {
Order order = orderIn(from);
if (expected.equals("ILLEGAL")) {
assertThrows(IllegalStateException.class, () -> invoke(order, action));
} else {
invoke(order, action);
assertEquals(expected, order.currentStatusName());
}
}
98. If asked to whiteboard the State pattern in a live coding interview, what is an efficient order of operations to design and narrate the solution, and what should you say out loud to demonstrate real understanding rather than rote recall?
Start by naming the concrete business scenario you will model, such as the order lifecycle, and explicitly state which lifecycle stages exist and, crucially, which transitions between them are legal, before writing any code; this shows the interviewer you are reasoning about the domain, not just reciting a template. Then sketch the three participants in order: the State interface first, with only the methods the scenario actually needs; one or two ConcreteState classes next, deliberately implemented differently to demonstrate the polymorphism paying off; and the Context last, showing its delegation and its centralized, restricted transition-setting method.
Narrate the "why" at each step, particularly why an illegal transition throws rather than silently no-ops (Q9), and why the transition-setting method is package-private rather than public (Q57), since these two details are exactly the kind of production-hardening insight that separates a candidate who has actually built this in real systems from one who only memorized the GoF diagram.
99. Compile a checklist of the most common mistakes developers make when implementing the State pattern in Java, drawing together the bugs discussed throughout this guide.
The recurring mistakes worth calling out explicitly in review: leaving a public setState that bypasses transition validation entirely (Q57); silently no-op-ing an illegal transition instead of throwing (Q9, Q58); adding mutable per-context fields to a ConcreteState that is shared as a stateless singleton, corrupting data across unrelated Contexts (Q14, Q56, Q96); forgetting that terminal states must reject every action, not just the "obvious" ones (Q7); and reaching for the full pattern on a trivial two-state, one-line-per-branch case where a plain enum would be clearer (Q59-Q60).
| Mistake | Consequence |
|---|---|
| Public setState() bypassing validation | Any code can force an arbitrary, illegal transition |
| Silent no-op on illegal transition | Bugs and duplicate actions go unnoticed |
| Mutable field on a shared singleton state | Cross-context data corruption under load |
| Terminal state missing a rejection | Object can be mutated after it should be frozen |
| Overengineering a trivial two-state case | Unnecessary ceremony obscuring simple logic |
100. Summarize, as a final decision checklist, exactly when you would recommend the State pattern over its alternatives, and when you would advise against it, tying together the guidance from this entire guide.
Recommend the State pattern when an object's behavior genuinely varies across several methods based on an internal lifecycle stage, when at least one of those stages carries its own meaningful data or non-trivial logic worth isolating and testing separately, and when new stages are a realistic future possibility that should not require touching every existing branch of conditional logic. It is especially well justified once you already have a status field checked independently in three or more methods, since that is the concrete, observable symptom of the shotgun-surgery smell (Q92) the pattern directly fixes.
Advise against it, in favor of a plain enum, a boolean, or a lightweight transition table, when there are only two or three simple states with one-line-per-branch logic and no per-state data, when the "variation" is really about which algorithm to run rather than an object's own lifecycle (reach for Strategy instead, Q21-Q25), or when the workflow has grown so large and cross-service that a dedicated orchestration platform (Q89) would serve the system better than any in-process pattern, State included.
Post a Comment
Add