Definition
- State pattern – lets an object alter its behaviour when its internal state changes, by delegating to separate state classes.
- Context – holds a reference to the current state object and forwards requests to it.
- State interface – declares the operations that vary by state.
- Concrete states – each implements the operations and decides which state to transition to next.
- Eliminates conditionals – replaces sprawling if-else or switch on a status field with clean, cohesive state classes.
Think of a loan application at National Bank of India moving through stages. When Rajesh's loan is "under review" it can be approved or rejected. Once "approved" it can be disbursed but not re-reviewed. Each stage knows exactly what actions are allowed, so the application never does something nonsensical for its current stage.
Understanding the Concepts
What is the State pattern?
The State pattern lets an object change its behaviour when its internal state changes. The context holds a reference to a state interface and delegates behaviour to it. Each concrete state implements the actions and decides the next state.
package com.bank.state;
import java.math.BigDecimal;
public interface LoanState {
void approve(LoanContext ctx);
void disburse(LoanContext ctx);
void reject(LoanContext ctx);
void close(LoanContext ctx);
String name();
}
How do you implement the context?
The context holds the current state and delegates all behaviour to it.
package com.bank.state;
import java.math.BigDecimal;
public class LoanContext {
private LoanState state;
private final String loanId;
private final BigDecimal amount;
public LoanContext(String loanId, BigDecimal amount) {
this.loanId = loanId;
this.amount = amount;
this.state = new AppliedState();
}
public void setState(LoanState state) {
System.out.println("Loan " + loanId + ": "
+ this.state.name() + " → " + state.name());
this.state = state;
}
public void approve() { state.approve(this); }
public void disburse() { state.disburse(this); }
public void reject() { state.reject(this); }
public void close() { state.close(this); }
public BigDecimal getAmount() { return amount; }
public String getLoanId() { return loanId; }
public String getStateName() { return state.name(); }
}
How do you implement concrete states?
Each state implements only the actions valid for that state and throws for invalid ones.
package com.bank.state;
public class AppliedState implements LoanState {
@Override
public void approve(LoanContext ctx) {
ctx.setState(new ApprovedState());
}
@Override
public void reject(LoanContext ctx) {
ctx.setState(new RejectedState());
}
@Override
public void disburse(LoanContext ctx) {
throw new IllegalStateException(
"Cannot disburse a loan that is still in APPLIED state");
}
@Override
public void close(LoanContext ctx) {
throw new IllegalStateException(
"Cannot close a loan that is in APPLIED state");
}
@Override
public String name() { return "APPLIED"; }
}
public class ApprovedState implements LoanState {
@Override
public void approve(LoanContext ctx) {
throw new IllegalStateException("Loan is already approved");
}
@Override
public void disburse(LoanContext ctx) {
System.out.println("Disbursing INR " + ctx.getAmount()
+ " for loan " + ctx.getLoanId());
ctx.setState(new DisbursedState());
}
@Override
public void reject(LoanContext ctx) {
throw new IllegalStateException(
"Cannot reject a loan that is already approved");
}
@Override
public void close(LoanContext ctx) {
throw new IllegalStateException(
"Cannot close a loan that has not been disbursed");
}
@Override
public String name() { return "APPROVED"; }
}
public class DisbursedState implements LoanState {
@Override
public void approve(LoanContext ctx) {
throw new IllegalStateException("Already disbursed");
}
@Override
public void disburse(LoanContext ctx) {
throw new IllegalStateException("Already disbursed");
}
@Override
public void reject(LoanContext ctx) {
throw new IllegalStateException("Cannot reject a disbursed loan");
}
@Override
public void close(LoanContext ctx) {
System.out.println("Closing loan " + ctx.getLoanId());
ctx.setState(new ClosedState());
}
@Override
public String name() { return "DISBURSED"; }
}
public class RejectedState implements LoanState {
@Override
public void approve(LoanContext ctx) {
throw new IllegalStateException("Rejected loan cannot be approved");
}
@Override
public void disburse(LoanContext ctx) {
throw new IllegalStateException("Rejected loan cannot be disbursed");
}
@Override
public void reject(LoanContext ctx) {
throw new IllegalStateException("Already rejected");
}
@Override
public void close(LoanContext ctx) {
throw new IllegalStateException("Rejected loan cannot be closed");
}
@Override
public String name() { return "REJECTED"; }
}
public class ClosedState implements LoanState {
@Override
public void approve(LoanContext ctx) {
throw new IllegalStateException("Closed loan");
}
@Override
public void disburse(LoanContext ctx) {
throw new IllegalStateException("Closed loan");
}
@Override
public void reject(LoanContext ctx) {
throw new IllegalStateException("Closed loan");
}
@Override
public void close(LoanContext ctx) {
throw new IllegalStateException("Already closed");
}
@Override
public String name() { return "CLOSED"; }
}
How do you use the State pattern?
The client calls high-level methods on the context. The behaviour depends entirely on the current state object.
package com.bank.demo;
import com.bank.state.LoanContext;
import java.math.BigDecimal;
public class StateDemo {
public static void main(String[] args) {
LoanContext loan = new LoanContext("LOAN-001",
new BigDecimal("500000"));
System.out.println("Current state: " + loan.getStateName());
loan.approve();
loan.disburse();
loan.close();
System.out.println("Final state: " + loan.getStateName());
try {
loan.approve(); // illegal on a closed loan
} catch (IllegalStateException e) {
System.out.println("Caught: " + e.getMessage());
}
}
}
Current state: APPLIED Loan LOAN-001: APPLIED → APPROVED Disbursing INR 500000 for loan LOAN-001 Loan LOAN-001: APPROVED → DISBURSED Closing loan LOAN-001 Loan LOAN-001: DISBURSED → CLOSED Final state: CLOSED Caught: Closed loan
How is the State pattern different from a plain enum?
With a plain enum, behaviour lives in a central switch. With the State pattern, behaviour lives with the state.
| Aspect | Plain Enum + Switch | State Pattern |
|---|---|---|
| Behaviour location | Central switch statement | Each state class |
| Adding a state | Edit every switch | Add a new class |
| Illegal transitions | Manual guard in each switch | Impossible by construction |
| Testability | Test the whole switch | Test each state independently |
| Complexity | Low for simple lifecycles | Higher but cleaner for rich workflows |
// Enum + switch: behaviour in a central place
switch (status) {
case APPLIED -> { /* logic */ }
case APPROVED -> { /* logic */ }
// Must edit every switch for a new state
}
// State pattern: behaviour with each state
loan.approve(); // delegates to current state's approve()
When should you use the State pattern?
Use it when an object has many states with genuinely different behaviour, when you keep seeing switch statements on a status field, or when adding a new state should not require editing existing code.
Do not use it for a trivial three-state lifecycle where a guarded enum is clearer and simpler.
What are common mistakes?
The first mistake is putting transition logic in the context instead of the state. This recreates the switch problem.
// Wrong: context decides transitions (defeats the pattern)
public void approve() {
if (state instanceof AppliedState) { state = new ApprovedState(); }
// This is just a switch in disguise
}
// Correct: state decides transitions
public void approve() { state.approve(this); }
// AppliedState.approve() calls ctx.setState(new ApprovedState())
The second mistake is forgetting to make invalid actions throw. If a state silently ignores an invalid action, bugs hide.
What should a senior engineer know?
The design judgment is where transition logic lives. Letting each state choose its successor keeps transitions local and readable, which Pankaj prefers for simple workflows. For complex, heavily audited workflows, he sometimes centralises the transition table so the whole lifecycle is visible at a glance. Both are valid.
At National Bank of India, Pankaj models loan applications, order status, and payment lifecycles using the State pattern, because a regulated money workflow must never perform an action that is invalid for its current stage, and the State pattern guarantees that by construction.
// Adding a new state: ON_HOLD
public class OnHoldState implements LoanState {
@Override
public void approve(LoanContext ctx) {
ctx.setState(new ApprovedState()); // resume
}
// Other actions throw IllegalStateException
@Override
public String name() { return "ON_HOLD"; }
}
// No changes to existing states
Visual Overview
Loan State Machine
──────────────────
APPLIED ──approve()──→ APPROVED ──disburse()──→ DISBURSED ──close()──→ CLOSED
│ │
└──reject()──→ REJECTED │
│
Each arrow is a valid transition.
All other actions throw IllegalStateException.
State Pattern Structure
───────────────────────
LoanContext
│
│ delegates to current state
▼
<<interface>> LoanState
▲ ▲ ▲ ▲
│ │ │ │
AppliedState ApprovedState DisbursedState ClosedState
(can approve (can disburse) (can close) (terminal)
or reject)
Key Takeaway
The State pattern lets an object change its behaviour when its internal state changes, by giving each state its own class and having the context delegate to the current state object. This is the clean alternative to a status field checked by sprawling conditionals, which is exactly the fragile code that produces illegal transitions in real systems.
The structure is a context holding a reference to a state interface, and one concrete class per state. Each state implements the actions that make sense for it and decides the next state. Illegal transitions become impossible by construction: a state simply does not offer an action that does not apply, so a loan in APPLIED state cannot be disbursed because the applied-state class refuses that request.
Compared to a plain enum with a switch, the key difference is that behaviour lives with the state. Adding a new state like ON_HOLD is a new class with no edits to existing states, satisfying Open/Closed. Each state is cohesive and independently testable.
The design judgment is where transition logic lives. Letting each state choose its successor keeps transitions local and readable. For complex, heavily audited workflows, centralising the transition table makes the whole lifecycle visible at a glance. At National Bank of India, Pankaj models loan applications, order status, and payment lifecycles using the State pattern, because a regulated money workflow must never perform an action that is invalid for its current stage.
Remember one sentence: the State pattern gives each state its own class, making illegal transitions impossible by construction and replacing status-checking conditionals with clean delegation.
---