The ticket says “support can void a paid order, and a refunded order must reject a second refund.” You open OrderProcessor and find process already switching on DRAFT vs PAID. refund has the same enum. You add VOID and REFUNDED to both, then discover voidOrder needs a third copy. Every new status edits every verb. A merge from the capture-on-success branch conflicts in all three methods.

That is State’s entire complaint. From the Design Patterns Roadmap: when an object’s behavior depends on its internal status, that status should be an object that owns the legal transitions.

This post stays on the shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab. We do not re-lecture the three families. We split the status enum because this order changed — not because a caller picked a different discount algorithm.

The switch that copies itself per verb

Here is checkout after paid and a first-cut refund have landed as enum branches. Fine for two statuses. Unkind to four:

public class OrderProcessor {

    private final PaymentGateway gateway;
    private final DiscountPolicy discount;
    private final OrderRepository orders;

    public OrderProcessor(
            PaymentGateway gateway, DiscountPolicy discount, OrderRepository orders) {
        this.gateway = gateway;
        this.discount = discount;
        this.orders = orders;
    }

    public void process(Order order) {
        if (order.status() != OrderStatusCode.DRAFT) {
            throw new IllegalOrderStateException(order.id(), "pay", order.status());
        }
        BigDecimal payable = discount.payable(order);
        PaymentResult result = gateway.charge(order, payable);
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        orders.markPaid(order.id(), result.reference());
        order.setStatus(OrderStatusCode.PAID);
    }

    public void refund(Order order) {
        switch (order.status()) {
            case PAID -> {
                gateway.refund(order);
                orders.markRefunded(order.id());
                order.setStatus(OrderStatusCode.REFUNDED);
            }
            case DRAFT, REFUNDED, VOID ->
                    throw new IllegalOrderStateException(order.id(), "refund", order.status());
        }
    }

    public void voidOrder(Order order) {
        switch (order.status()) {
            case DRAFT, PAID -> {
                orders.markVoid(order.id());
                order.setStatus(OrderStatusCode.VOID);
            }
            case REFUNDED, VOID ->
                    throw new IllegalOrderStateException(order.id(), "void", order.status());
        }
    }
}

Now price the next status — captured-but-not-settled, or a cancel-from-draft that must not hit the gateway:

Add CAPTURED                  -> new case in process, refund, and voidOrder
Change who may void PAID      -> same three methods, same merge conflict
Test refund without pay       -> construct a processor and forge order.setStatus
Add a fifth verb (capture)    -> a fourth switch that repeats the enum

Four costs, and none of them are about the card network. Every verb has become a full copy of the status table.

Note: The problem is not switch. Two statuses that have not moved in two years can live in process. The problem is a table of legal transitions that grows on support’s calendar, sitting inside the class that also charges and persists.

What State actually is

Two parts, one promise:

PartIn this labJob
ContextOrder (or the session that holds it)Delegates pay / refund / voidOrder to the current status.
StateOrderStatus implementationsOne type per status. Encodes what is legal from here.

The context does not name PAID in a switch. The current status object decides, then replaces itself. If OrderProcessor.process still starts with if (order.status() instanceof DraftStatus), you moved the enum into types and kept the table.

You do not need a class named State. You need the verbs the order already exposes, implemented once per status:

public interface OrderStatus {

    void pay(Order order, PaymentGateway gateway, DiscountPolicy discount, OrderRepository orders);

    void refund(Order order, PaymentGateway gateway, OrderRepository orders);

    void voidOrder(Order order, OrderRepository orders);
}

That is the seam. DraftStatus and PaidStatus are implementations. Illegal verbs throw. Legal ones do the work and call order.become(...).

One class per status

Draft can pay and void. It cannot refund. Paid can refund and void. Refunded and void reject everything. Each class is the table for one row:

public final class DraftStatus implements OrderStatus {

    static final OrderStatus INSTANCE = new DraftStatus();

    @Override
    public void pay(Order order, PaymentGateway gateway, DiscountPolicy discount,
            OrderRepository orders) {
        BigDecimal payable = discount.payable(order);
        PaymentResult result = gateway.charge(order, payable);
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        orders.markPaid(order.id(), result.reference());
        order.become(PaidStatus.INSTANCE);
    }

    @Override
    public void refund(Order order, PaymentGateway gateway, OrderRepository orders) {
        throw new IllegalOrderStateException(order.id(), "refund", "DRAFT");
    }

    @Override
    public void voidOrder(Order order, OrderRepository orders) {
        orders.markVoid(order.id());
        order.become(VoidStatus.INSTANCE);
    }
}

Paid is the row that used to live in three case PAID arms:

public final class PaidStatus implements OrderStatus {

    static final OrderStatus INSTANCE = new PaidStatus();

    @Override
    public void pay(Order order, PaymentGateway gateway, DiscountPolicy discount,
            OrderRepository orders) {
        throw new IllegalOrderStateException(order.id(), "pay", "PAID");
    }

    @Override
    public void refund(Order order, PaymentGateway gateway, OrderRepository orders) {
        gateway.refund(order);
        orders.markRefunded(order.id());
        order.become(RefundedStatus.INSTANCE);
    }

    @Override
    public void voidOrder(Order order, OrderRepository orders) {
        orders.markVoid(order.id());
        order.become(VoidStatus.INSTANCE);
    }
}

RefundedStatus and VoidStatus throw on all three verbs — or you give them a shared TerminalStatus if they truly never diverge. Until a ticket says refunded orders can reopen, do not invent a type hierarchy for two identical reject-all classes; two small types are cheaper than a premature abstract parent.

The order holds the current status and forwards. become is the only mutator; processors do not call setStatus:

public class Order {

    private OrderStatus status = DraftStatus.INSTANCE;
    // id, email, items, total — same payload as the rest of the lab

    public void pay(PaymentGateway gateway, DiscountPolicy discount, OrderRepository orders) {
        status.pay(this, gateway, discount, orders);
    }

    public void refund(PaymentGateway gateway, OrderRepository orders) {
        status.refund(this, gateway, orders);
    }

    public void voidOrder(OrderRepository orders) {
        status.voidOrder(this, orders);
    }

    void become(OrderStatus next) {
        this.status = next;
    }
}

OrderProcessor becomes a thin edge: it does not own the table.

public class OrderProcessor {

    private final PaymentGateway gateway;
    private final DiscountPolicy discount;
    private final OrderRepository orders;

    public OrderProcessor(
            PaymentGateway gateway, DiscountPolicy discount, OrderRepository orders) {
        this.gateway = gateway;
        this.discount = discount;
        this.orders = orders;
    }

    public void process(Order order) {
        order.pay(gateway, discount, orders);
    }

    public void refund(Order order) {
        order.refund(gateway, orders);
    }

    public void voidOrder(Order order) {
        order.voidOrder(orders);
    }
}

Note: Keep status objects focused on what is legal from here. The moment PaidStatus.refund starts choosing a DiscountPolicy or sending email, Observer and Strategy have leaked into the row. Charge and persist can stay here because they are the transition. Fan-out after paid belongs on the success path you already extracted in Observer.

What the diff looks like now

Same void-from-paid ticket, both designs:

Before — add VOID to every verb
  M OrderProcessor.java      new case in process, refund, voidOrder
  M OrderProcessorTest.java  every test re-reads the enum table

After — PaidStatus.voidOrder becomes legal
  M PaidStatus.java          one row knows void is allowed
  A PaidStatusTest.java      void from paid; refund still works
  (OrderProcessor unchanged)

A transition change is one status class. OrderProcessor is closed against “support invented a new legal move” and still fully open when the workflow grows a new verb — capture lands on the OrderStatus interface and each row implements it. That is a real change to the protocol. Copy-pasting case VOID into a fourth method is not.

Proving a row without the whole processor

The seam pays a second dividend: you can test PaidStatus with a fake gateway and a recording repository, starting from an order that is already paid — no process() prelude required if you become in the test fixture.

@Test
void paidRefundMarksRefundedAndRejectsSecondRefund() {
    FakePaymentGateway gateway = FakePaymentGateway.alwaysApproved("ref-1");
    RecordingRepository orders = new RecordingRepository();
    Order order = Order.draft("o-1", "a@b.com", List.of(), new BigDecimal("40.00"));
    order.become(PaidStatus.INSTANCE);

    order.refund(gateway, orders);

    assertEquals(1, orders.markRefundedCalls().size());
    assertThrows(IllegalOrderStateException.class, () -> order.refund(gateway, orders));
}

@Test
void draftPayDeclinedDoesNotBecomePaid() {
    FakePaymentGateway gateway = FakePaymentGateway.alwaysDeclined("card_declined");
    RecordingRepository orders = new RecordingRepository();
    Order order = Order.draft("o-1", "a@b.com", List.of(), new BigDecimal("40.00"));

    assertThrows(PaymentDeclinedException.class,
            () -> order.pay(gateway, new NoDiscount(), orders));
    assertTrue(orders.markPaidCalls().isEmpty());
    assertThrows(IllegalOrderStateException.class, () -> order.refund(gateway, orders));
}

If a test for PercentOff rounding needs PaidStatus, you have mixed two reasons to change. Pricing still belongs on DiscountPolicy. Status owns whether pay may run.

State is not Strategy

Both replace a switch with an interface. Review comments that say “this is a Strategy” after you introduced OrderStatus are naming the extraction, not who chooses the object.

StateStrategy
Who choosesThe object itself, after a transitionThe caller (or a mapper at the edge)
How long it lastsChanges over the object’s lifeUsually fixed for one process()
Typical lab typeOrderStatus on this OrderDiscountPolicy passed into the processor
Illegal combinationsEncoded as throws inside the current rowUsually all implementations are valid

DiscountPolicies.from("PERCENT10", gold) is Strategy: checkout picks an algorithm before process. order.become(PaidStatus.INSTANCE) is State: after a successful charge, this order no longer pays the same way. If the caller passes new PaidStatus() into process as a parameter, you modeled a status as a Strategy and lost the transitions. Strategy still owns promos. Do not make DraftStatus a discount.

When State is the wrong move

Skip the status types when:

  • There are two statuses that never grow. DRAFT vs PAID and no ticket for refund or void is a boolean, or one if in process. Four classes and become are a state machine in search of a product.
  • The “states” are algorithms the caller picks. Percent vs flat is not an order status. It does not transition when the charge succeeds.
  • You still need a global table. If product wants a spreadsheet of every from-to pair in one file, a data-driven map may be more honest than ten types that each hide one cell. State shines when behavior (charge vs throw vs refund) differs, not when only a label changes.
  • The context is a stateless function. A pure payable(order) has no internal status to change. Do not add OrderStatus so the catalog looks complete.

The healthy trigger is a third status — or a second verb — that would copy the switch again. DRAFT / PAID / REFUNDED / VOID with different legal moves is that trigger. DRAFT / PAID forever is not.

State also is not a workflow engine. If you need timers, human approvals, and a visual map, look at a real process tool. OrderStatus is four small classes and become. Stop there until the product leaves checkout.

Cheat sheet

Context    Order                    holds current OrderStatus; pay/refund/void delegate
State      Draft / Paid / ...       one class per status; illegal verbs throw
Transition order.become(next)       only status objects call this
Processor  OrderProcessor           forwards; does not switch on the enum

Trigger to apply: a third status or second verb would copy the switch
Trigger to stop:  two statuses that never grow, or a caller-picked algorithm
Scoreboard:       new legal move = edit one status class; process() stays thin
Not Strategy:     this object changed vs caller chose the algorithm

Do:

  • Name the interface after the domain status (OrderStatus), not State.
  • Put legal transitions in the row that starts them. Throw everywhere else.
  • Test each status with fakes; do not require a full process() to fixture PAID if become is package-visible in tests.
  • Keep DiscountPolicy as Strategy. Do not store it as a status.

Don’t:

  • Extract State for DRAFT vs PAID when refund does not exist.
  • Call setStatus from OrderProcessor after introducing become.
  • Pass the next status in as a Strategy argument and skip the transition table.
  • Switch on instanceof PaidStatus in the processor to decide whether to send email — that is Observer, on the success path.

Wrap-up

State is an interface whose implementations are the object’s current status, and a context that delegates instead of switching. The DRAFT / PAID / REFUNDED / VOID table was expensive because every verb copied it. Moving the row into DraftStatus and PaidStatus makes the next legal move an edit to one class, keeps illegal refunds unit-testable without forging an enum, and leaves OrderProcessor as a forwarder. Two statuses that never grow can stay an if. A promo chosen at the edge is still Strategy.

Next optional step in the series Pass fraud, inventory, and payment down a chain that does not name the next class. Chain of Responsibility: Pass a Request Until Someone Handles It