Skip to content
wannacode

State

In short: State turns each state of an object (such as an order) into its own object that knows which actions it allows and which state comes next, instead of an if on the status in every method.

Problem

The order that checks its status everywhere

An order in the shop goes new → paid → shipped → delivered, and it can also be cancelled. The status is text, and every method checks it:

class Order {
private String status = "new";
public void pay() {
if (status.equals("new")) {
status = "paid";
} else {
throw new IllegalStateException("The order is " + status + ": it can't be paid");
}
}
public void ship() {
if (status.equals("cancelled")) {
throw new IllegalStateException("The order is " + status + ": it can't be shipped");
}
status = "shipped";
}
public void deliver() {
if (status.equals("shipped")) {
status = "delivered";
} else {
throw new IllegalStateException("The order is " + status + ": it can't be delivered");
}
}
public void cancel() {
if (status.equals("delivered")) {
throw new IllegalStateException("The order is " + status + ": it can't be cancelled");
}
if (!status.equals("new")) {
System.out.println("Refunding the customer");
}
status = "cancelled";
}
public String status() {
return status;
}
}

Two orders:

Order first = new Order();
first.ship();
System.out.println("First: " + first.status());
Order second = new Order();
second.pay();
second.ship();
second.cancel();
System.out.println("Second: " + second.status());
Output
First: shipped
Refunding the customer
Second: cancelled

The first one was shipped without being paid: ship only checks for "cancelled". The second one was cancelled while the parcel is on the road. The customer gets the money back, and the courier will still deliver it.

Nobody wrote a bug on purpose. Four events times five statuses are twenty combinations, and each method remembers only some of them. A new status "packed" means thinking again about all four methods, and the forgotten combination will still slip through quietly.

The idea

State: each state knows what it allows

The question "is this event allowed now?" has an answer that depends on the state. So the answer moves into the state itself.

Each state is an object of its own class. It says which events it allows and which state comes next. The common base class OrderState refuses every event with a clear error, and each state overrides only the allowed ones. The order checks nothing: it passes the event to the current state and remembers what that state returns.

This is the State pattern. It has three roles:

  • the order keeps the current state. Books call this the context;
  • the base class OrderState: all the events, and each one refuses by default;
  • the concrete states: NewState, PaidState, ShippedState, DeliveredState, CancelledState.

See it

The five states and the order

The base class: every event refuses with a text that includes the name of the state.

abstract class OrderState {
abstract String name();
OrderState pay() {
throw refuse("be paid");
}
OrderState ship() {
throw refuse("be shipped");
}
OrderState deliver() {
throw refuse("be delivered");
}
OrderState cancel() {
throw refuse("be cancelled");
}
IllegalStateException refuse(String action) {
return new IllegalStateException("The order is " + name() + ": it can't " + action);
}
}

Each state overrides only what it allows. Cancelling a paid order refunds the money, while cancelling a new one refunds nothing, because nothing has been paid. This behavior is in its own state, without an if:

class NewState extends OrderState {
@Override
String name() {
return "new";
}
@Override
OrderState pay() {
return new PaidState();
}
@Override
OrderState cancel() {
return new CancelledState();
}
}
class PaidState extends OrderState {
@Override
String name() {
return "paid";
}
@Override
OrderState ship() {
return new ShippedState();
}
@Override
OrderState cancel() {
System.out.println("Refunding the customer");
return new CancelledState();
}
}
class ShippedState extends OrderState {
@Override
String name() {
return "shipped";
}
@Override
OrderState deliver() {
return new DeliveredState();
}
}
class DeliveredState extends OrderState {
@Override
String name() {
return "delivered";
}
}
class CancelledState extends OrderState {
@Override
String name() {
return "cancelled";
}
}

The order keeps the current state and passes every event to it:

class Order {
private OrderState state = new NewState();
public void pay() {
state = state.pay();
}
public void ship() {
state = state.ship();
}
public void deliver() {
state = state.deliver();
}
public void cancel() {
state = state.cancel();
}
public String status() {
return state.name();
}
}

The two combinations from the problem, and then the whole way:

Order order = new Order();
try {
order.ship();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.pay();
order.ship();
System.out.println(order.status());
try {
order.cancel();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.deliver();
System.out.println(order.status());
Output
The order is new: it can't be shipped
shipped
The order is shipped: it can't be cancelled
delivered

Order order = new Order();
try {
order.ship();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.pay();
order.ship();
System.out.println(order.status());
try {
order.cancel();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.deliver();
System.out.println(order.status());
Step by step, in words
  1. A new order: its state field points to NewState ("new").
  2. ship() on a new order. NewState has no ship arrow, so the base class refuses and catch prints the error.
  3. pay(): NewState allows it and returns PaidState. The order just remembers the new state.
  4. The same ship() as before, but now PaidState answers: it allows it, and the next state is ShippedState.
  5. The status is the name of the current state: shipped.
  6. cancel() while the parcel is on the road. ShippedState has no cancel arrow: a wall, and no refund.
  7. deliver(): from shipped to delivered.
  8. Nobody wrote a check for the two forbidden combinations. They have no arrow, so they are refused.

There is no ship arrow from "new", so shipping is refused. There is no cancel arrow from "shipped". Nobody wrote a check for these two combinations: they are forbidden because no state allowed them.

See it

A new event without touching the other states

The warehouse wants the parcel packed before it leaves: paid → packed → shipped. The new event goes into the base class, and it refuses like the others:

OrderState pack() {
throw refuse("be packed");
}

PaidState now allows pack instead of ship, and the new PackedState allows ship and cancelling with a refund:

class PaidState extends OrderState {
@Override
String name() {
return "paid";
}
@Override
OrderState pack() {
return new PackedState();
}
@Override
OrderState cancel() {
System.out.println("Refunding the customer");
return new CancelledState();
}
}
class PackedState extends OrderState {
@Override
String name() {
return "packed";
}
@Override
OrderState ship() {
return new ShippedState();
}
@Override
OrderState cancel() {
System.out.println("Refunding the customer");
return new CancelledState();
}
}

The order gets the method pack, which only passes the event on:

public void pack() {
state = state.pack();
}
Order order = new Order();
try {
order.pack();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.pay();
order.pack();
order.ship();
System.out.println(order.status());
Output
The order is new: it can't be packed
shipped

NewState, ShippedState, DeliveredState and CancelledState weren't touched, and packing is forbidden in them without you writing a line for it. A paid order can no longer be shipped directly, because PaidState has no ship. With the chain of ifs, the new event had to cross your mind in every method.

See it

Without a class for each state: an enum and a table

Here, cancelling a paid order refunds the money: the state does something, and that belongs in a class. If the states don't do anything and only say which transition is allowed, less is enough.

An enum is a type with a fixed list of values. The status is one of them, and the table says which statuses you can move to from each one:
enum Status { NEW, PAID, SHIPPED, DELIVERED, CANCELLED }
class Order {
private static final Map<Status, Set<Status>> NEXT = Map.of(
Status.NEW, Set.of(Status.PAID, Status.CANCELLED),
Status.PAID, Set.of(Status.SHIPPED, Status.CANCELLED),
Status.SHIPPED, Set.of(Status.DELIVERED),
Status.DELIVERED, Set.of(),
Status.CANCELLED, Set.of());
private Status status = Status.NEW;
public void moveTo(Status next) {
if (!NEXT.get(status).contains(next)) {
throw new IllegalStateException(status + " → " + next + ": not allowed");
}
status = next;
}
public Status status() {
return status;
}
}
Order order = new Order();
order.moveTo(Status.PAID);
order.moveTo(Status.SHIPPED);
try {
order.moveTo(Status.CANCELLED);
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
System.out.println(order.status());
Output
SHIPPED → CANCELLED: not allowed
SHIPPED

The check is in one place, and the transitions are data: one row in the table for a new status. This is the honest choice when the states have no behavior. Once some transition has to do something (refund the money, send an email, record when it was shipped), the table starts growing ifs around it, and then State is worth its classes.

More precisely, if you are curious

The states here have no fields, so one object of each would be enough for all orders. In Java this is often written as an enum in which each value has its own methods (NEW { OrderState pay() { return PAID; } }). This saves objects, but it has a cost: once a state gets a field (the amount to refund, the payment date), one shared object shares it between all orders. That's why new on every transition is the safer start.

Try it

Predict the output

The order is paid and cancelled right away, and then someone tries to ship it. What does the program print?

Order order = new Order();
order.pay();
order.cancel();
System.out.println(order.status());
try {
order.ship();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
Answer

Try it

Find the bug

Almost every state allows cancelling, so someone decides to write it once, in the base class:

abstract class OrderState {
abstract String name();
OrderState pay() {
throw refuse("be paid");
}
OrderState ship() {
throw refuse("be shipped");
}
OrderState deliver() {
throw refuse("be delivered");
}
OrderState cancel() {
return new CancelledState();
}
IllegalStateException refuse(String action) {
return new IllegalStateException("The order is " + name() + ": it can't " + action);
}
}
Order order = new Order();
order.pay();
order.ship();
order.cancel();
System.out.println(order.status());
Output
cancelled

The shipped order was cancelled without an error, and the parcel is on the road. Which line is the bug on?

Pick the line with the bug.

The idea

When you don't need it

State has a cost: one class for each state and a base class, instead of one field. It isn't worth it when:

  • there are two states and one transition. "Paid or not" is one boolean field;
  • the states have no behavior, only allowed transitions. Then an enum and a table from the previous card are enough;
  • the transitions come from outside (the shop owner sets them up in the admin panel). Classes live in the code, while data can change without a new version of the program.

It is worth it when there are more states, each one does something of its own on an event, and new ones keep coming.

Exercise

Returns within 14 days

Solve the task in your editor.

Returns. The customer can return a delivered order, and the shop refunds the money. Add an event returnOrder and a state "returned". It is allowed only from "delivered". Take one order all the way to delivered and return it, then try to return a new order.

Output
Refunding the customer
returned
The order is new: it can't be returned

More tasks

  • History. The order remembers the statuses it went through and prints them like this: new → paid → shipped → delivered. Where do you record the next status: in the order or in each state?
  • An email on every change. After every allowed transition the customer gets a line "Email: the order is paid". Make it so that a refused event sends no email.
  • Packed with the table. Add "packed" to the version with the enum and the table. Count how many places you changed there and how many in the version with classes.

Test

Check yourself

1. The shop adds a "pack" event. What do you write in the states where it is not allowed?
2. What does the order keep with State?
3. Someone makes cancelling allowed by default, in the base class, so it doesn't have to be written in every state. What happens to a shipped order?
4. When is an enum and a transition table enough, without a class for each state?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·