An e-commerce order moves through several states: new, paid, shipped, delivered, cancelled. Each action, such as paying, shipping or cancelling, is valid only in certain states, and the behavior differs too: cancelling a paid order must issue a refund, while cancelling a new order does not. The State pattern lets an object change its behavior when its internal state changes, so from the outside it looks as if the object changed its class. This article implements the State pattern for an order in three ways, then compares when to pick each one so you can apply the State pattern effectively.

This article follows Chain of Responsibility: filter chains in Servlets and Spring Security in the Java & Design Patterns series, and the State pattern belongs to the same family of behavioral patterns. Unlike a chain of responsibility, where a request travels through many objects, here a single object changes how it reacts depending on its circumstances. All code was run on JDK 21 (OpenJDK 21.0.x), on an Apple M4 with 16 GB of RAM, and the output in this article is real output.

The problem: state switches everywhere, and the role of the State pattern

The first approach most developers take is a status field, either a string or an enum, plus methods that use if or switch to check it. With two states this is fine. With five states and four actions you must check 20 combinations, and the same switch statements repeat across many methods. Adding a "returning" state means revisiting every method, and if you miss one, a business bug reaches production without any warning.

According to refactoring.guru, the State pattern solves this by creating a class for each state, moving the state-specific behavior into that class, while the original object (the context) keeps a reference to the current state and delegates to it. Changing state is just swapping the state object. This view ties into the concept of a finite-state machine: according to Wikipedia, it is a computational model in which the system is always in exactly one of a finite number of states, and inputs trigger transitions between states.

The structure of the State pattern

The State pattern has three roles, shown in the table below together with the matching part of the example.

RoleResponsibilityIn the order example
ContextHolds the current state, delegates actions, allows the state to be replacedOrder
State (interface)Declares the methods whose behavior varies by stateOrderState
Concrete stateImplements the behavior of one state, and may trigger a state transitionNew, Paid, Shipped...

The important part is who controls transitions. According to refactoring.guru, both the context and the concrete states can replace the active state. In the implementation below, each state decides where it moves next, so the business rules are spread across classes, and each class stays small and easy to read.

The classic State pattern implementation for an order

The OrderState interface declares four actions, and each action has a default implementation that throws an exception. As a result, each concrete state overrides only the actions that are valid for it, and every other action is automatically forbidden. This is a practical detail: the "allowed" list is much shorter than the "forbidden" list.

    /** State interface: declares only the actions whose behavior differs by state. */
    interface OrderState {
        String name();
        default void pay(Order o)     { throw new IllegalTransition(name(), "pay"); }
        default void ship(Order o)    { throw new IllegalTransition(name(), "ship"); }
        default void deliver(Order o) { throw new IllegalTransition(name(), "confirm delivery"); }
        default void cancel(Order o)  { throw new IllegalTransition(name(), "cancel"); }
    }

    static class New implements OrderState {
        public String name() { return "NEW"; }
        public void pay(Order o)    { o.moveTo(new Paid()); }
        public void cancel(Order o) { o.moveTo(new Cancelled()); }
    }
    static class Paid implements OrderState {
        public String name() { return "PAID"; }
        public void ship(Order o)   { o.moveTo(new Shipped()); }
        public void cancel(Order o) { System.out.println("    (refund the customer)"); o.moveTo(new Cancelled()); }
    }
    static class Shipped implements OrderState {
        public String name() { return "SHIPPED"; }
        public void deliver(Order o) { o.moveTo(new Delivered()); }
    }
    static class Delivered implements OrderState { public String name() { return "DELIVERED"; } }
    static class Cancelled implements OrderState { public String name() { return "CANCELLED"; } }

The cancel action of Paid prints an extra refund line, while the cancel action of New does not: the same call produces different behavior, and no if checks the state. Running java ClassicState.java for the classic State pattern:

Order A: [NEW, PAID, SHIPPED, DELIVERED]
    (refund the customer)
Order B: [NEW, PAID, CANCELLED]
Order C: Cannot cancel when the order is in state SHIPPED
Order D: Cannot ship when the order is in state NEW

Order C is already in the SHIPPED state, so cancelling is rejected with a clear message, and order D has not been paid, so it cannot be shipped. Adding a new state to the State pattern only requires adding one class and updating the states that point to it, which matches the Open/Closed spirit analyzed in SOLID principles in Java with practical examples.

Comparing the State pattern with Strategy

The State pattern and Strategy have almost identical class diagrams: the context delegates to an object through an interface. The difference lies in intent. According to refactoring.guru, in Strategy the helper objects are independent and unaware of each other, while in State the concrete states may know about each other and trigger transitions, so State can be seen as an extension of Strategy. In short: Strategy is chosen from the outside by the client, while the State pattern is driven by the object itself as it moves through its lifecycle. The article Strategy pattern with lambdas and functional interfaces covers the other side in detail.

The State pattern with an enum and a transition table

The classic way of implementing the State pattern has a drawback: the transition rules are split across many classes, and to see the whole picture you must open all five files. For a State pattern applied to orders, what you usually need is a data table that says "in state X, event Y leads to Z". This table matches the notion of a state transition table in finite-state machine theory. In Java, an enum combined with EnumMap is a sensible tool: according to the EnumMap Javadoc, it is represented internally as an array, which is compact and efficient, and is usually faster than HashMap (the documentation says "likely, though not guaranteed").

    enum Status { NEW, PAID, SHIPPED, DELIVERED, CANCELLED }
    enum Event  { PAY, SHIP, DELIVER, CANCEL }

    static final Map<Status, Map<Event, Status>> TABLE = new EnumMap<>(Status.class);
    static {
        TABLE.put(Status.NEW,     new EnumMap<>(Map.of(Event.PAY, Status.PAID, Event.CANCEL, Status.CANCELLED)));
        TABLE.put(Status.PAID,    new EnumMap<>(Map.of(Event.SHIP, Status.SHIPPED, Event.CANCEL, Status.CANCELLED)));
        TABLE.put(Status.SHIPPED, new EnumMap<>(Map.of(Event.DELIVER, Status.DELIVERED)));
        TABLE.put(Status.DELIVERED, new EnumMap<>(Event.class));
        TABLE.put(Status.CANCELLED, new EnumMap<>(Event.class));
    }

    static Status next(Status from, Event e) {
        Status to = TABLE.get(from).get(e);
        if (to == null) throw new IllegalStateException(e + " is not valid when the order is " + from);
        return to;
    }

The biggest benefit of the table-based State pattern is that the table is data, so we can verify the diagram itself. The main method runs a sequence of events, then computes the set of states reachable from NEW, finds the terminal states, and prints the diagram in Graphviz DOT format so it can be redrawn:

NEW --PAY--> PAID
PAID --SHIP--> SHIPPED
SHIPPED --DELIVER--> DELIVERED
Error: CANCEL is not valid when the order is SHIPPED
Reachable from NEW: [NEW, PAID, SHIPPED, DELIVERED, CANCELLED]
Terminal states: [DELIVERED, CANCELLED]
digraph order {
  NEW -> PAID [label="PAY"];
  NEW -> CANCELLED [label="CANCEL"];
  PAID -> SHIPPED [label="SHIP"];
  PAID -> CANCELLED [label="CANCEL"];
  SHIPPED -> DELIVERED [label="DELIVER"];
}

You can paste the DOT snippet into Graphviz to get a state diagram for your documentation. The classic approach does not give you that for free. In return, the table-based State pattern does not let each state carry its own data (a tracking number that exists only once the order has shipped), and side behavior such as refunds must live elsewhere, usually in a service class that receives the result of the transition. For a state machine that only needs to validate transition rules, this is the lightest variant of the State pattern, and many real order systems stop here.

The modern State pattern with a sealed interface and records

Since Java 17, JEP 409 officially added sealed classes to the language, with the goal of letting the compiler check whether patterns are exhaustive. Java 21 adds pattern matching for switch and record patterns. Put together, they give a third way to model the problem: each state is an immutable record carrying exactly its own data, and a transition is a pure function (state, event) -> new state.


    sealed interface OrderState permits New, Paid, Shipped, Delivered, Cancelled {}
    record New(String id) implements OrderState {}
    record Paid(String id, BigDecimal amount) implements OrderState {}
    record Shipped(String id, BigDecimal amount, String trackingNo) implements OrderState {}
    record Delivered(String id, LocalDate on) implements OrderState {}
    record Cancelled(String id, String reason) implements OrderState {}

    sealed interface Event permits Pay, Ship, Deliver, Cancel {}
    record Pay(BigDecimal amount) implements Event {}
    record Ship(String trackingNo) implements Event {}
    record Deliver(LocalDate on) implements Event {}
    record Cancel(String reason) implements Event {}

    /** Pure function: (state, event) -> new state. There is no default, so adding a new state makes the compiler complain. */
    static OrderState apply(OrderState s, Event e) {
        return switch (s) {
            case New n -> switch (e) {
                case Pay p    -> new Paid(n.id(), p.amount());
                case Cancel c -> new Cancelled(n.id(), c.reason());
                case Ship x    -> throw bad(s, e);
                case Deliver x -> throw bad(s, e);
            };
            case Paid p -> switch (e) {
                case Ship sh  -> new Shipped(p.id(), p.amount(), sh.trackingNo());
                case Cancel c -> new Cancelled(p.id(), c.reason() + " (refund " + p.amount() + ")");
                case Pay x     -> throw bad(s, e);
                case Deliver x -> throw bad(s, e);
            };
            case Shipped sh -> switch (e) {
                case Deliver d -> new Delivered(sh.id(), d.on());
                case Pay x     -> throw bad(s, e);
                case Ship x    -> throw bad(s, e);
                case Cancel x  -> throw bad(s, e);
            };
            case Delivered d -> throw bad(s, e);
            case Cancelled c -> throw bad(s, e);
        };
    }

This version of the State pattern has two notable points. First, Shipped carries a trackingNo while New does not: an invalid state cannot be represented, because no object can be "shipped without a tracking number". Second, the switch has no default branch. According to the Oracle pattern matching documentation, a pattern switch must be exhaustive, and with a sealed type the compiler knows all permitted subtypes, so no default is needed. Output when run:

Pay[amount=1250000]  =>  Paid[id=DH-1001, amount=1250000]
Ship[trackingNo=VN123456]  =>  Shipped[id=DH-1001, amount=1250000, trackingNo=VN123456]
Deliver[on=2024-03-14]  =>  Delivered[id=DH-1001, on=2024-03-14]
Cancelled[id=DH-1002, reason=customer changed mind (refund 300000)]
Error: Cancel is not valid in Delivered

To see the benefit, I tried adding a Returned state to the permits list without updating the apply function (I copied the file to a temporary tmp/ directory for this experiment, so the path in the message starts with tmp/). The compiler rejects it immediately:

tmp/SealedState.java:23: error: the switch expression does not cover all possible input values
        return switch (s) {
               ^
1 error

This is a protection that the previous two State pattern variants do not have at compile time: if you add a state and forget to handle it, the build fails instead of the code failing at runtime. Note that, according to the same Oracle documentation, if you recompile only the sealed interface without recompiling the places that use the switch, the program may throw MatchException at runtime, so build the whole module together. The apply function is also a bit long because it lists the invalid combinations explicitly; that is the price of letting the compiler check every cell of the table.

Which State pattern variant to choose

CriterionState classesEnum + tableSealed + record
Behavior that differs by stateAn excellent fitMust live elsewhereLives in a function or service
Per-state dataYes, but the objects are mutableNoYes, immutable
Overview of transition rulesSpread across many classesOne table, a diagram can be drawnOne function
Compile-time checkingNoPartial (enum)Yes, exhaustive switch
Best whenEach state has a lot of logicYou only need to validate rulesStates carry data and safety is the priority

There is no single right answer. The rule I apply to the State pattern: start with an enum and a table until the state-dependent behavior grows large, then move to state classes or a sealed interface. If the state of a State pattern is stored in a database, store the enum name (as a string) and not its ordinal, so that adding a state in the middle does not corrupt old data.

When not to use the State pattern

According to refactoring.guru, the State pattern can be overkill when the state machine has only a few states or rarely changes. A boolean paid flag does not need five classes. Consider the State pattern when both conditions hold: there are roughly four or more states, and the actions truly behave differently depending on state rather than just being "allowed or not".

For workflows with complex conditional branching, nested states, parallel states, timers, or a need to persist the state machine to a data store, you can use a library. The Spring Statemachine documentation describes concepts such as guards (conditions protecting a transition), actions that run on a transition or on entering and leaving a state, hierarchical states and parallel regions. In exchange you take on an additional framework dependency and a learning curve; check its maintenance status and compatibility with your Spring Boot version before choosing it.

Common State pattern mistakes

  • Non-atomic transitions: two requests pay the same order at once. I tested with 8 threads, each calling pay on the same order. The plain "check then change" version charged all 8 times, while the version using compareAndSet charged only once.
Unsynchronized: number of charges = 8
compareAndSet: number of charges = 1
Unsynchronized: number of charges = 8
compareAndSet: number of charges = 1
Unsynchronized: number of charges = 8
compareAndSet: number of charges = 1

The result was identical across all three runs (file DoublePay.java in the code directory). In an application with a database, the same effect occurs across multiple processes, so you need a conditional update, for example UPDATE orders SET status='PAID' WHERE id=? AND status='NEW' followed by a check of the affected row count, or optimistic locking with a version column.

  • State holding the context's data: if you create a new state object on every transition, all shared data must live in the context. Do not hide it in the old state, because it will be lost.
  • Not recording transition history: an order needs an audit trail of who changed it and when. Record each transition as the history does in the first example, or store an event table.
  • Swallowing invalid transition errors: catching the exception and ignoring it loses the signal. Return a clear error code to the client.
  • Using free-form strings as the state in a State pattern: a typo such as "SHIPED" is not caught. Use an enum or a sealed type.

State pattern checklist

  • List the states and events and draw the diagram before writing code; mark the terminal states clearly.
  • Choose a State pattern variant: state classes, enum and table, or sealed and record, using the comparison table above.
  • Forbid every transition by default and open only the ones you list.
  • Make transitions atomic when there is concurrency.
  • Record the history and write tests for every valid and invalid transition.
  • When adding a state, let the compiler or the tests point out the places that need changes.

To rerun the examples, use java directly on each Java file in the article's code directory (JDK 21), or run runall.sh.

Next article

The State pattern changes behavior based on internal state. The next article moves on to traversing a collection without exposing its internal structure: Iterator pattern in Java: Iterable, Spliterator and Stream.