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:
| Part | In this lab | Job |
|---|---|---|
| Context | Order (or the session that holds it) | Delegates pay / refund / voidOrder to the current status. |
| State | OrderStatus implementations | One 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.
| State | Strategy | |
|---|---|---|
| Who chooses | The object itself, after a transition | The caller (or a mapper at the edge) |
| How long it lasts | Changes over the object’s life | Usually fixed for one process() |
| Typical lab type | OrderStatus on this Order | DiscountPolicy passed into the processor |
| Illegal combinations | Encoded as throws inside the current row | Usually 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.
DRAFTvsPAIDand no ticket for refund or void is a boolean, or oneifinprocess. Four classes andbecomeare 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 addOrderStatusso 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), notState. - 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 ifbecomeis package-visible in tests. - Keep
DiscountPolicyas Strategy. Do not store it as a status.
Don’t:
- Extract State for DRAFT vs PAID when refund does not exist.
- Call
setStatusfromOrderProcessorafter introducingbecome. - Pass the next status in as a Strategy argument and skip the transition table.
- Switch on
instanceof PaidStatusin 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.