Loading learning content...
Consider a seemingly simple scenario: you're building a document editor, and a document can be in different states—Draft, Under Review, Approved, or Published. Each state determines what actions are available and how the document responds to those actions.
In the Draft state, anyone can edit the document. Under Review, only reviewers can add comments. Once Approved, the document is read-only except for administrators. After Publishing, it's completely immutable.
This sounds straightforward until you start implementing it. Suddenly, every method in your Document class needs conditional logic checking the current state. The edit() method has four branches. The approve() method has four branches. The publish() method has four branches. Your clean, focused class becomes a labyrinth of conditionals where every operation must first ask: "What state am I in?"
By the end of this page, you will understand why state-dependent behavior creates deep structural problems in object-oriented design. You'll see how naive conditional approaches violate fundamental design principles, become maintenance nightmares, and resist testability. This understanding is essential before we explore how the State Pattern elegantly resolves these issues.
State-dependent behavior is ubiquitous in software systems. An object's state represents its current condition or mode at any given moment, and this state fundamentally determines how the object responds to requests.
What makes behavior state-dependent?
Behavior is state-dependent when:
This is fundamentally different from simple conditional branching. We're not just choosing between two options once—we're dealing with a continuous behavioral metamorphosis where the object essentially becomes a different actor while retaining its identity.
| Domain | Entity | States | State-Dependent Behavior |
|---|---|---|---|
| E-commerce | Order | Pending, Paid, Shipped, Delivered, Cancelled | cancel() works in Pending/Paid, illegal in Delivered |
| Networking | TCP Connection | Closed, Listen, SYN_Sent, Established, Close_Wait | send() only works in Established state |
| Gaming | Character | Alive, Stunned, Dead, Invulnerable | attack() has no effect when Dead or Stunned |
| Media | Video Player | Stopped, Playing, Paused, Buffering | pause() only meaningful when Playing |
| Finance | Account | Active, Frozen, Closed, Overdrawn | withdraw() rejected when Frozen or Closed |
| IoT | Smart Thermostat | Heating, Cooling, Idle, Away, Sleep | setTemperature() behavior varies by mode |
The key insight: In each of these examples, the operations remain the same (cancel, send, attack, pause, withdraw, setTemperature), but their implementations vary dramatically based on state. The operation's signature doesn't change—its semantics do.
This behavioral polymorphism based on internal state is the phenomenon we must model. The question is: how do we model it cleanly?
A critical distinction: we're discussing runtime behavioral variation, not static type hierarchies. A connection doesn't change its class from TcpConnection to UdpConnection—it remains a TcpConnection throughout its lifecycle. But its behavior when receiving packets changes fundamentally based on whether it's in the ESTABLISHED or CLOSE_WAIT state. This is dynamic, not static, polymorphism.
When developers first encounter state-dependent behavior, the natural instinct is to use conditional statements. After all, if we need different behavior based on a variable, a switch statement or if-else chain seems like the obvious solution.
Let's examine this approach in detail with a realistic example: modeling a vending machine.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
// Naive implementation: State modeled as enum with conditionals everywhereenum VendingMachineState { IDLE, // Waiting for coins HAS_COINS, // Coins inserted, waiting for selection DISPENSING, // Dispensing product OUT_OF_STOCK // Product unavailable} class VendingMachine { private state: VendingMachineState = VendingMachineState.IDLE; private balance: number = 0; private inventory: Map<string, number> = new Map(); insertCoin(amount: number): void { // Different behavior based on current state switch (this.state) { case VendingMachineState.IDLE: this.balance += amount; this.state = VendingMachineState.HAS_COINS; console.log(`Coin accepted. Balance: ${this.balance}`); break; case VendingMachineState.HAS_COINS: this.balance += amount; console.log(`Additional coin accepted. Balance: ${this.balance}`); break; case VendingMachineState.DISPENSING: console.log("Please wait, dispensing in progress..."); // Coin is rejected during dispensing break; case VendingMachineState.OUT_OF_STOCK: console.log("Machine is out of stock. Returning coin."); // Return the coin immediately break; } } selectProduct(productId: string): void { switch (this.state) { case VendingMachineState.IDLE: console.log("Please insert coins first."); break; case VendingMachineState.HAS_COINS: const price = this.getPrice(productId); const stock = this.inventory.get(productId) ?? 0; if (stock === 0) { console.log("Product out of stock."); this.state = VendingMachineState.OUT_OF_STOCK; } else if (this.balance >= price) { this.state = VendingMachineState.DISPENSING; this.dispenseProduct(productId); this.balance -= price; this.state = this.balance > 0 ? VendingMachineState.HAS_COINS : VendingMachineState.IDLE; } else { console.log(`Insufficient balance. Need ${price - this.balance} more.`); } break; case VendingMachineState.DISPENSING: console.log("Please wait, dispensing in progress..."); break; case VendingMachineState.OUT_OF_STOCK: console.log("Machine is out of stock. Please refund your coins."); break; } } refund(): void { switch (this.state) { case VendingMachineState.IDLE: console.log("No coins to refund."); break; case VendingMachineState.HAS_COINS: console.log(`Refunding ${this.balance} cents.`); this.balance = 0; this.state = VendingMachineState.IDLE; break; case VendingMachineState.DISPENSING: console.log("Cannot refund during dispensing."); break; case VendingMachineState.OUT_OF_STOCK: console.log(`Refunding ${this.balance} cents.`); this.balance = 0; this.state = VendingMachineState.IDLE; break; } } // ... many more methods, each with similar switch statements}At first glance, this code seems acceptable. It's readable, the logic is explicit, and it works correctly. But hidden within this straightforward implementation are structural time bombs waiting to detonate as the system grows.
The conditional approach to state-dependent behavior creates cascading problems that compound over time. These aren't minor inconveniences—they represent fundamental violations of sound object-oriented design principles.
The exponential growth problem:
Consider the mathematical reality. With S states and M methods, the conditional approach creates S × M conditional branches. Our vending machine has 4 states and 3 methods shown, meaning 12 branches. But real systems are larger:
| States | Methods | Conditional Branches |
|---|---|---|
| 4 | 5 | 20 |
| 6 | 8 | 48 |
| 10 | 12 | 120 |
| 15 | 15 | 225 |
Each branch is a potential bug location. Each branch must be tested. Each branch represents cognitive load for anyone reading the code.
The most insidious risk: when adding a new method, developers must remember to handle ALL states. When adding a new state, developers must remember to update ALL methods. Forgetting just one creates a runtime error or undefined behavior. The compiler can't help because switch statements with enums don't require exhaustive handling in most languages.
Let's examine a more complex real-world example: an e-commerce order processing system. Orders move through multiple states, each with distinct rules about what operations are permitted and what side effects they trigger.
This state machine has 13 states and numerous transitions. Now consider the operations an order must support:
cancel() — Only valid in certain states, with different side effectsupdateShippingAddress() — Only before shippingrequestRefund() — Valid only after delivery, within time windowaddItem() — Only in Created stateapplyDiscount() — Only before paymenttrackPackage() — Only after shippingconfirmDelivery() — Only in Shipped stateUsing conditionals, each of these 7+ methods needs branches for all 13 states. That's 91 conditional branches just for the core operations—not counting validation, logging, event emission, and other cross-cutting concerns.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
class Order { private state: OrderState; cancel(): void { switch (this.state) { case OrderState.CREATED: // Release inventory reservation this.releaseInventory(); this.notifyCustomer("Order cancelled before payment"); this.state = OrderState.CANCELLED; break; case OrderState.PAYMENT_PENDING: // Cancel payment hold this.cancelPaymentHold(); this.releaseInventory(); this.notifyCustomer("Order cancelled, payment voided"); this.state = OrderState.CANCELLED; break; case OrderState.PAID: // Initiate refund this.initiateRefund(); this.releaseInventory(); this.notifyCustomer("Order cancelled, refund initiated"); this.state = OrderState.CANCELLED; break; case OrderState.PROCESSING: // Stop warehouse, initiate refund this.haltWarehouseProcessing(); this.initiateRefund(); this.notifyCustomer("Order cancelled during processing"); this.state = OrderState.CANCELLED; break; case OrderState.SHIPPED: throw new IllegalStateException( "Cannot cancel shipped order. Please request a return instead." ); case OrderState.DELIVERED: case OrderState.RETURN_REQUESTED: case OrderState.RETURNED: case OrderState.REFUNDED: case OrderState.COMPLETED: throw new IllegalStateException( "Order in terminal state, cancellation not possible." ); default: throw new IllegalStateException("Unknown order state"); } } // Imagine 6 more methods like this, each with 13 cases...}If the business adds a 'Backordered' state, you must update EVERY method. If payment refund logic changes, you must find every conditional branch handling post-payment cancellation. If you want to add logging, you must add it to every branch. This code does not scale with complexity.
Conditional state management creates testing challenges that compound exponentially. Let's analyze what comprehensive testing actually requires.
12345678910111213141516171819202122232425262728293031323334353637383940414243
describe('Order cancellation', () => { // Must test cancel() in EVERY state describe('when in CREATED state', () => { it('should release inventory'); it('should notify customer with correct message'); it('should transition to CANCELLED'); it('should NOT attempt payment operations'); }); describe('when in PAYMENT_PENDING state', () => { it('should cancel payment hold'); it('should release inventory'); it('should notify customer with correct message'); it('should transition to CANCELLED'); }); describe('when in PAID state', () => { it('should initiate refund'); it('should release inventory'); it('should notify customer with correct message'); it('should transition to CANCELLED'); }); describe('when in PROCESSING state', () => { it('should halt warehouse processing'); it('should initiate refund'); it('should notify customer with correct message'); it('should transition to CANCELLED'); }); describe('when in SHIPPED state', () => { it('should throw IllegalStateException'); it('should not modify order state'); it('should not trigger any side effects'); }); // ... 8 more state-specific describe blocks for cancel() alone // Then repeat ALL of this for every other method: // updateShippingAddress(), requestRefund(), addItem(), // applyDiscount(), trackPackage(), confirmDelivery()...});The biggest testing problem isn't test count—it's isolation. To test SHIPPED state behavior, you must first transition through CREATED → PAYMENT_PENDING → PAID → PROCESSING → SHIPPED. Any bug in earlier transitions contaminates your SHIPPED tests. You're not testing state behavior; you're testing transition chains.
The theoretical problems translate directly into practical engineering costs. Let's examine real-world scenarios where conditional state management has created significant issues.
| System Complexity | Time to Add State | Time to Add Operation | Bug Fix Time |
|---|---|---|---|
| Simple (4 states, 5 operations) | 4 hours | 4 hours | 2 hours |
| Medium (8 states, 10 operations) | 2 days | 2 days | 1 day |
| Complex (15 states, 20 operations) | 1 week | 1 week | 3 days |
| Enterprise (30+ states, 50+ operations) | 2-3 weeks | 2-3 weeks | 1 week |
Teams often reach a breaking point where management asks: 'Should we rewrite this from scratch?' The answer is usually no—rewrites are risky and expensive. But the question arises because conditional state logic has made the existing system unmaintainable. Proper state management patterns prevent reaching this point.
Not every conditional is a problem. Simple two-state toggles (enabled/disabled) or straightforward mode switches may not warrant pattern application. The key is recognizing when complexity has crossed the threshold where conditional logic becomes a liability.
switch(state) in more than 2-3 methods, state logic is spreading.isProcessing, hasBeenPaid, waitingForShipment alongside a state enum suggests incomplete state modeling.A practical heuristic: Once you have three or more states with three or more state-dependent methods, consider the State Pattern. Below this threshold, conditionals may be simpler. Above it, the pattern's structural benefits outweigh its additional classes.
Questions to ask before applying the pattern:
Is the state finite and well-defined? States should be discrete, not continuous. If there are potentially infinite states, a different approach is needed.
Are transitions between states constrained? If any state can transition to any other, you might have a simpler mode switch rather than a true state machine.
Does behavior genuinely vary by state? If most operations are state-independent with just occasional checks, full pattern application may be overkill.
Will the state machine evolve? If states and operations are fixed forever, the overhead of the pattern may not pay off. If evolution is expected, the pattern's extensibility becomes essential.
Is the team comfortable with the pattern? Patterns require shared understanding. A well-implemented conditional approach may be better than a poorly understood pattern.
We've thoroughly examined why state-dependent behavior creates structural problems when handled with conditional logic. Let's consolidate these insights:
What's next:
Having established the problem clearly, we're ready to explore the elegant solution: the State Pattern. In the next page, we'll see how treating each state as a separate object with its own behavior transforms the monolithic conditional approach into a clean, extensible, testable design where:
You now understand why state-dependent behavior creates fundamental design challenges. The conditional approach that seems natural at first creates maintenance nightmares, testing difficulties, and violation of core design principles. Next, we'll see how the State Pattern addresses these problems through polymorphic state objects.