State and command
Part of the free Low-Level Design course on LogicWiz, in the chapter: Behavioural patterns II. The class-diagram exercises run in the app.
Week 8: The order that was cancelled and then delivered
"This order went from CANCELLED to DELIVERED. Which of these six switches let that happen?"
Priya, reading a support ticket
Status checks in every method
A SwiftKart order moves through a few statuses: placed, packed, out for delivery, delivered. It can be cancelled before the rider leaves. Arjun's Order class keeps an enum and checks it in every method:
public class Order {
private OrderStatus status = OrderStatus.PLACED;
public void cancel() {
switch (status) {
case PLACED, PACKED -> status = OrderStatus.CANCELLED;
case OUT_FOR_DELIVERY -> throw new IllegalStateException("Rider has it");
default -> throw new IllegalStateException("Too late to cancel");
}
}
public void dispatch() {
if (status == OrderStatus.PACKED) {
status = OrderStatus.OUT_FOR_DELIVERY;
}
}
public void deliver() {
status = OrderStatus.DELIVERED; // no check at all
}
}
The bug is in deliver. It never checks the current status, so a cancelled order can be marked delivered. Look at dispatch too: on a wrong status it does nothing and says nothing.
The deeper problem is where the rules live. "What can a packed order do?" is a simple question. To answer it, you read every method and find the PACKED line in each. Add a new status such as READY_FOR_PICKUP and you edit every method again.
Each state as a class
State the problem in one line. An object's behaviour depends on its current state, and the rules for one state are spread across many methods.
The fix turns that around. Make one class per state. Each class answers every action for that state, and returns the next state.
public interface OrderState {
OrderState pack();
OrderState dispatch();
OrderState deliver();
OrderState cancel();
}
public class PackedState implements OrderState {
public OrderState pack() { throw new IllegalStateException("Already packed"); }
public OrderState dispatch() { return new OutForDeliveryState(); }
public OrderState deliver() { throw new IllegalStateException("Not dispatched yet"); }
public OrderState cancel() { return new CancelledState(); }
}
public class Order {
private OrderState state = new PlacedState();
public void pack() { state = state.pack(); }
public void dispatch() { state = state.dispatch(); }
public void deliver() { state = state.deliver(); }
public void cancel() { state = state.cancel(); }
}
This is the State pattern. The Order is the context: it holds the current state object and passes every action to it. Each state class decides what is allowed and which state comes next. Now CancelledState.deliver() throws, and the bug cannot come back.
stateDiagram-v2
[*] --> Placed
Placed --> Packed: pack
Placed --> Cancelled: cancel
Packed --> OutForDelivery: dispatch
Packed --> Cancelled: cancel
OutForDelivery --> Delivered: deliver
Delivered --> [*]
Cancelled --> [*]
Writing four methods in every state class gets repetitive. A common fix is an abstract base class, BaseOrderState, whose methods all throw "not allowed". Each state then overrides only the moves it permits.
Testing also changes. Before, you tested Order with every status for every method. Now each state class has a small test of its own. For CancelledState, check that every action throws. For PackedState, check that dispatch returns an OutForDeliveryState.
State or strategy?
Arjun looked at the class diagram and said: "This is the same as last week. An interface and some classes. It is strategy again."
The diagrams do look the same. The intent is different, and interviewers ask about it:
- Strategy: someone outside picks the rule, such as the fee selector picking
RainFee. The strategies do not know about each other. The choice rarely changes during the object's life. - State: the object moves itself from state to state as events happen. Each state knows which states come next. The current state changes many times in the object's life.
A short test: if the client chooses, it is strategy. If the object changes its own behaviour as it moves through a lifecycle, it is state.
When not to use State: if there are two or three statuses and only one method checks them, a single switch is clearer. Next week you will also see a lighter option, a table of allowed moves.
Actions as objects: command
A different problem from the cart screen. A customer removes the milk from the cart, and a bar appears: "Item removed. Undo". Arjun's first idea was a lastRemovedItem field on Cart. Then product asked for undo on "apply coupon" and "change quantity" too, and for a history of the last five actions.
The problem: you need to store an action so you can undo it, retry it or run it later. A method call happens and is gone. So turn each action into an object that knows how to do itself and how to reverse itself.
public interface CartCommand {
void execute();
void undo();
}
public class RemoveItem implements CartCommand {
private final Cart cart;
private final String sku;
private int removedQty;
public RemoveItem(Cart cart, String sku) {
this.cart = cart;
this.sku = sku;
}
public void execute() { removedQty = cart.remove(sku); }
public void undo() { cart.add(sku, removedQty); }
}
The command keeps what it needs to reverse itself: here, how many units were removed. A separate class runs commands and remembers them. It is called the invoker.
public class CartHistory {
private final Deque<CartCommand> done = new ArrayDeque<>();
public void run(CartCommand command) {
command.execute();
done.push(command);
}
public void undoLast() {
if (!done.isEmpty()) {
done.pop().undo();
}
}
}
This is the Command pattern: a request wrapped as an object with an execute method, and often undo. Because the request is now an object, you can:
- keep a stack of them for undo,
- put them in a queue for a worker to run later,
- retry one that failed, such as "assign a rider" when no rider was free.
⚠️ Warning: A command object lives in memory. To queue work across services or survive a restart, store the command's data (type, order id, arguments) in a database or queue, not the Java object.
Retry works the same way. Say AssignRider fails because no rider is free. The dispatcher puts the command object back in a queue, and a worker runs execute again 30 seconds later. The worker does not know what the command does. It only knows how to run one. That separation is the point: the code that decides what to do is apart from the code that decides when to do it.
When not to use Command: if an action is never undone, queued, logged or retried, a plain method call is easier to read.
Practice: states and commands
First, replace the status switches with state classes. The order should hold an OrderState, and at least three state classes should implement it.
Then design undo for the cart. Add a command interface with execute and undo, two commands, and an invoker that keeps the history.
📌 Summary: State puts each lifecycle stage in its own class, and the state picks the next state. Strategy is chosen from outside. Command turns an action into an object for undo, queues or retries.
What Arjun learns next
Before an order is placed, SwiftKart checks stock, the slot, the address and the payment. Arjun's validate method has grown to 120 lines. Next: chain of responsibility, and template method for weekly payouts.
Check your understanding
- In Arjun's new design, PackedState.dispatch() returns new OutForDeliveryState(). Who decides the next state?
- Arjun says: "State and strategy are the same pattern, the class diagrams match." What is the difference?
- Product wants "Undo" for remove item, change quantity and apply coupon. Which design fits best?
- An order has two statuses, ACTIVE and CLOSED, checked in one method. Should you add the State pattern?