Observer left a question open: today nothing prevents changeStatus(DELIVERED) on a freshly created, unpaid order. And the problem runs deeper than validating transitions: almost everything you can do with an order depends on where it is in its life cycle. Can it be cancelled? Free if it's created, with a penalty if it's being prepared, impossible while out for delivery. Can it be modified? Only before paying. The current code answers with a switch over the status... repeated in every method, growing with every new status. State proposes the reification you've come to expect by this point in the module: each state is an object, with that state's behavior inside, and the order delegates to its current state — which also decides where to transition next.

Contents

  1. The problem in PideYa: the order's life cycle
  2. The first attempt: a switch over an enum
  3. Intent and structure of the pattern
  4. The state diagram
  5. Complete Java implementation
  6. Design variants
  7. State vs. switch: the honest comparison
  8. When to use it and when not to
  9. Relationship with other patterns
  10. Common mistakes
  11. Exercises and conclusion

The problem in PideYa: the order's life cycle

The business rules, as dictated by operations:

State Pay? Modify lines? Cancel? Outgoing transitions
CREATED Yes Yes Yes, free PAID, CANCELLED
PAID No (already is) No Yes, with full refund PREPARING, CANCELLED
PREPARING No No Yes, with a 20% penalty OUT_FOR_DELIVERY, CANCELLED
OUT_FOR_DELIVERY No No No (it's on its way) DELIVERED
DELIVERED No No No — (final)
CANCELLED No No No — (final)

Notice the nature of the problem: it isn't one behavior with variants (that would be something else, and it has its lesson right after this one); it's the same object behaving like six different objects depending on the moment. The technical term is that Order is a finite state machine: states, permitted transitions, and per-state behavior.

The first attempt: a switch over an enum

public class Order {

    private OrderStatusEnum status = OrderStatusEnum.CREATED;

    public void pay() {
        switch (status) {
            case CREATED -> { charge(); status = OrderStatusEnum.PAID; }
            default -> throw new InvalidTransitionException("Cannot pay in " + status);
        }
    }

    public void cancel() {
        switch (status) {
            case CREATED -> status = OrderStatusEnum.CANCELLED;
            case PAID -> { refundAll(); status = OrderStatusEnum.CANCELLED; }
            case PREPARING -> { refundWithPenalty(); status = OrderStatusEnum.CANCELLED; }
            default -> throw new InvalidTransitionException("Cannot cancel in " + status);
        }
    }

    public void modifyLine(...) { switch (status) { /* ... */ } }
    public void markOutForDelivery() { switch (status) { /* ... */ } }
    // ... one switch per operation
}

It's only honest to say it: for six states and four stable operations, this works and is readable. The problems appear with evolution:

  • Each state's logic is scattered across every method: to know "what can be done while PREPARING" you must read one case in every switch in the class. The state doesn't exist as a concept in the code; it exists as a repeated label.
  • Adding a state (here comes WAITING_FOR_COURIER, courtesy of the DispatchCenter) means touching every switch — and forgetting one gives no compile error if there's a default.
  • Per-state behavior grows: penalties that depend on time spent preparing, different notifications per state... each case fattens until Order is a filing cabinet of conditionals.
  • The state × operation combination is prone to the forgotten case: Observer's bug (delivering without paying) was exactly a case without a defensive default.

Intent and structure of the pattern

Intent (GoF): allow an object to alter its behavior when its internal state changes. The object will appear to change its class.

"Will appear to change its class" is the key phrase: an order out for delivery behaves like a different class than a freshly created one — because, underneath, its behavior lives in a different class.

classDiagram
    class Order {
        -state: OrderState
        +pay()
        +cancel()
        +modifyLine(...)
        +markOutForDelivery()
        ~transitionTo(next: OrderState)
    }
    class OrderState {
        <<interface>>
        +pay(o: Order)
        +cancel(o: Order)
        +modifyLine(o: Order, ...)
        +markOutForDelivery(o: Order)
        +name() String
    }
    class CreatedState
    class PaidState
    class PreparingState
    class OutForDeliveryState
    class DeliveredState
    class CancelledState

    OrderState <|.. CreatedState
    OrderState <|.. PaidState
    OrderState <|.. PreparingState
    OrderState <|.. OutForDeliveryState
    OrderState <|.. DeliveredState
    OrderState <|.. CancelledState
    Order o-- OrderState : delegates to the current one
GoF role In PideYa
Context (holds the current state and delegates) Order
State (interface with the state-dependent operations) OrderState
ConcreteState CreatedState, PaidState, PreparingState, OutForDeliveryState, DeliveredState, CancelledState

The state diagram

The complete machine, in the UML diagram designed exactly for this (we introduced it in the UML lesson; here it shines brightest):

stateDiagram-v2
    [*] --> CREATED
    CREATED --> PAID : pay()
    CREATED --> CANCELLED : cancel() / free
    PAID --> PREPARING : kitchen accepts
    PAID --> CANCELLED : cancel() / full refund
    PREPARING --> OUT_FOR_DELIVERY : courier picks up
    PREPARING --> CANCELLED : cancel() / 20% penalty
    OUT_FOR_DELIVERY --> DELIVERED : delivery confirmed
    DELIVERED --> [*]
    CANCELLED --> [*]

A piece of craft advice: draw this diagram before writing a single line of the pattern. It is the contract with the business (is cancelling out for delivery really impossible?), the exact list of classes to write, and the source of the tests (every arrow, a passing test; every missing arrow, a test that expects an exception).

Complete Java implementation

The State interface, with default implementations that reject: this way each concrete state only writes what it allows, and whatever is unwritten is automatically forbidden — the forgotten case becomes impossible:

public interface OrderState {

    default void pay(Order o)                { reject("pay", o); }
    default void cancel(Order o)             { reject("cancel", o); }
    default void modifyLine(Order o, OrderLine l) { reject("modify", o); }
    default void markPreparing(Order o)      { reject("prepare", o); }
    default void markOutForDelivery(Order o) { reject("dispatch", o); }
    default void markDelivered(Order o)      { reject("deliver", o); }

    String name();

    private void reject(String operation, Order o) {
        throw new InvalidTransitionException(
            "Cannot " + operation + " an order in state " + name());
    }
}

The concrete states. Each class is one column of the business table turned into code — compact, cohesive, with its own name:

public class CreatedState implements OrderState {

    @Override
    public void pay(Order o) {
        o.chargeTotal();                            // the context's work
        o.transitionTo(new PaidState());            // transition decided HERE
    }

    @Override
    public void cancel(Order o) {
        o.transitionTo(new CancelledState());       // free: no refund needed
    }

    @Override
    public void modifyLine(Order o, OrderLine line) {
        o.applyModification(line);                  // the only state that allows it
    }

    @Override
    public String name() { return "CREATED"; }
}

public class PreparingState implements OrderState {

    @Override
    public void cancel(Order o) {
        o.refundWithPenalty(new BigDecimal("0.20"));
        o.transitionTo(new CancelledState());
    }

    @Override
    public void markOutForDelivery(Order o) {
        o.transitionTo(new OutForDeliveryState());
    }

    @Override
    public String name() { return "PREPARING"; }
}

public class DeliveredState implements OrderState {
    // Final state: overrides nothing — everything rejected by default
    @Override
    public String name() { return "DELIVERED"; }
}

The context. Order ends up clean of conditionals: it exposes the operations and delegates; and transitionTo is the only place where the state changes — the perfect spot to connect with Observer:

public class Order {

    private OrderState state = new CreatedState();

    public void pay()                       { state.pay(this); }
    public void cancel()                    { state.cancel(this); }
    public void modifyLine(OrderLine l)     { state.modifyLine(this, l); }
    public void markOutForDelivery()        { state.markOutForDelivery(this); }
    public void markDelivered()             { state.markDelivered(this); }

    /** The only gate for state changes; package visibility: only the states use it. */
    void transitionTo(OrderState next) {
        OrderState previous = this.state;
        this.state = next;
        EventLog.INSTANCE.info("Order " + id + ": " + previous.name() + " → " + next.name());
        notifyObservers(previous);   // Observer broadcasts what State authorizes
    }

    // chargeTotal(), refundWithPenalty(...), applyModification(...): the real work,
    // invoked by the states; the data stays encapsulated here.
}

The module's two pieces click together as if destined: State decides which transitions are legal; Observer broadcasts the ones that happen. The "deliver without paying" bug can no longer exist — CreatedState doesn't override markDelivered, so the call dies in an InvalidTransitionException before notifying anything.

And the distribution of responsibilities matters: the states decide what happens and where to go; the context executes the work (chargeTotal) and guards its data. States that paw at the order's fields are the first step into the mud.

Design variants

  • States with or without data of their own? Ours are stateless (all data lives in Order): they can be shared — reused constants instead of new on every transition (PaidState.INSTANCE, in the Flyweight spirit). If a state needs its own data (OUT_FOR_DELIVERY holding the assigned courier), it gets instantiated per transition and carries it inside.
  • Who decides the transition? Here, each state (the usual choice: the rule "from CREATED you go to PAID" is the state's knowledge). Alternative: the context or an external transition table decides, and the states only execute behavior — more declarative, less object-oriented.
  • Entry/exit actions: onEnter(Order)/onExit(Order) methods on the interface, invoked by transitionTo — the ideal spot for per-state effects (on entering OUT_FOR_DELIVERY, notify the DispatchCenter).
  • State with an enum: in Java, an enum can implement methods per constant (CREATED { void pay(...) {...} }, PAID {...}) — the full State pattern with the enum's exhaustiveness guarantee. Excellent for machines without per-state data; it falls short when the states need it (enums have no per-transition instances).

State vs. switch: the honest comparison

Criterion switch over enum State pattern
Few, stable states and operations Wins: fewer pieces, everything in sight Bureaucracy
Bulky/growing per-state logic Filing-cabinet methods Wins: one cohesive class per state
Adding a state Touch N switches (no compiler help if there's a default) Add one class; the rest untouched
Adding an operation One new method with its switch Touch the interface and (with defaults) only the states that allow it
Seeing the whole machine at a glance So-so (scattered across methods) Poor (scattered across classes) — keep the stateDiagram as documentation
Per-state data Awkward (fields only sometimes valid) Natural (fields of the concrete state)

The honest rule: the switch is not the villain; it's the right solution for small, stable machines. State earns its keep when per-state behavior carries weight and the machine evolves — exactly the case of PideYa's order, whose life cycle is the changing heart of the business.

When to use it and when not to

Use it when:

  • An object's behavior depends on its state and must change at runtime as it transitions.
  • The operations have large, repeated conditionals over the same state discriminant.
  • The states have substantial behavior or data of their own, or the machine grows with the business.

Avoid it when:

  • The machine is small and stable: enum + switch (or an enum with methods) is more direct.
  • The "state" is just data being read, with no behavior attached: a plain field suffices.
  • The "behavior variants" don't form a life cycle (there are no transitions): that's Strategy — the exact distinction, in one line here and in detail in the comparison: State transitions on its own between states that know each other; Strategy is chosen from outside and ignores its siblings.

Relationship with other patterns

  • Strategy: structural twins (a context delegating to an interchangeable object); the difference is intent and who changes the object — mention made, head-to-head in the next lesson and in the comparison.
  • Observer: State authorizes transitions, Observer broadcasts them; connected in transitionTo.
  • Singleton/Flyweight: shared stateless states as single instances.
  • Memento: to rewind the machine to an earlier point, photograph the context (including which state it was in).
  • Command: the panel's commands (CancelOrderCommand) invoke operations whose legality State now decides — undo() must account for the InvalidTransitionException.

Common mistakes

  • Transitions from outside: a public order.setState(new DeliveredState()) destroys the machine — anyone can skip the rules. The gate (transitionTo) must be inaccessible from outside (package/private).
  • States that touch the context's data directly: the states decide, the context executes; if PaidState manipulates Order's fields, encapsulation breaks and the states couple to the guts.
  • Forgetting the default behavior: without the rejecting defaults, every state must implement everything, and the unhandled case comes back as null or silence instead of a clear exception.
  • State explosion from combining dimensions: if the order needs an independent "payment state" × "kitchen state", don't create PaidAndInTheOven: those are two parallel machines, each with its own State.
  • Duplicating the discriminant: keeping an additional status enum that must be manually synchronized with the state object — two sources of truth. If you need the name for persistence or display, derive it from the object (state.name()), don't store it separately.

Exercises

Exercise 1: a new state without touching the old ones

Operations introduces WAITING_FOR_COURIER: after PREPARING, if there's no courier, the order waits; when the DispatchCenter assigns one, it moves to OUT_FOR_DELIVERY; it can be cancelled with a full refund (the delay is our fault). Implement WaitingForCourierState and state which existing classes change.

Exercise 2: draw before you code

Model with a mermaid stateDiagram-v2 the state machine of a support ticket: OPEN → IN_PROGRESS (when an agent takes it) → RESOLVED (with a solution) → CLOSED (the customer confirms); from RESOLVED it can go back to IN_PROGRESS (the customer complains); from OPEN and IN_PROGRESS it can go straight to CLOSED if the customer withdraws it.

Exercise 3: State, switch, or Strategy?

Justify the tool for each case: (a) a courier's availability light: AVAILABLE ↔ BUSY ↔ ON_BREAK, with no behavior attached beyond the value itself; (b) a restaurant's onboarding document: DRAFT / IN_REVIEW / PUBLISHED / WITHDRAWN, with different edit permissions, validations, and notifications per state, and new states expected; (c) three ways of splitting the tip among couriers, selectable per country.

Solutions

Solution 1:

public class WaitingForCourierState implements OrderState {

    @Override
    public void cancel(Order o) {
        o.refundAll();                          // our fault: no penalty
        o.transitionTo(new CancelledState());
    }

    @Override
    public void markOutForDelivery(Order o) {   // invoked by the center's assignment
        o.transitionTo(new OutForDeliveryState());
    }

    @Override
    public String name() { return "WAITING_FOR_COURIER"; }
}

Only PreparingState changes: its markOutForDelivery now decides between transitioning to OutForDelivery (there's a courier) or to WaitingForCourier (there isn't) — it is the source state of the new arrow, and arrows are the knowledge of their source. Order, the interface, and the other four states remain untouched. Compare with the switch approach: every method with a switch would have changed.

Solution 2:

stateDiagram-v2
    [*] --> OPEN
    OPEN --> IN_PROGRESS : agent takes it
    OPEN --> CLOSED : customer withdraws it
    IN_PROGRESS --> RESOLVED : solution proposed
    IN_PROGRESS --> CLOSED : customer withdraws it
    RESOLVED --> IN_PROGRESS : customer complains
    RESOLVED --> CLOSED : customer confirms
    CLOSED --> [*]

(The value of the exercise is in the questions the diagram forces you to ask: can a CLOSED ticket be reopened? As stated, no — and now it's written down.)

Solution 3: (a) a plain enum (not even a switch): three values with no behavior — applying State would be the over-engineering of lesson 01-06; (b) State: substantial per-state behavior, transitions with rules, and a growing machine — the textbook case; (c) Strategy: alternative algorithms chosen by configuration (country), with no transitions between them — no tip calculation "transitions" into another. Which is, precisely, the next lesson.

Conclusion

State reifies the life cycle: each stage of the order is now a class that concentrates its behavior and knows its exits, Order delegates without a single conditional, and illegal transitions die at the gate with a first and last name — the hole Observer left open is closed, and both patterns collaborate in transitionTo: State authorizes, Observer broadcasts. You also take away the honest criterion (switch for small, stable machines; State when the behavior carries weight and the machine evolves) and the golden habit: first the state diagram, then the code.

The final head-to-head of the exercise revealed the exact boundary with the next pattern: alternative computations that don't transition, chosen from outside. PideYa has two textbook cases waiting: the delivery fees (by distance, flat rate, free with a promotion) and the courier assignment that the DispatchCenter left pending extraction (nearest, least busy, round-robin). Families of interchangeable algorithms behind an interface: see you in Strategy.

© Copyright 2026. All rights reserved