Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 032 / 100

State pattern: modeling an order lifecycle — a deep dive

Replace boolean flags with explicit states.

Expona Admin8 min read

State pattern: modeling an order lifecycle

I've seen countless production bugs trace back to a single root cause: boolean flags that shouldn't coexist, but do. An order that's both isPaid and isCancelled, a payment marked isProcessing and isRefunded simultaneously—these are data states that contradict each other, yet no code stopped them from happening. The State pattern solves this by replacing scattered flags with explicit states, each aware of its own rules and valid transitions.

In this article, I'll walk through how I've used the State pattern to model order lifecycles across e-commerce and ride-hailing platforms. You'll see how to eliminate impossible states, enforce transitions in one place, and make your domain logic self-documenting.

Order states — Steps: Pending → Paid → Shipped → Delivered

Let me start with the simplest mental model: an order moves through a series of distinct, mutually exclusive states. In e-commerce, that usually looks like Pending → Paid → Shipped → Delivered, but you might also have Failed, Cancelled, or Returned states depending on your business.

The key insight is that each state is a real thing—not a computed property, not a flag, but a concrete entity. I like to model this with an enum or discriminated union:

typescript
type OrderState = 
  | { kind: 'Pending' }
  | { kind: 'Paid' }
  | { kind: 'Shipped'; trackingNumber: string }
  | { kind: 'Delivered' }
  | { kind: 'Cancelled'; reason: string }
  | { kind: 'Failed'; error: string };

Notice that Shipped carries a trackingNumber, and Cancelled carries a reason. States can hold data relevant only to them. This prevents you from creating orders with a tracking number but no shipment, or a cancellation reason but no cancellation.

Your Order class then holds a single state field:

typescript
class Order {
  id: string;
  customerId: string;
  total: number;
  state: OrderState;

  constructor(id: string, customerId: string, total: number) {
    this.id = id;
    this.customerId = customerId;
    this.total = total;
    this.state = { kind: 'Pending' };
  }
}

In a real system, I've built this using interfaces too—one per state class—and a shared StateInterface. But for most mid-level teams, discriminated unions give you 95% of the benefits with cleaner TypeScript ergonomics.

The flag problem — isPaid, isShipped, isCancelled — and impossible combinations

Let me show you what I want to avoid. Here's the anti-pattern I've inherited in several codebases:

typescript
class Order {
  id: string;
  customerId: string;
  total: number;
  
  isPending: boolean = true;
  isPaid: boolean = false;
  isShipped: boolean = false;
  isCancelled: boolean = false;
  isDelivered: boolean = false;
}

On the surface, this looks flexible. But the moment you inherit or maintain this code, you're in trouble. What does it mean if isPaid and isCancelled are both true? What if isShipped is true but isPaid is false? These states are logically incompatible, but nothing in the code prevents them.

I've debugged order reconciliation issues where an order somehow had isPaid = true and isPending = true at the same time. A race condition in an older codebase had set both flags. The next developer who read that order thought it was in two states at once, leading to incorrect refund logic downstream.

With separate booleans, you also end up with defensive checks everywhere:

typescript
if (order.isPaid && !order.isCancelled && !order.isShipped) {
  // Can we ship?
}

if (order.isPaid && order.isShipped && !order.isCancelled) {
  // Can we deliver?
}

if (order.isCancelled && order.isPaid) {
  // Do we refund?
}

Each function repeats the same logic. Each new feature adds another conditional. The business rules are scattered across the codebase instead of living in one place.

Explicit transitions — Each state knows which transitions are allowed

Now, with the State pattern, transitions become explicit and localized. Each state declares: "from here, you can go to these other states."

I'll implement this with methods that return either a new state or throw an error:

typescript
class Order {
  id: string;
  customerId: string;
  total: number;
  state: OrderState;

  constructor(id: string, customerId: string, total: number) {
    this.id = id;
    this.customerId = customerId;
    this.total = total;
    this.state = { kind: 'Pending' };
  }

  pay(paymentId: string): void {
    if (this.state.kind !== 'Pending') {
      throw new Error(
        `Cannot pay order in ${this.state.kind} state`
      );
    }
    this.state = { kind: 'Paid' };
  }

  ship(trackingNumber: string): void {
    if (this.state.kind !== 'Paid') {
      throw new Error(
        `Cannot ship order in ${this.state.kind} state. Must be Paid first.`
      );
    }
    this.state = { kind: 'Shipped', trackingNumber };
  }

  deliver(): void {
    if (this.state.kind !== 'Shipped') {
      throw new Error(
        `Cannot deliver order in ${this.state.kind} state. Must be Shipped first.`
      );
    }
    this.state = { kind: 'Delivered' };
  }

  cancel(reason: string): void {
    if (this.state.kind === 'Delivered' || this.state.kind === 'Cancelled') {
      throw new Error(
        `Cannot cancel order in ${this.state.kind} state`
      );
    }
    this.state = { kind: 'Cancelled', reason };
  }
}

Look at what we've gained. The rule "you can't ship an order that isn't paid" is no longer scattered across three different payment processors and a shipment service. It lives in the ship() method. If a business rule changes—"actually, we now allow partial shipments"—you update it in one place.

The type system also helps. If you're using TypeScript's discriminated unions properly, your IDE will tell you which fields exist on each state:

typescript
const order = new Order('ord-123', 'cust-456', 99.99);
order.pay('pay-789');
order.ship('FEDEX12345');

if (order.state.kind === 'Shipped') {
  console.log(order.state.trackingNumber); // TypeScript knows this exists
}

// This is a compile error:
// console.log(order.state.trackingNumber); // if state is Pending, no trackingNumber

Enforced in one place — order.ship() throws if the order isn't paid

This is where the pattern earns its keep. In my ride-hailing platform, a driver could somehow accept a ride that never had its payment confirmed. The bug report was vague: "some rides end up weird." It turned out multiple services were checking isPaymentConfirmed and isRideActive in different ways, with different caching strategies.

Once I modeled ride state as a single source of truth, the issue vanished. The ride state itself enforced that you can't transition to "DriverAccepted" without "PaymentConfirmed" first.

Here's how enforcement looks in practice. Let's say we have an OrderService that orchestrates order transitions:

typescript
class OrderService {
  async confirmPayment(
    orderId: string,
    paymentId: string
  ): Promise {
    const order = await this.orderRepository.get(orderId);
    
    // This throws if state is not Pending
    order.pay(paymentId);
    
    await this.orderRepository.save(order);
    await this.eventBus.publish(
      new OrderPaidEvent(orderId, paymentId)
    );
  }

  async shipOrder(
    orderId: string,
    trackingNumber: string
  ): Promise {
    const order = await this.orderRepository.get(orderId);
    
    // This throws if state is not Paid
    order.ship(trackingNumber);
    
    await this.orderRepository.save(order);
    await this.eventBus.publish(
      new OrderShippedEvent(orderId, trackingNumber)
    );
  }

  async deliverOrder(orderId: string): Promise {
    const order = await this.orderRepository.get(orderId);
    
    // This throws if state is not Shipped
    order.deliver();
    
    await this.orderRepository.save(order);
    await this.eventBus.publish(new OrderDeliveredEvent(orderId));
  }
}

Notice that the service doesn't check preconditions before calling the order methods. The order class itself is responsible. If a race condition sends two pay() calls concurrently, the second one will fail. That failure is logged, retried (if appropriate), and never corrupts the order state.

At the database level, I also store the state as a single column—usually as a JSON field or an enum—rather than separate boolean columns. This makes the invariant obvious to anyone reading the schema.

Common mistakes

  • Keeping old boolean flags alongside state. If your Order class has both state and isPaid, you're asking for bugs. Delete the flags. Compute them from state if you need them: const isPaid = order.state.kind === 'Paid' || order.state.kind === 'Shipped';
  • Allowing too many transitions. I once designed a state machine where every state could transition to every other state. It "worked," but it defeated the purpose. Restrict transitions ruthlessly. If business changes, add a new state; don't make existing transitions permissive.
  • Storing state as a string without validation. If you store state as a string in the database, ensure deserialization validates it. I've seen a typo in a migration create orders with state "Piad" instead of "Paid". The code then treated them as unknown states. Use enums or discriminated unions to catch this at compile time.
  • Putting all state logic in the service, not the entity. The Order class should own its state transitions. The OrderService orchestrates side effects (publishing events, saving to the database). Keep this separation clean.
  • Forgetting about error states. Real systems fail. Add states for Failed, Cancelled, and Refunding explicitly. Don't try to cram error handling into normal states.

Key takeaways

  • Replace scattered boolean flags with a single state field. This eliminates impossible state combinations and makes the domain model obvious.
  • Each state should encode the data it needs. A Shipped state holds a tracking number; a Pending state doesn't.
  • Implement transitions as methods on the entity that validate preconditions before changing state. Throw early and loudly if a transition is invalid.
  • Enforce the rules in one place—the entity itself—not in services or controllers. This keeps your business logic cohesive.
  • Use TypeScript discriminated unions or enums to make impossible states unrepresentable. Let the type system help you.
  • Test state transitions explicitly. Verify that invalid transitions throw errors. Build confidence that the state machine is sound.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles