// Change at scaleWorked on 2015–now

Model business workflows as state machines

Many 'weird bugs' in order, refund and tenant systems are illegal state transitions. Make states and transitions explicit, enforce them in the database with a conditional write, and keep an audit trail.

#architecture#java#databases#ddd

Orders, refunds, subscriptions, tenants, support tickets, deployments — most business entities move through a lifecycle. And a whole category of production bugs comes from that lifecycle being implicit:

  • A cancelled order gets shipped because the cancel and the ship request arrived 50 ms apart.
  • A refund is issued twice because two retries both saw status = 'PAID'.
  • An account stuck in PROVISIONING for a week because a step failed and nothing defined what happens next.

In each case, the states existed only as a string column, and any code path could set any value. The fix is to make the state machine explicit, and to enforce it where concurrency actually happens: in the database.

Step 1: name the states and transitions

Write them down in one place. For an order:

Order states: CREATED to PAID to PACKED to SHIPPED to DELIVERED. CREATED and PAID can move to CANCELLED. PAID can move to REFUND_PENDING then REFUNDED.Order states: CREATED to PAID to PACKED to SHIPPED to DELIVERED. CREATED and PAID can move to CANCELLED. PAID can move to REFUND_PENDING then REFUNDED.
Every arrow is an allowed transition; anything else is a bug. Explore the interactive diagram ↗

In Java, an enum with its allowed transitions keeps the rules next to the states:

public enum OrderStatus {
    CREATED, PAID, PACKED, SHIPPED, DELIVERED, CANCELLED, REFUND_PENDING, REFUNDED;

    private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED = Map.of(
        CREATED,        Set.of(PAID, CANCELLED),
        PAID,           Set.of(PACKED, CANCELLED, REFUND_PENDING),
        PACKED,         Set.of(SHIPPED),
        SHIPPED,        Set.of(DELIVERED),
        REFUND_PENDING, Set.of(REFUNDED, PAID)   // refund rejected -> back to PAID
    );

    public boolean canMoveTo(OrderStatus next) {
        return ALLOWED.getOrDefault(this, Set.of()).contains(next);
    }
}

DELIVERED, CANCELLED and REFUNDED have no outgoing transitions: they’re terminal. Writing that down is itself useful — it’s the conversation with product (“can a delivered order be cancelled?”) that usually never happened.

Step 2: enforce transitions with a conditional write

Checking canMoveTo in memory isn’t enough. Two requests can each load the order as PAID, each decide the transition is valid, and each write their new state. The last write wins, and one of them silently overwrote the other.

Make the database do the check, atomically:

UPDATE orders
SET    status = 'CANCELLED', version = version + 1, updated_at = now()
WHERE  id = :id
AND    status IN ('CREATED', 'PAID');      -- the allowed "from" states

If the update touches one row, you won the transition. If it touches zero, someone else moved the order first — reload and decide what to tell the caller (“this order has already shipped”). No locks held across network calls, no race.

The same idea works on stores without SQL. In DynamoDB it’s a ConditionExpression on the update; in a document store it’s a compare-and-set on a version field. The principle is identical: the write states what it expects the current state to be.

@Transactional
public void cancel(long orderId, String reason, String actor) {
    int updated = jdbc.update("""
        UPDATE orders SET status = 'CANCELLED', version = version + 1
        WHERE id = ? AND status IN ('CREATED', 'PAID')
        """, orderId);
    if (updated == 0) {
        throw new IllegalTransitionException(orderId, OrderStatus.CANCELLED);
    }
    transitions.record(orderId, OrderStatus.CANCELLED, reason, actor);
}

Step 3: record every transition

Keep an append-only history next to the current state:

CREATE TABLE order_transitions (
    order_id    BIGINT      NOT NULL,
    from_status TEXT        NOT NULL,
    to_status   TEXT        NOT NULL,
    actor       TEXT        NOT NULL,     -- user, service, or job that caused it
    reason      TEXT,
    at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

Written in the same transaction as the state change, it answers the questions support and finance actually ask — who cancelled this, and when? — and it’s invaluable when debugging how an entity reached a strange state.

Step 4: make failure states first-class

Long-running lifecycles (provisioning, migrations, onboarding) need explicit failure states with an exit:

  • PROVISIONING_FAILED → PROVISIONING (retry) or → DEPROVISIONING (clean up).
  • A job that times out of a state moves to a failure state instead of sitting there forever. A periodic sweep that finds entities older than X in a transient state is a cheap, effective safety net.

An entity “stuck” in a state with no defined way out is a design gap, not an operations problem.

Where the state machine lives

Keep the rules in one place — an enum, a small domain class, or a library — and route every status change through it. If you find order.setStatus(...) scattered across controllers, jobs and consumers, that’s the refactor to do first.

For lifecycles with many steps, timers and external calls, the state machine often becomes the backbone of a workflow; at that point a durable workflow engine may fit better (see Durable workflows vs. a queue and a cron job). The two combine well: the workflow drives the steps, and the entity’s state machine guarantees that no other code path can move it illegally in the meantime.

Takeaways

  • Write down every state and every allowed transition; agree on terminal states with product.
  • Enforce transitions with a conditional write that names the expected current state.
  • Keep an append-only transition history, written in the same transaction.
  • Give every transient state a timeout and every failure state a way out.
// TL;DR, in one picture
order #1042status: PAID(for now)cancel()ship()t = 0 mst = 50 mssaw PAID ✓saw PAID ✓Both checked the status. Both were right.Use a conditional write.ayushwing.com