Hire Me
← All Writing Java

Enums as State Machines — Beyond Simple Constants

Use Java enums to build self-contained, type-safe state machines with validated transitions and behaviour attached directly to each state.

Most Java developers reach for enums to model a fixed set of constants and stop there. That’s a shame, because enums are first-class objects in Java — they can carry fields, implement interfaces, and define abstract methods. That set of capabilities maps almost perfectly onto what you need for a state machine: each state encapsulates its own behaviour and declares which transitions are valid from it. No external transition table required.

I’ve used this pattern extensively in betting exchange work where an order moves through a well-defined lifecycle. Get the transitions wrong and you end up with orders that claim to be both PENDING and SETTLED at the same time. The compiler won’t save you — but a well-designed enum state machine will.

The Basic Pattern

Start with the order lifecycle on Betfair: PENDING → PLACED → MATCHED → SETTLED, with CANCELLED reachable from PENDING or PLACED.

public enum BetStatus {

    PENDING {
        @Override
        public Set<BetStatus> transitions() {
            return Set.of(PLACED, CANCELLED);
        }

        @Override public boolean canCancel() { return true; }
        @Override public boolean canAmend()  { return true; }
    },

    PLACED {
        @Override
        public Set<BetStatus> transitions() {
            return Set.of(MATCHED, CANCELLED);
        }

        @Override public boolean canCancel() { return true; }
        @Override public boolean canAmend()  { return false; }
    },

    MATCHED {
        @Override
        public Set<BetStatus> transitions() {
            return Set.of(SETTLED);
        }

        @Override public boolean canCancel() { return false; }
        @Override public boolean canAmend()  { return false; }
    },

    SETTLED {
        @Override
        public Set<BetStatus> transitions() {
            return Set.of();
        }

        @Override public boolean canCancel() { return false; }
        @Override public boolean canAmend()  { return false; }
    },

    CANCELLED {
        @Override
        public Set<BetStatus> transitions() {
            return Set.of();
        }

        @Override public boolean canCancel() { return false; }
        @Override public boolean canAmend()  { return false; }
    };

    public abstract Set<BetStatus> transitions();
    public abstract boolean canCancel();
    public abstract boolean canAmend();
}

Each constant overrides the abstract methods and owns its own rules. There is no switch statement scattered across your service layer, no external Map<BetStatus, Set<BetStatus>> that can drift out of sync with the enum.

Validating Transitions

Add a transitionTo method directly on the enum so invalid transitions throw immediately at the call site:

public BetStatus transitionTo(BetStatus next) {
    if (!transitions().contains(next)) {
        throw new IllegalStateTransitionException(
            String.format("Cannot transition from %s to %s", this, next)
        );
    }
    return next;
}

Usage in your service layer becomes a one-liner:

order.setStatus(order.getStatus().transitionTo(BetStatus.MATCHED));

If the transition is invalid, an exception is thrown before the entity is touched. The state machine enforces correctness rather than relying on every developer who touches the codebase knowing which transitions are legal.

Attaching Behaviour to States

The abstract method approach works well for a handful of methods, but can become verbose. For richer behaviour, implement an interface:

public interface OrderBehaviour {
    boolean canCancel();
    boolean canAmend();
    String displayLabel();
}

public enum BetStatus implements OrderBehaviour {

    PENDING {
        @Override public boolean canCancel()   { return true; }
        @Override public boolean canAmend()    { return true; }
        @Override public String displayLabel() { return "Awaiting placement"; }
        // ...
    },

    MATCHED {
        @Override public boolean canCancel()   { return false; }
        @Override public boolean canAmend()    { return false; }
        @Override public String displayLabel() { return "Fully matched"; }
        // ...
    }
    // ...
}

Now your UI layer can call order.getStatus().displayLabel() without a switch, and your service layer calls order.getStatus().canCancel() without an if-chain. The enum is the single source of truth for everything related to a bet’s state.

Switch Expressions (Java 14+)

When you do need to dispatch on state — perhaps for metrics tagging or serialisation — sealed switch expressions make the exhaustiveness check explicit:

String metricTag = switch (order.getStatus()) {
    case PENDING   -> "pending";
    case PLACED    -> "placed";
    case MATCHED   -> "matched";
    case SETTLED   -> "settled";
    case CANCELLED -> "cancelled";
};

No default branch needed: if you add a new BetStatus constant and forget to handle it here, the compiler refuses to compile. That’s the property you want — the state machine forces every callsite to stay up to date.

Compared to an External Transition Table

The alternative is a Map<BetStatus, Set<BetStatus>> maintained separately from the enum, often in a StateTransitionService or configuration class. This approach has one advantage: you can swap out the transition rules at runtime or load them from a database. For most applications that flexibility is theoretical — the transitions are part of the business domain, not configuration.

The downsides of the external table are real. The enum constants and the transition rules can drift out of sync. New constants can be added without updating the table. The logic is split across at least two classes. Behaviour methods like canCancel() still end up as switch statements somewhere.

The in-enum pattern keeps everything co-located, enforces completeness at compile time, and requires no additional infrastructure. It is the right default for any domain object with a bounded, well-understood lifecycle.

When to Reach for Something Else

Enums as state machines work best when:

When your state needs to carry data (e.g. PLACED(orderId, timestamp)), consider a sealed class hierarchy instead. When you need audit logs, distributed coordination, or saga choreography, a proper workflow engine or event-sourced aggregate is the right tool.

If you’re building a Java system where business rules drive complex object lifecycles and you want the compiler on your side, get in touch.