STATE / QUICK REFERENCE
One context.
Behavior follows its state.
Delegate state-dependent commands to the current state object. A transition changes which rules handle the next command.
- What changes?
- The rules for the next command depend on whether the order is Pending, Paid, Shipped or Cancelled.
- What stays the same?
- The caller sends pay, ship or cancel to the same Order object.
- Where do the rules live?
- Each state object handles its own commands and proposes the next state. Order applies the decision.
The roles
- Context · Order
- Retains data and one current OrderState. Delegates commands and applies accepted decisions.
- State contract · OrderState
- handle(event) returns an accepted/rejected decision, optional effect and next state.
- Concrete states · Pending, Paid, Shipped, Cancelled
- Own stage-specific behavior. Pending can pay; Paid can ship or cancel with a refund; terminal states reject commands.
- Caller · checkout / fulfilment UI
- Sends commands to Order. It does not directly install arbitrary lifecycle stages.
The order’s rules at a glance
- Pending + pay → Paid · record one payment.
- Pending + cancel → Cancelled · no refund.
- Paid + ship → Shipped · record one shipment.
- Paid + cancel → Cancelled · record one refund.
- Everything else → reject · state and counters stay unchanged.
The states return destinations. Order installs them. Shipped and Cancelled are terminal in this small model; a real order system may include delivery, returns and disputed payments.
The delegation boundary
State interface and decision · TypeScript
export type OrderEvent = "pay" | "ship" | "cancel";
export type OrderStatus = "Pending" | "Paid" | "Shipped" | "Cancelled";
export type OrderSnapshot = Readonly<{
status: OrderStatus;
payments: number;
shipments: number;
refunds: number;
}>;
export type Decision = Readonly<{
accepted: boolean;
message: string;
next?: OrderState;
effect?: "payments" | "shipments" | "refunds";
}>;
export interface OrderState {
readonly name: OrderStatus;
handle(event: OrderEvent): Decision;
}Context: no stage checks · TypeScript
export class Order {
private state: OrderState = new Pending();
private payments = 0;
private shipments = 0;
private refunds = 0;
snapshot(): OrderSnapshot {
return Object.freeze({
status: this.state.name,
payments: this.payments,
shipments: this.shipments,
refunds: this.refunds,
});
}
send(event: OrderEvent): OrderRecord {
const before = this.snapshot();
const decision = this.state.handle(event);
if (decision.accepted) {
if (decision.effect) this[decision.effect] += 1;
if (decision.next) this.state = decision.next;
}
return Object.freeze({
event,
before,
after: this.snapshot(),
accepted: decision.accepted,
message: decision.message,
});
}
}Concrete states and transitions · TypeScript
function reject(state: OrderStatus, event: OrderEvent): Decision {
return {
accepted: false,
message: `${state} rejects ${event}. Nothing changes.`,
};
}
export class Pending implements OrderState {
readonly name = "Pending";
handle(event: OrderEvent): Decision {
if (event === "pay")
return {
accepted: true,
message: "Record one payment; the order is now Paid.",
next: new Paid(),
effect: "payments",
};
if (event === "cancel")
return {
accepted: true,
message: "Cancel the unpaid order. No refund is needed.",
next: new Cancelled(),
};
return reject(this.name, event);
}
}
export class Paid implements OrderState {
readonly name = "Paid";
handle(event: OrderEvent): Decision {
if (event === "ship")
return {
accepted: true,
message: "Record one shipment; the order is now Shipped.",
next: new Shipped(),
effect: "shipments",
};
if (event === "cancel")
return {
accepted: true,
message: "Record one refund; the order is now Cancelled.",
next: new Cancelled(),
effect: "refunds",
};
return reject(this.name, event);
}
}
export class Shipped implements OrderState {
readonly name = "Shipped";
handle(event: OrderEvent): Decision {
return reject(this.name, event);
}
}
export class Cancelled implements OrderState {
readonly name = "Cancelled";
handle(event: OrderEvent): Decision {
return reject(this.name, event);
}
}TypeScript excerpts come from the running model. Equivalent Kotlin compilation is unverified. Counters simulate effects locally; they do not contact payment or shipment services.
Use it when behavior belongs to lifecycle stages.
A good fit
Several operations repeat the same state checks, and a stage’s rules change together. Grouping them makes each stage easier to reason about and test.
Costs and alternatives
More objects, more files, and transitions between states to track. Prefer a boolean, enum or transition table for a small stable lifecycle. A state-machine library can help when you need guards, hierarchy or tooling.
- State versus Strategy: both delegate to a collaborator. Strategy usually varies how to do the same job through caller choice; State varies permitted behavior as a lifecycle progresses.
- Adding a state: add its rules, update incoming/outgoing transitions and test neighboring stages. The context can stay unchanged while the command/effect contract stays compatible.
- Changing a label is insufficient: a status field does not automatically enforce commands. All entry points must use the state’s rules.
- Real effects need more: persistence, transactions, idempotency and concurrent requests need explicit policies. The local synchronous example demonstrates none of those guarantees.