Observer left a question open: today nothing prevents changeStatus(DELIVERED) on a freshly created, unpaid order. And the problem runs deeper than validating transitions: almost everything you can do with an order depends on where it is in its life cycle. Can it be cancelled? Free if it's created, with a penalty if it's being prepared, impossible while out for delivery. Can it be modified? Only before paying. The current code answers with a switch over the status... repeated in every method, growing with every new status. State proposes the reification you've come to expect by this point in the module: each state is an object, with that state's behavior inside, and the order delegates to its current state — which also decides where to transition next.
Contents
- The problem in PideYa: the order's life cycle
- The first attempt: a switch over an enum
- Intent and structure of the pattern
- The state diagram
- Complete Java implementation
- Design variants
- State vs. switch: the honest comparison
- When to use it and when not to
- Relationship with other patterns
- Common mistakes
- Exercises and conclusion
The problem in PideYa: the order's life cycle
The business rules, as dictated by operations:
| State | Pay? | Modify lines? | Cancel? | Outgoing transitions |
|---|---|---|---|---|
| CREATED | Yes | Yes | Yes, free | PAID, CANCELLED |
| PAID | No (already is) | No | Yes, with full refund | PREPARING, CANCELLED |
| PREPARING | No | No | Yes, with a 20% penalty | OUT_FOR_DELIVERY, CANCELLED |
| OUT_FOR_DELIVERY | No | No | No (it's on its way) | DELIVERED |
| DELIVERED | No | No | No | — (final) |
| CANCELLED | No | No | No | — (final) |
Notice the nature of the problem: it isn't one behavior with variants (that would be something else, and it has its lesson right after this one); it's the same object behaving like six different objects depending on the moment. The technical term is that Order is a finite state machine: states, permitted transitions, and per-state behavior.
The first attempt: a switch over an enum
public class Order {
private OrderStatusEnum status = OrderStatusEnum.CREATED;
public void pay() {
switch (status) {
case CREATED -> { charge(); status = OrderStatusEnum.PAID; }
default -> throw new InvalidTransitionException("Cannot pay in " + status);
}
}
public void cancel() {
switch (status) {
case CREATED -> status = OrderStatusEnum.CANCELLED;
case PAID -> { refundAll(); status = OrderStatusEnum.CANCELLED; }
case PREPARING -> { refundWithPenalty(); status = OrderStatusEnum.CANCELLED; }
default -> throw new InvalidTransitionException("Cannot cancel in " + status);
}
}
public void modifyLine(...) { switch (status) { /* ... */ } }
public void markOutForDelivery() { switch (status) { /* ... */ } }
// ... one switch per operation
}It's only honest to say it: for six states and four stable operations, this works and is readable. The problems appear with evolution:
- Each state's logic is scattered across every method: to know "what can be done while PREPARING" you must read one
casein everyswitchin the class. The state doesn't exist as a concept in the code; it exists as a repeated label. - Adding a state (here comes
WAITING_FOR_COURIER, courtesy of the DispatchCenter) means touching every switch — and forgetting one gives no compile error if there's adefault. - Per-state behavior grows: penalties that depend on time spent preparing, different notifications per state... each
casefattens untilOrderis a filing cabinet of conditionals. - The state × operation combination is prone to the forgotten case: Observer's bug (delivering without paying) was exactly a case without a defensive
default.
Intent and structure of the pattern
Intent (GoF): allow an object to alter its behavior when its internal state changes. The object will appear to change its class.
"Will appear to change its class" is the key phrase: an order out for delivery behaves like a different class than a freshly created one — because, underneath, its behavior lives in a different class.
classDiagram
class Order {
-state: OrderState
+pay()
+cancel()
+modifyLine(...)
+markOutForDelivery()
~transitionTo(next: OrderState)
}
class OrderState {
<<interface>>
+pay(o: Order)
+cancel(o: Order)
+modifyLine(o: Order, ...)
+markOutForDelivery(o: Order)
+name() String
}
class CreatedState
class PaidState
class PreparingState
class OutForDeliveryState
class DeliveredState
class CancelledState
OrderState <|.. CreatedState
OrderState <|.. PaidState
OrderState <|.. PreparingState
OrderState <|.. OutForDeliveryState
OrderState <|.. DeliveredState
OrderState <|.. CancelledState
Order o-- OrderState : delegates to the current one
| GoF role | In PideYa |
|---|---|
| Context (holds the current state and delegates) | Order |
| State (interface with the state-dependent operations) | OrderState |
| ConcreteState | CreatedState, PaidState, PreparingState, OutForDeliveryState, DeliveredState, CancelledState |
The state diagram
The complete machine, in the UML diagram designed exactly for this (we introduced it in the UML lesson; here it shines brightest):
stateDiagram-v2
[*] --> CREATED
CREATED --> PAID : pay()
CREATED --> CANCELLED : cancel() / free
PAID --> PREPARING : kitchen accepts
PAID --> CANCELLED : cancel() / full refund
PREPARING --> OUT_FOR_DELIVERY : courier picks up
PREPARING --> CANCELLED : cancel() / 20% penalty
OUT_FOR_DELIVERY --> DELIVERED : delivery confirmed
DELIVERED --> [*]
CANCELLED --> [*]
A piece of craft advice: draw this diagram before writing a single line of the pattern. It is the contract with the business (is cancelling out for delivery really impossible?), the exact list of classes to write, and the source of the tests (every arrow, a passing test; every missing arrow, a test that expects an exception).
Complete Java implementation
The State interface, with default implementations that reject: this way each concrete state only writes what it allows, and whatever is unwritten is automatically forbidden — the forgotten case becomes impossible:
public interface OrderState {
default void pay(Order o) { reject("pay", o); }
default void cancel(Order o) { reject("cancel", o); }
default void modifyLine(Order o, OrderLine l) { reject("modify", o); }
default void markPreparing(Order o) { reject("prepare", o); }
default void markOutForDelivery(Order o) { reject("dispatch", o); }
default void markDelivered(Order o) { reject("deliver", o); }
String name();
private void reject(String operation, Order o) {
throw new InvalidTransitionException(
"Cannot " + operation + " an order in state " + name());
}
}The concrete states. Each class is one column of the business table turned into code — compact, cohesive, with its own name:
public class CreatedState implements OrderState {
@Override
public void pay(Order o) {
o.chargeTotal(); // the context's work
o.transitionTo(new PaidState()); // transition decided HERE
}
@Override
public void cancel(Order o) {
o.transitionTo(new CancelledState()); // free: no refund needed
}
@Override
public void modifyLine(Order o, OrderLine line) {
o.applyModification(line); // the only state that allows it
}
@Override
public String name() { return "CREATED"; }
}
public class PreparingState implements OrderState {
@Override
public void cancel(Order o) {
o.refundWithPenalty(new BigDecimal("0.20"));
o.transitionTo(new CancelledState());
}
@Override
public void markOutForDelivery(Order o) {
o.transitionTo(new OutForDeliveryState());
}
@Override
public String name() { return "PREPARING"; }
}
public class DeliveredState implements OrderState {
// Final state: overrides nothing — everything rejected by default
@Override
public String name() { return "DELIVERED"; }
}The context. Order ends up clean of conditionals: it exposes the operations and delegates; and transitionTo is the only place where the state changes — the perfect spot to connect with Observer:
public class Order {
private OrderState state = new CreatedState();
public void pay() { state.pay(this); }
public void cancel() { state.cancel(this); }
public void modifyLine(OrderLine l) { state.modifyLine(this, l); }
public void markOutForDelivery() { state.markOutForDelivery(this); }
public void markDelivered() { state.markDelivered(this); }
/** The only gate for state changes; package visibility: only the states use it. */
void transitionTo(OrderState next) {
OrderState previous = this.state;
this.state = next;
EventLog.INSTANCE.info("Order " + id + ": " + previous.name() + " → " + next.name());
notifyObservers(previous); // Observer broadcasts what State authorizes
}
// chargeTotal(), refundWithPenalty(...), applyModification(...): the real work,
// invoked by the states; the data stays encapsulated here.
}The module's two pieces click together as if destined: State decides which transitions are legal; Observer broadcasts the ones that happen. The "deliver without paying" bug can no longer exist — CreatedState doesn't override markDelivered, so the call dies in an InvalidTransitionException before notifying anything.
And the distribution of responsibilities matters: the states decide what happens and where to go; the context executes the work (chargeTotal) and guards its data. States that paw at the order's fields are the first step into the mud.
Design variants
- States with or without data of their own? Ours are stateless (all data lives in
Order): they can be shared — reused constants instead ofnewon every transition (PaidState.INSTANCE, in the Flyweight spirit). If a state needs its own data (OUT_FOR_DELIVERY holding the assigned courier), it gets instantiated per transition and carries it inside. - Who decides the transition? Here, each state (the usual choice: the rule "from CREATED you go to PAID" is the state's knowledge). Alternative: the context or an external transition table decides, and the states only execute behavior — more declarative, less object-oriented.
- Entry/exit actions:
onEnter(Order)/onExit(Order)methods on the interface, invoked bytransitionTo— the ideal spot for per-state effects (on entering OUT_FOR_DELIVERY, notify the DispatchCenter). - State with an enum: in Java, an enum can implement methods per constant (
CREATED { void pay(...) {...} }, PAID {...}) — the full State pattern with the enum's exhaustiveness guarantee. Excellent for machines without per-state data; it falls short when the states need it (enums have no per-transition instances).
State vs. switch: the honest comparison
| Criterion | switch over enum |
State pattern |
|---|---|---|
| Few, stable states and operations | Wins: fewer pieces, everything in sight | Bureaucracy |
| Bulky/growing per-state logic | Filing-cabinet methods | Wins: one cohesive class per state |
| Adding a state | Touch N switches (no compiler help if there's a default) |
Add one class; the rest untouched |
| Adding an operation | One new method with its switch | Touch the interface and (with defaults) only the states that allow it |
| Seeing the whole machine at a glance | So-so (scattered across methods) | Poor (scattered across classes) — keep the stateDiagram as documentation |
| Per-state data | Awkward (fields only sometimes valid) | Natural (fields of the concrete state) |
The honest rule: the switch is not the villain; it's the right solution for small, stable machines. State earns its keep when per-state behavior carries weight and the machine evolves — exactly the case of PideYa's order, whose life cycle is the changing heart of the business.
When to use it and when not to
Use it when:
- An object's behavior depends on its state and must change at runtime as it transitions.
- The operations have large, repeated conditionals over the same state discriminant.
- The states have substantial behavior or data of their own, or the machine grows with the business.
Avoid it when:
- The machine is small and stable: enum + switch (or an enum with methods) is more direct.
- The "state" is just data being read, with no behavior attached: a plain field suffices.
- The "behavior variants" don't form a life cycle (there are no transitions): that's Strategy — the exact distinction, in one line here and in detail in the comparison: State transitions on its own between states that know each other; Strategy is chosen from outside and ignores its siblings.
Relationship with other patterns
- Strategy: structural twins (a context delegating to an interchangeable object); the difference is intent and who changes the object — mention made, head-to-head in the next lesson and in the comparison.
- Observer: State authorizes transitions, Observer broadcasts them; connected in
transitionTo. - Singleton/Flyweight: shared stateless states as single instances.
- Memento: to rewind the machine to an earlier point, photograph the context (including which state it was in).
- Command: the panel's commands (
CancelOrderCommand) invoke operations whose legality State now decides —undo()must account for theInvalidTransitionException.
Common mistakes
- Transitions from outside: a public
order.setState(new DeliveredState())destroys the machine — anyone can skip the rules. The gate (transitionTo) must be inaccessible from outside (package/private). - States that touch the context's data directly: the states decide, the context executes; if
PaidStatemanipulatesOrder's fields, encapsulation breaks and the states couple to the guts. - Forgetting the default behavior: without the rejecting
defaults, every state must implement everything, and the unhandled case comes back asnullor silence instead of a clear exception. - State explosion from combining dimensions: if the order needs an independent "payment state" × "kitchen state", don't create
PaidAndInTheOven: those are two parallel machines, each with its own State. - Duplicating the discriminant: keeping an additional
statusenum that must be manually synchronized with the state object — two sources of truth. If you need the name for persistence or display, derive it from the object (state.name()), don't store it separately.
Exercises
Exercise 1: a new state without touching the old ones
Operations introduces WAITING_FOR_COURIER: after PREPARING, if there's no courier, the order waits; when the DispatchCenter assigns one, it moves to OUT_FOR_DELIVERY; it can be cancelled with a full refund (the delay is our fault). Implement WaitingForCourierState and state which existing classes change.
Exercise 2: draw before you code
Model with a mermaid stateDiagram-v2 the state machine of a support ticket: OPEN → IN_PROGRESS (when an agent takes it) → RESOLVED (with a solution) → CLOSED (the customer confirms); from RESOLVED it can go back to IN_PROGRESS (the customer complains); from OPEN and IN_PROGRESS it can go straight to CLOSED if the customer withdraws it.
Exercise 3: State, switch, or Strategy?
Justify the tool for each case: (a) a courier's availability light: AVAILABLE ↔ BUSY ↔ ON_BREAK, with no behavior attached beyond the value itself; (b) a restaurant's onboarding document: DRAFT / IN_REVIEW / PUBLISHED / WITHDRAWN, with different edit permissions, validations, and notifications per state, and new states expected; (c) three ways of splitting the tip among couriers, selectable per country.
Solutions
Solution 1:
public class WaitingForCourierState implements OrderState {
@Override
public void cancel(Order o) {
o.refundAll(); // our fault: no penalty
o.transitionTo(new CancelledState());
}
@Override
public void markOutForDelivery(Order o) { // invoked by the center's assignment
o.transitionTo(new OutForDeliveryState());
}
@Override
public String name() { return "WAITING_FOR_COURIER"; }
}Only PreparingState changes: its markOutForDelivery now decides between transitioning to OutForDelivery (there's a courier) or to WaitingForCourier (there isn't) — it is the source state of the new arrow, and arrows are the knowledge of their source. Order, the interface, and the other four states remain untouched. Compare with the switch approach: every method with a switch would have changed.
Solution 2:
stateDiagram-v2
[*] --> OPEN
OPEN --> IN_PROGRESS : agent takes it
OPEN --> CLOSED : customer withdraws it
IN_PROGRESS --> RESOLVED : solution proposed
IN_PROGRESS --> CLOSED : customer withdraws it
RESOLVED --> IN_PROGRESS : customer complains
RESOLVED --> CLOSED : customer confirms
CLOSED --> [*]
(The value of the exercise is in the questions the diagram forces you to ask: can a CLOSED ticket be reopened? As stated, no — and now it's written down.)
Solution 3: (a) a plain enum (not even a switch): three values with no behavior — applying State would be the over-engineering of lesson 01-06; (b) State: substantial per-state behavior, transitions with rules, and a growing machine — the textbook case; (c) Strategy: alternative algorithms chosen by configuration (country), with no transitions between them — no tip calculation "transitions" into another. Which is, precisely, the next lesson.
Conclusion
State reifies the life cycle: each stage of the order is now a class that concentrates its behavior and knows its exits, Order delegates without a single conditional, and illegal transitions die at the gate with a first and last name — the hole Observer left open is closed, and both patterns collaborate in transitionTo: State authorizes, Observer broadcasts. You also take away the honest criterion (switch for small, stable machines; State when the behavior carries weight and the machine evolves) and the golden habit: first the state diagram, then the code.
The final head-to-head of the exercise revealed the exact boundary with the next pattern: alternative computations that don't transition, chosen from outside. PideYa has two textbook cases waiting: the delivery fees (by distance, flat rate, free with a promotion) and the courier assignment that the DispatchCenter left pending extraction (nearest, least busy, round-robin). Families of interchangeable algorithms behind an interface: see you in Strategy.
Software Design Patterns Course
Module 1: Introduction to Design Patterns
- What Are Design Patterns?
- History and Origin of Design Patterns
- Design Principles: SOLID and Other Foundations
- Essential UML for Understanding Patterns
- Classification of Design Patterns
- Advantages and Disadvantages of Using Design Patterns
Module 2: Creational Patterns
- Introduction to Creational Patterns
- Singleton
- Factory Method
- Abstract Factory
- Builder
- Prototype
- Comparing and Choosing Creational Patterns
Module 3: Structural Patterns
- Introduction to Structural Patterns
- Adapter
- Bridge
- Composite
- Decorator
- Facade
- Flyweight
- Proxy
- Comparing and Choosing Structural Patterns
Module 4: Behavioral Patterns
- Introduction to Behavioral Patterns
- Chain of Responsibility
- Command
- Interpreter
- Iterator
- Mediator
- Memento
- Observer
- State
- Strategy
- Template Method
- Visitor
- Comparing and Choosing Behavioral Patterns
Module 5: Applying Design Patterns
- How to Select the Right Pattern
- Practical Examples of Pattern Usage
- Design Patterns in Real Projects
- Refactoring with Design Patterns
- Anti-Patterns: When Patterns Become a Problem
Module 6: Advanced Design Patterns
- Design Patterns in Modern Architectures
- Design Patterns in Microservices
- Design Patterns in Distributed Systems
- Concurrency Patterns
- Design Patterns in Agile Development
