← Back to PatternPip

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.