On This Page
Where a parcel can and can't go
A parcel can't be delivered before someone picks it up. A delivered parcel can't go back out for delivery. Say that out loud and it sounds obvious, but I've reviewed plenty of code where those rules live in five if statements spread over three services, and a boolean called isDelivered that quietly disagrees with a status string.
By the end of this you'll have a small, self-contained state machine pattern in Java: an enum for the states, an enum for the events, one table that holds every legal move, and a Parcel that refuses anything else. Here's what it prints when I run it:
== PK-1001 ends DELIVERED
CREATED --PICK_UP--> PICKED_UP
PICKED_UP --ARRIVE_AT_HUB--> IN_TRANSIT
IN_TRANSIT --ARRIVE_AT_HUB--> IN_TRANSIT
IN_TRANSIT --OUT_FOR_DELIVERY--> OUT_FOR_DELIVERY
OUT_FOR_DELIVERY --DELIVER--> DELIVERED
== PK-1002 ends RETURNED after 3 failed attempts
== rejected: No transition from DELIVERED on OUT_FOR_DELIVERY
Parcel PK-1002 never got a "return it" event from the caller. The machine sent it home by itself after the third failed delivery. That's a guard, and we'll build it in Step 4.
It's for any Java developer who has a status field and a growing pile of checks around it. No framework, no library. I built and ran everything below on a plain JDK. The only thing that tripped me was a serialVersionUID lint warning on the exception class, which you'll see fixed in the code.
stateDiagram-v2
[*] --> CREATED
CREATED --> PICKED_UP: PICK_UP
PICKED_UP --> IN_TRANSIT: ARRIVE_AT_HUB
IN_TRANSIT --> IN_TRANSIT: ARRIVE_AT_HUB
IN_TRANSIT --> OUT_FOR_DELIVERY: OUT_FOR_DELIVERY
IN_TRANSIT --> RETURNED: RETURN_TO_SENDER
OUT_FOR_DELIVERY --> DELIVERED: DELIVER
OUT_FOR_DELIVERY --> FAILED_ATTEMPT: FAIL_ATTEMPT
FAILED_ATTEMPT --> OUT_FOR_DELIVERY: OUT_FOR_DELIVERY
FAILED_ATTEMPT --> RETURNED: RETURN_TO_SENDER
DELIVERED --> [*]
RETURNED --> [*]
Versions I used
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 | java -version |
| Build | plain javac, no Maven or Gradle |
javac -version |
| IDE (optional) | IntelliJ IDEA or Eclipse, any recent release | Help → About |
You need Java 17 or newer. I also compiled the main code with --release 17 to make sure nothing here needs 21. A few vocabulary words before we start: a state is where the object is right now, an event is something that happens to it, and a transition is the allowed jump from one state to another when an event arrives. A guard is a condition that decides whether, or where, a transition goes.
Not sure why you'd pick this over the other behavioural patterns? If the algorithm changes, that's strategy. If a request travels along a fixed line of handlers, that's chain of responsibility. If the object's own history decides what's legal next, that's a state machine.
Create the project by hand
No wizard needed. Make the folders and you're done.
mkdir -p parcel-tracker/src/main/java/com/example/parcel parcel-tracker/src/test
cd parcel-tracker
parcel-tracker/
├── src/
│ ├── main/java/com/example/parcel/
│ │ ├── State.java
│ │ ├── Event.java
│ │ ├── Step.java
│ │ ├── IllegalTransitionException.java
│ │ ├── TransitionTable.java
│ │ ├── Parcel.java
│ │ └── Demo.java
│ └── test/Checks.java
In IntelliJ IDEA
Choose File → New → Project, pick Java, select your JDK 21, and leave the build system as IntelliJ. Create the package com.example.parcel under src and paste the files in. Mark the test folder as a test source root if you want the editor to treat it that way. I didn't click through the wizard in this run; the menu path is from JetBrains' current documentation.
In Eclipse
File → New → Java Project, JRE 21, then create the com.example.parcel package under src and add the classes. Same caveat: docs only, not executed.
With Maven or Gradle
The files drop straight into src/main/java. Put Checks.java under src/test/java and port it to JUnit 5 when you're ready. Maven Central wasn't reachable from my build machine, so I didn't run a Maven or JUnit build for this article. The checks below are a plain main method, and they behave the same way.
Write the machine, one piece at a time
Step 1: Name the states and events
Goal: make the vocabulary impossible to misspell.
src/main/java/com/example/parcel/State.java
package com.example.parcel;
public enum State {
CREATED, PICKED_UP, IN_TRANSIT, OUT_FOR_DELIVERY, DELIVERED, FAILED_ATTEMPT, RETURNED;
public boolean isTerminal() {
return this == DELIVERED || this == RETURNED;
}
}
src/main/java/com/example/parcel/Event.java
package com.example.parcel;
public enum Event {
PICK_UP, ARRIVE_AT_HUB, OUT_FOR_DELIVERY, DELIVER, FAIL_ATTEMPT, RETURN_TO_SENDER
}
Enums beat strings here for a boring reason: the compiler catches State.DELIVRED. isTerminal() marks the two states that end the story. I'd rather ask the enum than scatter == DELIVERED || == RETURNED through the codebase.
Checkpoint: compile with javac -d out src/main/java/com/example/parcel/*.java. Nothing should print.
Step 2: Write the rejection and the history record
Goal: decide what failure looks like before writing the happy path.
src/main/java/com/example/parcel/IllegalTransitionException.java
package com.example.parcel;
public class IllegalTransitionException extends RuntimeException {
private static final long serialVersionUID = 1L;
public IllegalTransitionException(State from, Event event) {
super("No transition from " + from + " on " + event);
}
}
src/main/java/com/example/parcel/Step.java
package com.example.parcel;
public record Step(State from, Event event, State to) {
@Override
public String toString() {
return from + " --" + event + "--> " + to;
}
}
Step is a record: three components, and the toString override gives the arrow format you saw in the output. The serialVersionUID line is there because my first compile with -Xlint:all printed:
IllegalTransitionException.java:3: warning: [serial] serializable class IllegalTransitionException has no definition of serialVersionUID
Harmless, but I like a clean lint run.
Step 3: Put every rule in one table
Goal: one place to read, review, and change the rules.
src/main/java/com/example/parcel/TransitionTable.java
package com.example.parcel;
import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
/** The whole rulebook in one place: which event moves which state where. */
public final class TransitionTable {
private final Map<State, Map<Event, State>> rules = new EnumMap<>(State.class);
private TransitionTable() { }
public static TransitionTable parcelRules() {
TransitionTable t = new TransitionTable();
t.allow(State.CREATED, Event.PICK_UP, State.PICKED_UP);
t.allow(State.PICKED_UP, Event.ARRIVE_AT_HUB, State.IN_TRANSIT);
t.allow(State.IN_TRANSIT, Event.ARRIVE_AT_HUB, State.IN_TRANSIT);
t.allow(State.IN_TRANSIT, Event.OUT_FOR_DELIVERY, State.OUT_FOR_DELIVERY);
t.allow(State.OUT_FOR_DELIVERY, Event.DELIVER, State.DELIVERED);
t.allow(State.OUT_FOR_DELIVERY, Event.FAIL_ATTEMPT, State.FAILED_ATTEMPT);
t.allow(State.FAILED_ATTEMPT, Event.OUT_FOR_DELIVERY, State.OUT_FOR_DELIVERY);
t.allow(State.FAILED_ATTEMPT, Event.RETURN_TO_SENDER, State.RETURNED);
t.allow(State.IN_TRANSIT, Event.RETURN_TO_SENDER, State.RETURNED);
return t;
}
private void allow(State from, Event event, State to) {
rules.computeIfAbsent(from, s -> new EnumMap<>(Event.class)).put(event, to);
}
public Optional<State> next(State from, Event event) {
return Optional.ofNullable(rules.getOrDefault(from, Map.of()).get(event));
}
}
The table is a map of maps: state → event → next state. Both levels are EnumMaps, which suit enum keys well (more in the EnumMap and EnumSet tutorial). next returns an Optional, so "no such move" is a value, not a null to forget about.
Read the allow lines top to bottom and you've read the business rules. The one that surprises people is IN_TRANSIT → IN_TRANSIT on ARRIVE_AT_HUB: a parcel passes through many hubs, so that self-loop is deliberate.
Step 4: Add the Parcel and its guard
Goal: apply events, record history, and handle "three strikes".
src/main/java/com/example/parcel/Parcel.java
package com.example.parcel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Parcel {
static final int MAX_FAILED_ATTEMPTS = 3;
private final String trackingId;
private final TransitionTable table;
private State state = State.CREATED;
private int failedAttempts;
private final List<Step> history = new ArrayList<>();
public Parcel(String trackingId) {
this(trackingId, TransitionTable.parcelRules());
}
public Parcel(String trackingId, TransitionTable table) {
this.trackingId = trackingId;
this.table = table;
}
public State fire(Event event) {
State from = state;
State to = table.next(from, event)
.orElseThrow(() -> new IllegalTransitionException(from, event));
record(from, event, to);
// Guard: the third failed attempt sends the parcel home automatically.
if (to == State.FAILED_ATTEMPT && ++failedAttempts >= MAX_FAILED_ATTEMPTS) {
State auto = table.next(to, Event.RETURN_TO_SENDER).orElseThrow();
record(to, Event.RETURN_TO_SENDER, auto);
}
return state;
}
private void record(State from, Event event, State to) {
history.add(new Step(from, event, to));
state = to;
}
public String trackingId() { return trackingId; }
public State state() { return state; }
public int failedAttempts() { return failedAttempts; }
public List<Step> history() { return Collections.unmodifiableList(history); }
}
fire does four things in order: look up the move (throw if there isn't one), record it, count failed deliveries, and apply the guard. When the third FAIL_ATTEMPT lands, the parcel records a second step, FAILED_ATTEMPT --RETURN_TO_SENDER--> RETURNED, using the table itself, so the auto-return is still a legal move rather than a back door. history() hands out an unmodifiable view, so callers can read the audit trail but not rewrite it.
Checkpoint: compile again. Same silent success.
Step 5: Write a demo
Goal: see all three outcomes: delivered, auto-returned, rejected.
src/main/java/com/example/parcel/Demo.java
package com.example.parcel;
public class Demo {
public static void main(String[] args) {
Parcel happy = new Parcel("PK-1001");
for (Event e : new Event[] {Event.PICK_UP, Event.ARRIVE_AT_HUB, Event.ARRIVE_AT_HUB,
Event.OUT_FOR_DELIVERY, Event.DELIVER}) {
happy.fire(e);
}
System.out.println("== " + happy.trackingId() + " ends " + happy.state());
happy.history().forEach(s -> System.out.println(" " + s));
Parcel unlucky = new Parcel("PK-1002");
unlucky.fire(Event.PICK_UP);
unlucky.fire(Event.ARRIVE_AT_HUB);
for (int i = 1; i <= 3; i++) {
unlucky.fire(Event.OUT_FOR_DELIVERY);
unlucky.fire(Event.FAIL_ATTEMPT);
}
System.out.println("== " + unlucky.trackingId() + " ends " + unlucky.state()
+ " after " + unlucky.failedAttempts() + " failed attempts");
try {
happy.fire(Event.OUT_FOR_DELIVERY);
} catch (IllegalTransitionException ex) {
System.out.println("== rejected: " + ex.getMessage());
}
}
}
Run it
mkdir -p out
javac -Xlint:all -d out src/main/java/com/example/parcel/*.java
java -cp out com.example.parcel.Demo
== PK-1001 ends DELIVERED
CREATED --PICK_UP--> PICKED_UP
PICKED_UP --ARRIVE_AT_HUB--> IN_TRANSIT
IN_TRANSIT --ARRIVE_AT_HUB--> IN_TRANSIT
IN_TRANSIT --OUT_FOR_DELIVERY--> OUT_FOR_DELIVERY
OUT_FOR_DELIVERY --DELIVER--> DELIVERED
== PK-1002 ends RETURNED after 3 failed attempts
== rejected: No transition from DELIVERED on OUT_FOR_DELIVERY
If you see those three == lines, the hard part's done.
Lock the rules in with 19 checks
src/test/Checks.java
import com.example.parcel.*;
import java.util.*;
public class Checks {
static int run, failed;
static void check(String name, boolean ok) {
run++;
if (!ok) failed++;
System.out.println((ok ? "PASS " : "FAIL ") + name);
}
static Parcel fireAll(Parcel p, Event... events) {
for (Event e : events) p.fire(e);
return p;
}
public static void main(String[] args) {
TransitionTable table = TransitionTable.parcelRules();
check("new parcel starts CREATED", new Parcel("A").state() == State.CREATED);
check("happy path ends DELIVERED",
fireAll(new Parcel("A"), Event.PICK_UP, Event.ARRIVE_AT_HUB, Event.OUT_FOR_DELIVERY, Event.DELIVER)
.state() == State.DELIVERED);
check("hub-to-hub loops stay IN_TRANSIT",
fireAll(new Parcel("A"), Event.PICK_UP, Event.ARRIVE_AT_HUB, Event.ARRIVE_AT_HUB, Event.ARRIVE_AT_HUB)
.state() == State.IN_TRANSIT);
Parcel retry = fireAll(new Parcel("A"), Event.PICK_UP, Event.ARRIVE_AT_HUB,
Event.OUT_FOR_DELIVERY, Event.FAIL_ATTEMPT, Event.OUT_FOR_DELIVERY, Event.DELIVER);
check("one failed attempt then delivered", retry.state() == State.DELIVERED && retry.failedAttempts() == 1);
Parcel three = new Parcel("A");
fireAll(three, Event.PICK_UP, Event.ARRIVE_AT_HUB);
for (int i = 0; i < 3; i++) fireAll(three, Event.OUT_FOR_DELIVERY, Event.FAIL_ATTEMPT);
check("third failure auto-returns", three.state() == State.RETURNED);
check("auto-return is recorded in history",
three.history().get(three.history().size() - 1).event() == Event.RETURN_TO_SENDER);
Parcel two = new Parcel("A");
fireAll(two, Event.PICK_UP, Event.ARRIVE_AT_HUB, Event.OUT_FOR_DELIVERY, Event.FAIL_ATTEMPT,
Event.OUT_FOR_DELIVERY, Event.FAIL_ATTEMPT);
check("two failures do not auto-return", two.state() == State.FAILED_ATTEMPT);
Parcel p = new Parcel("A");
try { p.fire(Event.DELIVER); check("illegal event throws", false); }
catch (IllegalTransitionException e) {
check("illegal event throws", e.getMessage().equals("No transition from CREATED on DELIVER"));
}
check("illegal event leaves state unchanged", p.state() == State.CREATED && p.history().isEmpty());
Parcel done = fireAll(new Parcel("A"), Event.PICK_UP, Event.ARRIVE_AT_HUB, Event.OUT_FOR_DELIVERY, Event.DELIVER);
boolean anyOut = false;
for (Event e : Event.values()) if (table.next(State.DELIVERED, e).isPresent()) anyOut = true;
check("DELIVERED has no outgoing transitions", !anyOut && done.state().isTerminal());
for (State s : State.values()) {
boolean hasOut = Arrays.stream(Event.values()).anyMatch(e -> table.next(s, e).isPresent());
check("terminal flag matches table for " + s, s.isTerminal() != hasOut);
}
Set<State> seen = EnumSet.of(State.CREATED);
Deque<State> todo = new ArrayDeque<>(seen);
while (!todo.isEmpty()) {
State s = todo.poll();
for (Event e : Event.values())
table.next(s, e).ifPresent(n -> { if (seen.add(n)) todo.add(n); });
}
check("every state reachable from CREATED", seen.equals(EnumSet.allOf(State.class)));
boolean immutable;
try { three.history().add(null); immutable = false; } catch (UnsupportedOperationException e) { immutable = true; }
check("history view is read-only", immutable);
System.out.println(run + " checks, " + failed + " failed");
if (failed > 0) System.exit(1);
}
}
javac -cp out -d out src/test/Checks.java
java -cp out Checks
PASS new parcel starts CREATED
PASS happy path ends DELIVERED
PASS hub-to-hub loops stay IN_TRANSIT
PASS one failed attempt then delivered
PASS third failure auto-returns
PASS auto-return is recorded in history
PASS two failures do not auto-return
PASS illegal event throws
PASS illegal event leaves state unchanged
PASS DELIVERED has no outgoing transitions
PASS terminal flag matches table for CREATED
PASS terminal flag matches table for PICKED_UP
PASS terminal flag matches table for IN_TRANSIT
PASS terminal flag matches table for OUT_FOR_DELIVERY
PASS terminal flag matches table for DELIVERED
PASS terminal flag matches table for FAILED_ATTEMPT
PASS terminal flag matches table for RETURNED
PASS every state reachable from CREATED
PASS history view is read-only
19 checks, 0 failed
Two of these are worth copying into any state machine you write. The "terminal flag matches table" check fails if someone marks a state terminal but leaves a way out, or forgets the flag on a dead end. The reachability check walks the table from CREATED and fails if any state is an island. Both catch real mistakes when somebody adds a state in six months.
To prove the guard check can fail, I changed >= MAX_FAILED_ATTEMPTS to >= 99 in a scratch copy and re-ran:
FAIL third failure auto-returns
FAIL auto-return is recorded in history
19 checks, 2 failed
Two red lines, which is what I wanted. (The grep I used also matched a harmless PASS ... FAILED_ATTEMPT line, which I've left out.) These are plain-Java checks, not JUnit; porting them to JUnit 5 is mechanical.
Why a table beats a pile of conditionals
The usual first attempt is a switch inside Parcel.fire. It works, and for three states it's fine. The problem shows up when you add a state. With a switch expression over an enum and no default, the compiler does warn you. I tried it with a cut-down enum:
Next.java:4: error: the switch expression does not cover all possible input values
return switch (s) {
^
1 error
That's useful, but it only tells you the state is unhandled. It can't tell you the event combination is unhandled, and you end up with a default -> throw that hides the gaps. The table makes every legal pair explicit and everything else illegal by default, and it's data you can loop over, which is how the reachability check works.
The guard is deliberately outside the table. The table says what's possible; Parcel decides what happens this time based on state it carries (failedAttempts). If your rules depend on more than the current state and event, keep that logic in code like this rather than inventing a DSL.
Two sibling patterns are worth a glance. A command object could wrap each Event if you need undo. And the enum-with-behaviour approach from the enums tutorial is a legitimate alternative: each state implements its own next(Event). I prefer the table for small machines because the whole rulebook fits on one screen.
Errors I hit, and the fixes
| What you see | Cause | Fix |
|---|---|---|
warning: [serial] serializable class IllegalTransitionException has no definition of serialVersionUID |
-Xlint:all and a RuntimeException subclass |
Add private static final long serialVersionUID = 1L; |
error: the switch expression does not cover all possible input values |
You added an enum constant to a switch without a default |
Handle the new constant, or move the rule into the table |
No transition from CREATED on null, or a bare NullPointerException |
fire(null): states that have rules look the event up in an EnumMap (returns nothing), but a state with no rules, like DELIVERED, falls back to Map.of(), which rejects null keys |
Add Objects.requireNonNull(event) at the top of fire so every state fails the same way |
FAIL third failure auto-returns after you edit the guard |
The threshold or the FAILED_ATTEMPT condition in Step 4 no longer matches the rule |
Restore the guard; the check is doing its job |
The null case surprised me. On a CREATED parcel I got a polite illegal-transition message; on a DELIVERED one the same call threw a bare NullPointerException (I ran both). Same bug, two behaviours, which is exactly why I'd add the requireNonNull line.
Before you ship it
- Thread safety.
Parcelisn't thread-safe. Two threads firing events on the same parcel can interleave. Makefiresynchronized, or serialise events per parcel through a queue. Concurrency tutorials here cover the options. - Persistence. Store the
Statename as a string in the database and replayhistoryas an audit table. Don't store the ordinal; reordering the enum would silently corrupt old rows. - Idempotency. Real scanners send the same event twice. Decide whether a duplicate
DELIVERis an error or a no-op, and write the check for it. - Bigger machines. At a few dozen states with timers and nested states, look at Spring Statemachine or a workflow engine. For parcels, orders, and tickets this hand-rolled version carries you a long way.
- Next up: JUnit 5 tests for the checks, and sealed classes if you want events that carry data (a signature, a hub id).
The short version
- Name states and events as enums.
- Put every legal move in one table; anything absent is illegal.
- Keep conditional logic (guards) in code beside the table, and route the result back through the table.
- Record each step so you have an audit trail for free.
- Test terminal states and reachability, beyond the happy path.
State machine questions people ask
What is the state machine pattern in Java?
It's a way to model an object that can only be in one of a fixed set of states and moves between them in response to events. In Java the lightest form is an enum for states, an enum for events, and a map of allowed transitions.
Should I use an enum or a class per state?
For small machines, an enum plus a transition table is easier to read and test. Class-per-state (the classic GoF State pattern) pays off when each state has substantial behaviour of its own.
Do I need Spring Statemachine?
Not for a handful of states. Reach for it when you need hierarchical states, timers, persistence, or distributed execution.
How do I stop invalid transitions?
Make "illegal" the default: look the move up in the table and throw when nothing is found, as Parcel.fire does here.