State Design Pattern in Java: Complete Guide with a Real-World Example
Have you ever worked on a Java application where the same method behaves differently depending on an order's status, a payment's status, or a user's account status? If so, you have already encountered the kind of problem that the State Design Pattern is designed to solve.
Imagine an online order. Before payment, a customer can cancel it. After shipment, cancellation may follow a different process. Once the order has been delivered, the application might allow a return request instead of cancellation. As more rules are added, a single service filled with if-else and switch statements can become difficult to understand and test.
In this guide, we will build an order management example using plain Java first, then look at how the same idea fits into a Spring Boot application.
1. What Is the State Design Pattern in Java?
The State Design Pattern is a behavioral design pattern that allows an object to change its behavior when its internal state changes. Instead of placing every state-dependent rule inside one class, we move that behavior into separate classes that implement a common state interface.
The main idea is simple: the object delegates an operation to its current state, and that state decides what the operation means or whether it is allowed.
For example, an order in the CREATED state can be paid for. An order in the SHIPPED state cannot be paid for again through the same operation. The behavior depends on the current state.
In simple words
The State pattern puts state-specific behavior in separate classes instead of keeping every rule in a large conditional block.
It is useful when an object's lifecycle has several states and the rules for its operations change as it moves from one state to another.
2. Real-World Example: E-Commerce Order Management
Consider a shopping application where customers place orders and track their delivery. An order moves through a defined lifecycle.
- CREATED: The order exists, but payment has not been completed.
- PAID: Payment has been confirmed.
- SHIPPED: The package has been handed over for delivery.
- DELIVERED: The package has reached the customer.
- CANCELLED: The order has been cancelled before shipment in this simplified example.
These states are not just labels. They affect which operations the application should allow.
| Current State | Operation | Expected Result |
|---|---|---|
| CREATED | Pay | Move to PAID |
| CREATED | Cancel | Move to CANCELLED |
| PAID | Ship | Move to SHIPPED |
| PAID | Cancel | Cancel and begin the applicable refund workflow |
| SHIPPED | Deliver | Move to DELIVERED |
| DELIVERED | Pay again | Reject the operation |
| CANCELLED | Ship | Reject the operation |
This is a simplified business workflow, not a universal e-commerce rule. A real application may introduce states such as PAYMENT_PENDING, REFUND_PENDING, RETURN_REQUESTED, or DELIVERY_FAILED. The permitted transitions should always reflect the actual business requirements.
3. The Problem Without the State Pattern
A first implementation might store the order status in an enum and check it inside every operation.
enum OrderStatus {
CREATED,
PAID,
SHIPPED,
DELIVERED,
CANCELLED
}
class OrderService {
public void pay(OrderStatus status) {
if (status == OrderStatus.CREATED) {
System.out.println("Payment completed");
} else if (status == OrderStatus.PAID) {
System.out.println("Order is already paid");
} else if (status == OrderStatus.SHIPPED) {
System.out.println("Order has already shipped");
} else {
System.out.println("Payment is not allowed");
}
}
public void cancel(OrderStatus status) {
if (status == OrderStatus.CREATED) {
System.out.println("Order cancelled");
} else if (status == OrderStatus.PAID) {
System.out.println("Cancel and process refund");
} else {
System.out.println("Cancellation is not allowed");
}
}
}
This is valid Java, and for a small application it may be enough. The problem appears when many operations depend on the same lifecycle: payment, cancellation, shipment, delivery, refunds, returns, and notifications.
Each operation can develop its own version of the state rules. That makes it easier for two methods to disagree about what is allowed. Adding a new state can also require changing many existing methods.
What do we want to improve?
- Keep each state's behavior close to the state itself.
- Make invalid transitions explicit.
- Make the order class delegate rather than own every rule.
- Make state-specific behavior easier to test.
That is where the State pattern becomes useful.
4. How the State Design Pattern Works
The pattern usually contains three main parts.
1. State interface
Defines the operations that every concrete state must support. In our example, these are payment, shipment, delivery, and cancellation.
2. Concrete state classes
Each class implements the behavior appropriate for one state. For example, CreatedState allows payment and cancellation, while DeliveredState rejects both payment and shipment.
3. Context class
The context is the object whose behavior depends on its current state. Here, Order stores the current state and delegates operations to it.
A state object can ask the context to move to another state when a valid transition occurs. The context remains the same order object; the object it delegates to changes.
5. State Design Pattern Diagram
The following original SVG illustrates the order lifecycle. It is created specifically for this article and does not rely on an external image.
Notice that the arrows describe business rules. A customer cannot simply jump from CREATED to DELIVERED by calling the delivery operation. The current state decides whether that action is valid.
6. Complete Java Implementation of the State Pattern
Let's implement the order workflow using Java 17. To make the example easy to run, all classes are nested in one file. In a production project, you can move the state interface and concrete states into separate files.
Step 1: Define the OrderState interface
The interface declares the operations supported by the order. Every state provides its own implementation.
interface OrderState {
void pay(Order order);
void ship(Order order);
void deliver(Order order);
void cancel(Order order);
String name();
}
Step 2: Implement the CreatedState
An order in this state can be paid or cancelled. Shipping and delivery are not allowed yet.
final class CreatedState implements OrderState {
@Override
public void pay(Order order) {
System.out.println("Payment successful.");
order.changeState(new PaidState());
}
@Override
public void ship(Order order) {
System.out.println("Cannot ship before payment.");
}
@Override
public void deliver(Order order) {
System.out.println("Cannot deliver an unpaid order.");
}
@Override
public void cancel(Order order) {
System.out.println("Order cancelled before payment.");
order.changeState(new CancelledState());
}
@Override
public String name() {
return "CREATED";
}
}
Step 3: Implement the PaidState
Once payment has been confirmed, the order can be shipped. Cancellation is still possible in this simplified model, but the application must start a refund workflow rather than assume the money has already been returned.
final class PaidState implements OrderState {
@Override
public void pay(Order order) {
System.out.println("Order is already paid.");
}
@Override
public void ship(Order order) {
System.out.println("Order shipped.");
order.changeState(new ShippedState());
}
@Override
public void deliver(Order order) {
System.out.println("Cannot deliver before shipment.");
}
@Override
public void cancel(Order order) {
System.out.println(
"Order cancellation accepted; refund processing is required."
);
order.changeState(new CancelledState());
}
@Override
public String name() {
return "PAID";
}
}
Step 4: Implement the ShippedState
After shipment, the delivery operation can move the order into its final delivered state. Cancellation is rejected here because this example does not model courier interception or return workflows.
final class ShippedState implements OrderState {
@Override
public void pay(Order order) {
System.out.println("Order is already paid.");
}
@Override
public void ship(Order order) {
System.out.println("Order has already shipped.");
}
@Override
public void deliver(Order order) {
System.out.println("Order delivered successfully.");
order.changeState(new DeliveredState());
}
@Override
public void cancel(Order order) {
System.out.println(
"Cancellation is not allowed after shipment."
);
}
@Override
public String name() {
return "SHIPPED";
}
}
Step 5: Implement the DeliveredState
The delivered order cannot be paid for, shipped, delivered again, or cancelled through these operations. A real application could expose a separate return or refund workflow.
final class DeliveredState implements OrderState {
@Override
public void pay(Order order) {
System.out.println("Delivered order cannot be paid for again.");
}
@Override
public void ship(Order order) {
System.out.println("Order has already been delivered.");
}
@Override
public void deliver(Order order) {
System.out.println("Order has already been delivered.");
}
@Override
public void cancel(Order order) {
System.out.println(
"Delivered order cannot be cancelled through this operation."
);
}
@Override
public String name() {
return "DELIVERED";
}
}
Step 6: Implement the CancelledState
Cancellation is terminal in this simplified example. The application rejects further lifecycle operations.
final class CancelledState implements OrderState {
@Override
public void pay(Order order) {
System.out.println("Cancelled order cannot be paid for.");
}
@Override
public void ship(Order order) {
System.out.println("Cancelled order cannot be shipped.");
}
@Override
public void deliver(Order order) {
System.out.println("Cancelled order cannot be delivered.");
}
@Override
public void cancel(Order order) {
System.out.println("Order is already cancelled.");
}
@Override
public String name() {
return "CANCELLED";
}
}
Step 7: Create the Order context
The Order class is the context. It stores the current state and forwards operations to it. The state field is private, so callers cannot directly replace it.
final class Order {
private OrderState state = new CreatedState();
public void pay() {
state.pay(this);
}
public void ship() {
state.ship(this);
}
public void deliver() {
state.deliver(this);
}
public void cancel() {
state.cancel(this);
}
void changeState(OrderState newState) {
this.state = newState;
System.out.println("Current state: " + state.name());
}
public String getState() {
return state.name();
}
}
Step 8: Run the complete example
Save the following code as StatePatternDemo.java. This single-file version includes the interface, all five state classes, the context, and the main method.
public class StatePatternDemo {
interface OrderState {
void pay(Order order);
void ship(Order order);
void deliver(Order order);
void cancel(Order order);
String name();
}
static final class Order {
private OrderState state = new CreatedState();
public void pay() {
state.pay(this);
}
public void ship() {
state.ship(this);
}
public void deliver() {
state.deliver(this);
}
public void cancel() {
state.cancel(this);
}
void changeState(OrderState newState) {
state = newState;
System.out.println("Current state: " + state.name());
}
public String getState() {
return state.name();
}
}
static final class CreatedState implements OrderState {
public void pay(Order order) {
System.out.println("Payment successful.");
order.changeState(new PaidState());
}
public void ship(Order order) {
System.out.println("Cannot ship before payment.");
}
public void deliver(Order order) {
System.out.println("Cannot deliver an unpaid order.");
}
public void cancel(Order order) {
System.out.println("Order cancelled before payment.");
order.changeState(new CancelledState());
}
public String name() {
return "CREATED";
}
}
static final class PaidState implements OrderState {
public void pay(Order order) {
System.out.println("Order is already paid.");
}
public void ship(Order order) {
System.out.println("Order shipped.");
order.changeState(new ShippedState());
}
public void deliver(Order order) {
System.out.println("Cannot deliver before shipment.");
}
public void cancel(Order order) {
System.out.println(
"Cancellation accepted; refund processing is required."
);
order.changeState(new CancelledState());
}
public String name() {
return "PAID";
}
}
static final class ShippedState implements OrderState {
public void pay(Order order) {
System.out.println("Order is already paid.");
}
public void ship(Order order) {
System.out.println("Order has already shipped.");
}
public void deliver(Order order) {
System.out.println("Order delivered successfully.");
order.changeState(new DeliveredState());
}
public void cancel(Order order) {
System.out.println(
"Cancellation is not allowed after shipment."
);
}
public String name() {
return "SHIPPED";
}
}
static final class DeliveredState implements OrderState {
public void pay(Order order) {
System.out.println("Delivered order cannot be paid for again.");
}
public void ship(Order order) {
System.out.println("Order has already been delivered.");
}
public void deliver(Order order) {
System.out.println("Order has already been delivered.");
}
public void cancel(Order order) {
System.out.println(
"Use a separate return workflow after delivery."
);
}
public String name() {
return "DELIVERED";
}
}
static final class CancelledState implements OrderState {
public void pay(Order order) {
System.out.println("Cancelled order cannot be paid for.");
}
public void ship(Order order) {
System.out.println("Cancelled order cannot be shipped.");
}
public void deliver(Order order) {
System.out.println("Cancelled order cannot be delivered.");
}
public void cancel(Order order) {
System.out.println("Order is already cancelled.");
}
public String name() {
return "CANCELLED";
}
}
public static void main(String[] args) {
Order order = new Order();
System.out.println("Initial state: " + order.getState());
order.pay();
order.ship();
order.deliver();
System.out.println("Final state: " + order.getState());
order.pay(); // Rejected because the order is already delivered.
}
}
Important: The compact demo above is self-contained. If you use the separate snippets instead, place each top-level interface or class in its own appropriately named Java file, or nest them inside one enclosing class.
7. How to Run the Example
You need a Java Development Kit (JDK). The example works with Java 17 and later.
- Create a file named
StatePatternDemo.java. - Paste the complete single-file example into it.
- Open a terminal in that directory.
- Compile and run the program.
javac StatePatternDemo.java
java StatePatternDemo
Expected output
Initial state: CREATED
Payment successful.
Current state: PAID
Order shipped.
Current state: SHIPPED
Order delivered successfully.
Current state: DELIVERED
Final state: DELIVERED
Delivered order cannot be paid for again.
The final payment attempt is rejected because the current state is DELIVERED. The caller does not need to know every rule inside the state classes; it simply calls order.pay().
8. State Design Pattern with Spring Boot
In a Spring Boot application, the same approach can be used in order management, payment workflows, document approval, and onboarding processes. You can keep the state classes as Spring beans when they are stateless, and inject them into a context or workflow service.
For example, a state interface could look like this:
public interface PaymentState {
void process(PaymentContext context);
String name();
}
A concrete state implements the behavior for one stage:
import org.springframework.stereotype.Component;
@Component
public class PaymentPendingState implements PaymentState {
@Override
public void process(PaymentContext context) {
// Validate the request and initiate the payment workflow.
// Transition only after the relevant business result is confirmed.
}
@Override
public String name() {
return "PAYMENT_PENDING";
}
}
Here, PaymentContext represents the application's workflow context and must be implemented according to the project design. The sample above illustrates the integration point, not a complete payment implementation.
Important Spring bean consideration
Spring beans are commonly shared between requests. Keep concrete state beans stateless: do not store a customer's payment ID, order ID, or mutable workflow data in instance fields. Pass request-specific data through a context or method parameter.
For a small workflow, a plain Java implementation may be simpler. For a more complex workflow, consider a dedicated transition service, persisted state machine, or workflow engine when the business requirements justify the additional infrastructure.
9. Production Considerations: Especially for Payment Systems
The State pattern organizes business behavior. By itself, it does not make a payment workflow secure, durable, or thread-safe. Those guarantees must come from the surrounding application design.
Persist the current state
If the application restarts, the order must retain its current state. Persist the state in a database and reconstruct the appropriate behavior when loading the order.
Validate transitions on the server
Do not trust a client to submit a new status such as PAID or DELIVERED. The server must validate the requested operation and confirm the corresponding business event.
Handle concurrent requests
Two requests could attempt to cancel and ship the same order at the same time. Use an appropriate concurrency strategy, such as optimistic locking with a version field, database locking, or conditional updates. A mutable in-memory state reference alone does not prevent race conditions.
Make payment operations idempotent
Payment gateways can send duplicate callbacks or clients can retry requests. Use idempotency keys, unique transaction references, and persistent payment records to prevent the same business operation from being applied more than once.
Keep side effects reliable
Changing an order state and sending a notification are separate concerns. If the database update succeeds but the notification fails, the system should have a recovery strategy. A transactional outbox or durable messaging workflow can help when reliable event delivery is required.
Do not confuse cancellation with a completed refund
In a real payment application, cancellation may require states such as REFUND_PENDING, REFUNDED, and REFUND_FAILED. A refund request being accepted does not mean the funds have already been returned.
10. State Pattern vs Strategy Pattern
These two patterns can have a similar structure: both can use an interface with multiple implementations and delegate behavior. The difference is their intent.
| Aspect | State Pattern | Strategy Pattern |
|---|---|---|
| Purpose | Represent state-dependent behavior and lifecycle transitions. | Choose among interchangeable algorithms. |
| Typical example | Order lifecycle or payment workflow. | Payment fee calculation or shipping cost calculation. |
| What selects behavior? | The object's current state and permitted transitions. | The caller, configuration, or business selection. |
| Transition logic | Often part of the state or workflow logic. | Usually not the pattern's main responsibility. |
| Can both be useful together? | Yes. A state may use a strategy to perform a calculation. | Yes. A strategy may be selected while processing a particular state. |
Easy way to remember: State answers, "What is this object allowed to do right now?" Strategy answers, "Which algorithm should perform this task?"
11. Advantages and Disadvantages
Advantages
- Clear responsibilities: Each state class owns its state-specific behavior.
- Less conditional duplication: Rules do not need to be repeated across many service methods.
- Easier testing: You can test allowed and rejected operations for each state.
- Better extensibility: A new state can often be added without rewriting every existing operation.
- More visible business rules: Lifecycle transitions are easier to review.
Disadvantages
- More classes: A simple workflow can become unnecessarily verbose.
- Transition management: Complex transitions require careful design and documentation.
- Persistence complexity: Database state and in-memory behavior must remain consistent.
- Concurrency still matters: The pattern alone does not protect against simultaneous operations.
Do not introduce the State pattern just to remove every if-else statement. Use it when state-dependent behavior has enough complexity to justify separate classes.
12. Where Is the State Design Pattern Used in Real Projects?
- Payment systems: Created, pending, authorized, captured, failed, refunded.
- E-commerce: Created, paid, packed, shipped, delivered, cancelled.
- Document approval: Draft, submitted, approved, rejected, published.
- User onboarding: Registered, verification pending, verified, suspended.
- Support tickets: Open, assigned, in progress, resolved, closed.
- Media players: Playing, paused, stopped, buffering.
- Vending machines: Idle, accepting payment, dispensing, out of stock.
The common feature is not simply that an object has a status field. It is that the object's permitted actions or behavior change according to that status.
13. State Design Pattern Interview Questions and Answers
Q1. What is the State Design Pattern?
Answer: It is a behavioral pattern that allows an object to change its behavior when its internal state changes by delegating behavior to separate state objects.
Q2. Why not use a switch statement?
Answer: A switch can be perfectly appropriate for simple rules. When many operations depend on many states, separate state classes can reduce duplicated conditions and isolate changes.
Q3. What are the main components?
Answer: A State interface, concrete state implementations, and a context that holds and delegates to its current state.
Q4. What is the difference between State and Strategy?
Answer: State focuses on lifecycle-dependent behavior and transitions. Strategy focuses on selecting an interchangeable algorithm.
Q5. Can State objects be singletons?
Answer: Yes, if they are stateless and safely shared. Do not use shared singleton state objects to hold mutable information belonging to individual orders or payments.
Q6. Does the State pattern replace a database status column?
Answer: No. The pattern organizes behavior; persistence is a separate concern. Many applications store the current state in a database and use state objects to implement its behavior.
Q7. Is the State pattern thread-safe?
Answer: No, not automatically. Shared mutable context objects and concurrent transitions need suitable synchronization, transaction boundaries, or database concurrency controls.
Q8. How would you test a State implementation?
Answer: Test valid transitions, invalid transitions, repeated operations, terminal states, and failure scenarios. In a persisted workflow, also test concurrent updates and recovery behavior.
Q9. Is an enum enough to represent states?
Answer: An enum is useful for naming and persisting states. The State pattern adds separate behavior implementations when each state has meaningful, different rules.
Q10. How would you use the pattern in a payment microservice?
Answer: Define explicit payment states and permitted transitions, persist them, validate gateway callbacks, make operations idempotent, and use database transactions and durable event handling where needed. The pattern helps organize the domain behavior but does not replace these reliability controls.
14. Frequently Asked Questions
What is the State Design Pattern in Java with an example?
It allows an object to change its behavior according to its current state. An online order is a useful example because the operations allowed before payment differ from those allowed after shipment or delivery.
Is the State pattern a behavioral design pattern?
Yes. It belongs to the behavioral family of design patterns because it organizes how an object's behavior changes with its state.
Can we use the State pattern with Spring Boot?
Yes. You can use plain Java state objects or stateless Spring beans. Keep request-specific information in the workflow context rather than shared bean fields.
When should we avoid the State pattern?
A few simple, stable conditions do not usually need separate state classes. Start with straightforward logic and introduce the pattern when lifecycle rules become complex or difficult to maintain.
Does adding a new state always avoid modifying existing code?
No. The pattern can make extension easier, but adding a state may still require changes to transition configuration, persistence mappings, APIs, tests, or code that must handle the new business behavior.
15. State Pattern Implementation Checklist
- Identify the actual business states and permitted transitions.
- Define a clear state interface.
- Move state-specific behavior into concrete state classes.
- Keep the context responsible for delegation and state ownership.
- Reject invalid transitions explicitly.
- Test both successful and rejected operations.
- Persist state if the workflow must survive application restarts.
- Consider duplicate requests and concurrent transitions.
- Keep shared state implementations free of per-request mutable data.
- Use the pattern only when it makes the domain easier to understand.
Conclusion
The State Design Pattern is useful when the behavior of a Java object changes throughout its lifecycle. By moving state-specific rules into dedicated classes, you can make complicated workflows easier to read, test, and extend.
Our e-commerce example demonstrates the core idea: the Order context delegates operations to its current state, and valid operations move it through the order lifecycle. The same approach can help organize payment processing, document approval, onboarding, and other stateful business workflows.
Remember that the pattern is a way to structure behavior, not a replacement for database transactions, concurrency control, idempotency, or reliable messaging. Those concerns become especially important in production payment and microservice applications.
If you are learning Java design patterns, try implementing the same order workflow first with a switch statement and then with the State pattern. Comparing both versions is one of the easiest ways to understand when this pattern is worth using.
No comments:
Post a Comment