Loading learning content...
Objects don't just have state—they change state. An order moves from pending to confirmed to shipped. A user transitions from anonymous to authenticated to logged-out. A document evolves from draft to review to published. These changes aren't random; they follow patterns, rules, and constraints that we call state transitions.
Understanding state transitions transforms how you design objects. Instead of thinking about static data structures, you begin to see objects as living entities with histories, futures, and well-defined paths through their existence. This temporal perspective is essential for building robust, predictable systems.
By the end of this page, you will understand how to model state transitions explicitly, design object lifecycles with clear phases, enforce valid transition rules, and use state machines to create predictable, self-documenting object behavior.
A state transition is a change in an object's state from one valid configuration to another. While the previous page discussed state as a snapshot, transitions are the edges that connect one snapshot to the next.
Formally, a state transition can be described as:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
public class Order { private OrderStatus status; private LocalDateTime statusChangedAt; public enum OrderStatus { PENDING, CONFIRMED, PROCESSING, SHIPPED, DELIVERED, CANCELLED } /** * State Transition: PENDING → CONFIRMED * * Trigger: confirm() method call * Guard: status must be PENDING * Action: update status, record timestamp * Result: status becomes CONFIRMED */ public void confirm() { // Guard condition: only PENDING orders can be confirmed if (status != OrderStatus.PENDING) { throw new IllegalStateException( "Cannot confirm order in " + status + " state. " + "Only PENDING orders can be confirmed." ); } // Action: perform the transition OrderStatus previousStatus = this.status; this.status = OrderStatus.CONFIRMED; this.statusChangedAt = LocalDateTime.now(); // Transition complete: S₁(PENDING) → S₂(CONFIRMED) log.info("Order transitioned: {} → {}", previousStatus, this.status); } /** * State Transition: CONFIRMED → PROCESSING * Similar pattern with different guard and actions */ public void startProcessing() { if (status != OrderStatus.CONFIRMED) { throw new IllegalStateException( "Cannot start processing order in " + status + " state" ); } this.status = OrderStatus.PROCESSING; this.statusChangedAt = LocalDateTime.now(); }}State transition rules encode business logic. "Orders can only be cancelled before shipping" is a business rule expressed as a transition constraint. When you model transitions explicitly, you make business rules visible, testable, and enforceable in code rather than just in documentation.
Every object has a lifecycle—a pattern of states it moves through from creation to destruction. Understanding lifecycles helps you design objects that behave predictably at every phase of their existence.
A typical object lifecycle includes:
| Object Type | Creation | Active States | Terminal States |
|---|---|---|---|
| Database Connection | connect() | OPEN, BUSY, IDLE | CLOSED |
| HTTP Request | new Request() | PENDING, IN_PROGRESS | COMPLETED, FAILED, TIMEOUT |
| User Session | login() | ACTIVE, IDLE | EXPIRED, LOGGED_OUT |
| Thread | new Thread() | RUNNABLE, BLOCKED, WAITING | TERMINATED |
| Order | new Order() | PENDING → ... → SHIPPED | DELIVERED, CANCELLED, REFUNDED |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
public class DatabaseConnection { public enum ConnectionState { // Creation phase INITIALIZING, // Active phase OPEN, // Ready to execute queries BUSY, // Currently executing a query // Suspended phase IDLE, // Open but unused, may be returned to pool // Terminal phase CLOSED, // Permanently closed, cannot be reused FAILED // Connection failed, cannot be reused } private ConnectionState state = ConnectionState.INITIALIZING; private final String connectionString; private Socket socket; public DatabaseConnection(String connectionString) { this.connectionString = connectionString; // Object exists but is not yet connected } // Creation → Active transition public void connect() throws ConnectionException { if (state != ConnectionState.INITIALIZING) { throw new IllegalStateException("Can only connect from INITIALIZING state"); } try { this.socket = establishConnection(connectionString); this.state = ConnectionState.OPEN; } catch (Exception e) { this.state = ConnectionState.FAILED; // Terminal state on failure throw new ConnectionException("Failed to connect", e); } } // Active → Active transition public ResultSet executeQuery(String sql) { if (state != ConnectionState.OPEN && state != ConnectionState.IDLE) { throw new IllegalStateException("Cannot execute query in " + state + " state"); } this.state = ConnectionState.BUSY; try { ResultSet result = doExecute(sql); this.state = ConnectionState.OPEN; return result; } catch (Exception e) { // Decide: return to OPEN, go to FAILED, or stay BUSY? this.state = ConnectionState.OPEN; // Recoverable error throw e; } } // Active → Terminal transition public void close() { if (state == ConnectionState.CLOSED) { return; // Idempotent: already closed } if (socket != null) { socket.close(); } this.state = ConnectionState.CLOSED; // Object is now in terminal state; no further transitions possible } public boolean isTerminal() { return state == ConnectionState.CLOSED || state == ConnectionState.FAILED; }}Once an object reaches a terminal state, it should not transition to any other state. A CLOSED connection cannot become OPEN again. Attempting to use an object in a terminal state should throw an exception. Design terminal states explicitly and enforce their finality.
A Finite State Machine (FSM) is a formal model for objects with discrete states and well-defined transitions. State machines are powerful tools for designing complex objects because they make all possible states and transitions explicit and verifiable.
Components of a State Machine:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
public class OrderStateMachine { public enum State { PENDING, CONFIRMED, PROCESSING, SHIPPED, DELIVERED, CANCELLED } public enum Event { CONFIRM, START_PROCESSING, SHIP, DELIVER, CANCEL } // Transition table: (current state, event) → next state private static final Map<State, Map<Event, State>> TRANSITIONS = Map.of( State.PENDING, Map.of( Event.CONFIRM, State.CONFIRMED, Event.CANCEL, State.CANCELLED ), State.CONFIRMED, Map.of( Event.START_PROCESSING, State.PROCESSING, Event.CANCEL, State.CANCELLED ), State.PROCESSING, Map.of( Event.SHIP, State.SHIPPED // Note: Cannot cancel once processing starts ), State.SHIPPED, Map.of( Event.DELIVER, State.DELIVERED ) // DELIVERED and CANCELLED are terminal states: no transitions out ); private State currentState; public OrderStateMachine() { this.currentState = State.PENDING; // Initial state } public void fire(Event event) { // Look up valid transitions from current state Map<Event, State> validTransitions = TRANSITIONS.get(currentState); if (validTransitions == null || !validTransitions.containsKey(event)) { throw new IllegalStateException(String.format( "Invalid transition: cannot apply %s in state %s. " + "Valid events: %s", event, currentState, validTransitions != null ? validTransitions.keySet() : "none" )); } State previousState = currentState; currentState = validTransitions.get(event); // Notify listeners, execute actions, etc. onTransition(previousState, event, currentState); } public boolean canFire(Event event) { Map<Event, State> validTransitions = TRANSITIONS.get(currentState); return validTransitions != null && validTransitions.containsKey(event); } public Set<Event> getValidEvents() { Map<Event, State> validTransitions = TRANSITIONS.get(currentState); return validTransitions != null ? validTransitions.keySet() : Set.of(); } private void onTransition(State from, Event event, State to) { log.info("Order: {} --[{}]--> {}", from, event, to); }}canFire() and getValidEvents() directly inform UI about available actions.Use state machines when an object has discrete states with restricted transitions (orders, workflows, connections). Don't use them for objects with continuous or combinatorial state (a Point with x,y coordinates, or a Document with arbitrary text content). State machines shine for lifecycle management.
Real-world state transitions are rarely as simple as "go from A to B." They often have conditions that must be met and side effects that must occur. These are modeled as guards and actions.
Guards are boolean conditions that must be true for a transition to be allowed, beyond just being in the right state.
Actions are operations executed during the transition—before, during, or after the state change.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
public class PaymentProcessor { private PaymentState state = PaymentState.PENDING; private final BigDecimal amount; private final Account sourceAccount; private final Account targetAccount; private String transactionId; /** * Transition: PENDING → COMPLETED * * Guards: * - Source account has sufficient balance * - Source account is not frozen * - Target account is active * * Actions: * - Debit source account * - Credit target account * - Generate transaction ID * - Send notification * - Log audit trail */ public void complete() { // Validate current state if (state != PaymentState.PENDING) { throw new IllegalStateException("Can only complete PENDING payments"); } // === GUARD CONDITIONS === if (sourceAccount.getBalance().compareTo(amount) < 0) { throw new InsufficientFundsException( "Source account balance " + sourceAccount.getBalance() + " is less than payment amount " + amount ); } if (sourceAccount.isFrozen()) { throw new AccountFrozenException("Source account is frozen"); } if (!targetAccount.isActive()) { throw new InvalidAccountException("Target account is not active"); } // All guards passed; execute transition // === ENTRY ACTIONS (before state change) === this.transactionId = generateTransactionId(); log.info("Starting payment {}: {} from {} to {}", transactionId, amount, sourceAccount.getId(), targetAccount.getId()); // === TRANSITION ACTIONS (during state change) === try { sourceAccount.debit(amount); targetAccount.credit(amount); // === STATE CHANGE === this.state = PaymentState.COMPLETED; } catch (Exception e) { // Transition failed; move to error state this.state = PaymentState.FAILED; log.error("Payment {} failed: {}", transactionId, e.getMessage()); throw e; } // === EXIT ACTIONS (after state change) === notificationService.sendPaymentConfirmation(this); auditLog.recordPayment(transactionId, sourceAccount, targetAccount, amount); log.info("Payment {} completed successfully", transactionId); } // Guards can be checked without triggering transition public boolean canComplete() { return state == PaymentState.PENDING && sourceAccount.getBalance().compareTo(amount) >= 0 && !sourceAccount.isFrozen() && targetAccount.isActive(); } // Get detailed reasons why completion is blocked public List<String> getCompletionBlockers() { List<String> blockers = new ArrayList<>(); if (state != PaymentState.PENDING) { blockers.add("Payment is not in PENDING state"); } if (sourceAccount.getBalance().compareTo(amount) < 0) { blockers.add("Insufficient funds"); } if (sourceAccount.isFrozen()) { blockers.add("Source account is frozen"); } if (!targetAccount.isActive()) { blockers.add("Target account is inactive"); } return blockers; }}Actions should be atomic with the state change. If you change state but an action fails, you have inconsistency. Use transactions, compensation logic, or the Saga pattern for complex multi-step actions. Never leave objects in partially-transitioned states.
Not all lifecycles are simple linear progressions. Real-world objects often have:
Let's examine techniques for modeling these complexities:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
/** * Complex document lifecycle with branching, loops, and hierarchy. * * Lifecycle Diagram: * * ┌──────────┐ save ┌──────────┐ submit ┌───────────┐ * │ DRAFT │ ─────────► │ SAVED │ ─────────► │ REVIEW │ * └──────────┘ └──────────┘ └───────────┘ * │ ▲ │ │ * │ discard │ revise approve reject * ▼ │ ▼ │ * ┌──────────┐ │ ┌───────────┐ │ * │ DISCARDED│ └──────────────│ APPROVED │ │ * └──────────┘ └───────────┘ │ * │ │ * publish │ ▼ * ┌───────────┐ │ ┌──────────┐ * │ PUBLISHED │◄──┘ │ REJECTED │ * └───────────┘ └──────────┘ * │ │ * archive revise * ▼ │ * ┌───────────┐ │ * │ ARCHIVED │◄───────────┘ * └───────────┘ */public class Document { public enum State { DRAFT, // Initial state SAVED, // Changes saved, ready for submission REVIEW, // Under editorial review APPROVED, // Approved by reviewer REJECTED, // Rejected by reviewer (can revise or archive) PUBLISHED, // Live and public ARCHIVED, // No longer active DISCARDED // Deleted before saving } private State state = State.DRAFT; private List<StateTransition> history = new ArrayList<>(); // Valid transitions mapped explicitly private static final Map<State, Set<State>> VALID_TRANSITIONS = Map.of( State.DRAFT, Set.of(State.SAVED, State.DISCARDED), State.SAVED, Set.of(State.DRAFT, State.REVIEW), // Can go back to DRAFT State.REVIEW, Set.of(State.APPROVED, State.REJECTED), State.APPROVED, Set.of(State.PUBLISHED, State.REVIEW), // Can revise approval State.REJECTED, Set.of(State.SAVED, State.ARCHIVED), // Revise or give up State.PUBLISHED, Set.of(State.ARCHIVED), State.ARCHIVED, Set.of(), // Terminal State.DISCARDED, Set.of() // Terminal ); private void transitionTo(State newState, String reason) { if (!VALID_TRANSITIONS.get(state).contains(newState)) { throw new IllegalStateException(String.format( "Cannot transition from %s to %s. Valid targets: %s", state, newState, VALID_TRANSITIONS.get(state) )); } history.add(new StateTransition(state, newState, Instant.now(), reason)); state = newState; } // Business methods that trigger transitions public void save() { transitionTo(State.SAVED, "Document saved"); } public void submit() { transitionTo(State.REVIEW, "Submitted for review"); } public void approve() { transitionTo(State.APPROVED, "Approved by reviewer"); } public void reject(String reason) { transitionTo(State.REJECTED, reason); } public void publish() { transitionTo(State.PUBLISHED, "Published to public"); } public void archive() { transitionTo(State.ARCHIVED, "Archived"); } public void discard() { transitionTo(State.DISCARDED, "Discarded by user"); } public void revise() { transitionTo(State.SAVED, "Returned for revision"); } // Query lifecycle position public boolean isEditable() { return state == State.DRAFT || state == State.SAVED; } public boolean isTerminal() { return state == State.ARCHIVED || state == State.DISCARDED; } public boolean isPublic() { return state == State.PUBLISHED; } // Audit trail public List<StateTransition> getHistory() { return List.copyOf(history); }}Draw the state diagram before implementing. State diagrams expose impossible transitions, missing states, and dead ends that are hard to spot in code. Tools like PlantUML, Mermaid, or even whiteboard sketches make complex lifecycles visible and discussable.
Sometimes you need more than just the current state—you need the complete history of how the object got there. Event sourcing is a pattern where you store all state transitions (events) rather than just the current state. The current state is derived by replaying all events.
This is particularly powerful for:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
public class EventSourcedOrder { // Events (immutable records of what happened) public sealed interface OrderEvent { Instant timestamp(); String actor(); // Who triggered this event } public record OrderCreated(Instant timestamp, String actor, String orderId, List<OrderItem> items) implements OrderEvent {} public record OrderConfirmed(Instant timestamp, String actor) implements OrderEvent {} public record OrderShipped(Instant timestamp, String actor, String trackingNumber, String carrier) implements OrderEvent {} public record OrderDelivered(Instant timestamp, String actor, String signedBy) implements OrderEvent {} public record OrderCancelled(Instant timestamp, String actor, String reason) implements OrderEvent {} // The event log: all transitions ever made private final List<OrderEvent> events = new ArrayList<>(); // Current state (recomputed from events) private String orderId; private List<OrderItem> items; private OrderStatus status; private String trackingNumber; private String signedBy; // Reconstruct state from events public static EventSourcedOrder fromEvents(List<OrderEvent> events) { EventSourcedOrder order = new EventSourcedOrder(); for (OrderEvent event : events) { order.apply(event); } return order; } // Apply an event to update state private void apply(OrderEvent event) { events.add(event); switch (event) { case OrderCreated e -> { this.orderId = e.orderId(); this.items = new ArrayList<>(e.items()); this.status = OrderStatus.PENDING; } case OrderConfirmed e -> { this.status = OrderStatus.CONFIRMED; } case OrderShipped e -> { this.status = OrderStatus.SHIPPED; this.trackingNumber = e.trackingNumber(); } case OrderDelivered e -> { this.status = OrderStatus.DELIVERED; this.signedBy = e.signedBy(); } case OrderCancelled e -> { this.status = OrderStatus.CANCELLED; } } } // Business methods create and apply events public void confirm(String actor) { if (status != OrderStatus.PENDING) { throw new IllegalStateException("Cannot confirm: not pending"); } apply(new OrderConfirmed(Instant.now(), actor)); } public void ship(String actor, String trackingNumber, String carrier) { if (status != OrderStatus.CONFIRMED) { throw new IllegalStateException("Cannot ship: not confirmed"); } apply(new OrderShipped(Instant.now(), actor, trackingNumber, carrier)); } // Time-travel query: what was the status at a given time? public OrderStatus getStatusAt(Instant pointInTime) { EventSourcedOrder snapshot = new EventSourcedOrder(); for (OrderEvent event : events) { if (event.timestamp().isAfter(pointInTime)) { break; // Stop replaying at the requested time } snapshot.apply(event); } return snapshot.status; } // Complete audit trail public List<OrderEvent> getAuditTrail() { return List.copyOf(events); }}Event sourcing provides powerful capabilities but adds complexity. Event storage grows unboundedly (snapshots help). Replaying many events is slow (indexing helps). Schema evolution is tricky (event versioning helps). Use event sourcing when audit trails, temporal queries, or undo are requirements—not as a default.
State transitions require systematic testing. You need to verify that:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
class OrderStateTransitionTest { // Test valid transitions @ParameterizedTest @CsvSource({ "PENDING, CONFIRMED", "CONFIRMED, PROCESSING", "PROCESSING, SHIPPED", "SHIPPED, DELIVERED" }) void shouldTransitionThroughHappyPath(String from, String to) { Order order = createOrderInState(OrderStatus.valueOf(from)); triggerNextTransition(order); assertEquals(OrderStatus.valueOf(to), order.getStatus()); } // Test invalid transitions @ParameterizedTest @CsvSource({ "PENDING, ship", "PENDING, deliver", "SHIPPED, confirm", "DELIVERED, ship", "CANCELLED, confirm" }) void shouldRejectInvalidTransitions(String state, String action) { Order order = createOrderInState(OrderStatus.valueOf(state)); IllegalStateException exception = assertThrows( IllegalStateException.class, () -> invokeAction(order, action) ); assertThat(exception.getMessage()) .contains("Cannot") .contains(state); // State should be unchanged after failed transition assertEquals(OrderStatus.valueOf(state), order.getStatus()); } // Test guard conditions @Test void shouldRejectShipmentWhenInventoryUnavailable() { Order order = createOrderInState(OrderStatus.CONFIRMED); inventoryService.setAvailable(false); // Guard will fail assertThrows(InventoryException.class, () -> order.ship()); assertEquals(OrderStatus.CONFIRMED, order.getStatus()); // Unchanged } // Test transition actions @Test void shouldSendNotificationOnConfirmation() { Order order = createOrderInState(OrderStatus.PENDING); order.confirm(); verify(notificationService).sendOrderConfirmation(order); verify(auditLog).recordTransition( eq(order.getId()), eq(OrderStatus.PENDING), eq(OrderStatus.CONFIRMED), any(Instant.class) ); } // Test terminal state behavior @Test void shouldRejectAllOperationsInTerminalState() { Order order = createOrderInState(OrderStatus.CANCELLED); assertThrows(IllegalStateException.class, () -> order.confirm()); assertThrows(IllegalStateException.class, () -> order.ship()); assertThrows(IllegalStateException.class, () -> order.cancel()); } // Test complete lifecycle (integration test) @Test void shouldCompleteFullOrderLifecycle() { Order order = new Order(customer, items); assertEquals(OrderStatus.PENDING, order.getStatus()); order.confirm(); assertEquals(OrderStatus.CONFIRMED, order.getStatus()); order.startProcessing(); assertEquals(OrderStatus.PROCESSING, order.getStatus()); order.ship("TRACK123"); assertEquals(OrderStatus.SHIPPED, order.getStatus()); assertEquals("TRACK123", order.getTrackingNumber()); order.deliver(); assertEquals(OrderStatus.DELIVERED, order.getStatus()); assertTrue(order.isTerminal()); }}For safety-critical systems, use the transition table to generate tests programmatically. If you have N states and M events, you should have N × M test cases: one for each (state, event) pair, verifying either successful transition or proper rejection. This ensures complete transition coverage.
State transitions transform static data models into living systems. Let's consolidate the key insights:
What's Next:
Now that we understand how state changes, we'll explore the relationship between state and behavior. The next page examines how methods manipulate state, how behavior should be designed around state transitions, and the principle that behavior is the interface through which state is transformed.
You now understand state transitions and object lifecycles. You can model transitions explicitly, design state machines, enforce guards, execute actions, and test transition behavior. Next, we'll explore how behavior and state interrelate through state manipulation.