A Simple State Machine for Spring Boot Projects
There are many articles and open-source projects on state machines (Ref#1) that can be searched on Google or GitHub. The Spring team itself provides a state machine framework (Ref#2). However, I found these frameworks not easy to customize. Furthermore, it was not easy to add logs and throw custom exceptions where I needed. So I created a simple implementation of the state machine that can be easily integrated into Spring Boot applications.
The idea of a state machine is to define a set of state transitions where each transition is affected by an event. For instance, Wikipedia has an example for a turnstile that has the Locked and Unlocked states, which are affected by the events "coin" and "push". The turnstile's state transitions from Locked to Unlocked when the "coin" event happens. It transitions from Unlocked to Locked when a "push" event happens. The state machine enforces that when the turnstile is in the Locked state the "push" event has no effect. Similarly, when the turnstile is in the Unlocked state the "coin" event has no effect.
The state machine framework proposed in this article is based on a state transitions table representation like the one shown below:
Initial State | Pre-Event | Processor | Post-Event | Final State |
Locked | coin | processCoin() | coinSuccess | Unlocked |
Locked | coin | processCoin() | coinError | Locked |
Unlocked | push | processPush() | pushSuccess | Locked |
The proposed framework consists of the following core components:
1. ProcessState
— where various states are configured as a Java Enum class.
2. ProcessEvent
— where state transitions like the above are configured as a Java Enum class.
3. StateTransitionsManager
— which responds to events. If the event is a pre-event then forwards the processing to a processor, or if the event is a post-event, then it changes the state to the final state. It also sets a default state if needed.
4. Processor
— which executes the business rules for the corresponding process state transition step.
The corresponding Java components are listed below:
//Enum implements this marker interface
public interface ProcessState {
}
//Enum implements this interface
public interface ProcessEvent {
public abstract Class<? extends Processor> nextStepProcessor(ProcessEvent event);
public abstract ProcessState nextState(ProcessEvent event);
}
//events handler
public interface StateTransitionsManager {
public ProcessData processEvent(ProcessData data) throws ProcessException;
}
//enforces initialization of the state where needed
public abstract class AbstractStateTransitionsManager implements StateTransitionsManager {
protected abstract ProcessData initializeState(ProcessData data) throws ProcessException;
protected abstract ProcessData processStateTransition(ProcessData data) throws ProcessException;
@Override
public ProcessData processEvent(ProcessData data) throws ProcessException {
initializeState(data);
return processStateTransition(data);
}
}
//executes the business rules
//needed for this state transition step
public interface Processor {
public ProcessData process(ProcessData data) throws ProcessException;
}
//
public interface ProcessData {
public ProcessEvent getEvent();
}
//
public class ProcessException extends Exception {
private static final long serialVersionUID = 1L;
public ProcessException(String message) {
super(message);
}
public ProcessException(String message, Throwable e) {
super(message, e);
}
}
An example implementation of the above state machine framework for an online order processing application involves the following classes:
/**
* DEFAULT - submit -> orderProcessor() -> orderCreated -> PMTPENDING
* PMTPENDING - pay -> paymentProcessor() -> paymentError -> PMTPENDING
* PMTPENDING - pay -> paymentProcessor() -> paymentSuccess -> COMPLETED
*/
public enum OrderState implements ProcessState {
Default,
PaymentPending,
Completed;
}
/**
* DEFAULT - submit -> orderProcessor() -> orderCreated -> PMTPENDING
* PMTPENDING - pay -> paymentProcessor() -> paymentError -> PMTPENDING
* PMTPENDING - pay -> paymentProcessor() -> paymentSuccess -> COMPLETED
*/
public enum OrderEvent implements ProcessEvent {
submit {
@Override
public Class<? extends Processor> nextStepProcessor(ProcessEvent event) {
return OrderProcessor.class;
}
/**
* This event has no effect on state so return current state
*/
@Override
public ProcessState nextState(ProcessEvent event) {
return OrderState.Default;
}
},
orderCreated {
/**
* This event does not trigger any process
* So return null
*/
@Override
public Class<? extends Processor> nextStepProcessor(ProcessEvent event) {
return null;
}
@Override
public ProcessState nextState(ProcessEvent event) {
return OrderState.PaymentPending;
}
},
pay {
@Override
public Class<? extends Processor> nextStepProcessor(ProcessEvent event) {
return PaymentProcessor.class;
}
/**
* This event has no effect on state so return current state
*/
@Override
public ProcessState nextState(ProcessEvent event) {
return OrderState.PaymentPending;
}
},
paymentSuccess {
/**
* This event does not trigger any process
* So return null
*/
@Override
public Class<? extends Processor> nextStepProcessor(ProcessEvent event) {
return null;
}
@Override
public ProcessState nextState(ProcessEvent event) {
return OrderState.Completed;
}
},
paymentError {
/**
* This event does not trigger any process
* So return null
*/
@Override
public Class<? extends Processor> nextStepProcessor(ProcessEvent event) {
return null;
}
@Override
public ProcessState nextState(ProcessEvent event) {
return OrderState.PaymentPending;
}
};
}
/**
* This class manages various state transitions
* based on the event
* The superclass AbstractStateTransitionsManager
* calls the two methods initializeState and
* processStateTransition in that order
*/
@RequiredArgsConstructor
@Slf4j
@Service
public class OrderStateTransitionsManager extends AbstractStateTransitionsManager {
private final ApplicationContext context;
private final OrderDbService dbService;
@Override
protected ProcessData processStateTransition(ProcessData sdata) throws ProcessException {
OrderData data = (OrderData) sdata;
try {
log.info("Pre-event: " + data.getEvent().toString());
data = (OrderData) this.context.getBean(data.getEvent().nextStepProcessor(data.getEvent())).process(data);
log.info("Post-event: " + data.getEvent().toString());
dbService.getStates().put(data.getOrderId(), (OrderState)data.getEvent().nextState(data.getEvent()));
log.info("Final state: " + dbService.getStates().get(data.getOrderId()).name());
log.info("??*************************************");
} catch (OrderException e) {
log.info("Post-event: " + ((OrderEvent) data.getEvent()).name());
dbService.getStates().put(data.getOrderId(), (OrderState)data.getEvent().nextState(data.getEvent()));
log.info("Final state: " + dbService.getStates().get(data.getOrderId()).name());
log.info("??*************************************");
throw new OrderException(((OrderEvent) data.getEvent()).name(), e);
}
return data;
}
private OrderData checkStateForReturningCustomers(OrderData data) throws OrderException {
// returning customers must have a state
if (data.getOrderId() != null) {
if (this.dbService.getStates().get(data.getOrderId()) == null) {
throw new OrderException("No state exists for orderId=" + data.getOrderId());
} else if (this.dbService.getStates().get(data.getOrderId()) == OrderState.Completed) {
throw new OrderException("Order is completed for orderId=" + data.getOrderId());
} else {
log.info("Initial state: " + dbService.getStates().get(data.getOrderId()).name());
}
}
return data;
}
@Override
protected ProcessData initializeState(ProcessData sdata) throws OrderException {
OrderData data = (OrderData) sdata;
if (data.getOrderId() != null) {
return checkStateForReturningCustomers(data);
}
UUID orderId = UUID.randomUUID();
data.setOrderId(orderId);
dbService.getStates().put(orderId, (OrderState) OrderState.Default);
log.info("Initial state: " + dbService.getStates().get(data.getOrderId()).name());
return data;
}
public ConcurrentHashMap<UUID, OrderState> getStates() {
return dbService.getStates();
}
}
//persists state of the data
//here we are using HashMap for illustration purposes
@Service
public class OrderDbService {
private final ConcurrentHashMap<UUID, OrderState> states;
public OrderDbService() {
this.states = new ConcurrentHashMap<UUID, OrderState>();
}
public ConcurrentHashMap<UUID, OrderState> getStates() {
return states;
}
}
@NoArgsConstructor
@AllArgsConstructor
@Setter @Getter
@Builder
public class OrderData implements ProcessData {
private double payment;
private ProcessEvent event;
private UUID orderId;
@Override
public ProcessEvent getEvent() {
return this.event;
}
}
@Service
public class OrderProcessor implements Processor {
@Override
public ProcessData process(ProcessData data) throws ProcessException{
((OrderData)data).setEvent(OrderEvent.orderCreated);
return data;
}
}
@Service
public class PaymentProcessor implements Processor {
@Override
public ProcessData process(ProcessData data) throws ProcessException {
if(((OrderData)data).getPayment() < 1.00) {
((OrderData)data).setEvent(OrderEvent.paymentError);
throw new PaymentException(OrderEvent.paymentError.name());
} else {
((OrderData)data).setEvent(OrderEvent.paymentSuccess);
}
return data;
}
}
@RequiredArgsConstructor
@RestController
public class OrderController {
private final OrderStateTransitionsManager stateTrasitionsManager;
@GetMapping("/order/cart")
public String handleOrderPayment(
@RequestParam double payment,
@RequestParam UUID orderId) throws Exception {
OrderData data = new OrderData();
data.setPayment(payment);
data.setOrderId(orderId);
data.setEvent(OrderEvent.pay);
data = (OrderData)stateTrasitionsManager.processEvent(data);
return ((OrderEvent)data.getEvent()).name();
}
@ExceptionHandler(value=OrderException.class)
public String handleOrderException(OrderException e) {
return e.getMessage();
}
@GetMapping("/order")
public String handleOrderSubmit() throws ProcessException {
OrderData data = new OrderData();
data.setEvent(OrderEvent.submit);
data = (OrderData)stateTrasitionsManager.processEvent(data);
return ((OrderEvent)data.getEvent()).name() + ", orderId = " + data.getOrderId();
}
}
@SpringBootApplication
public class StateMachineApplication {
public static void main(String[] args) {
SpringApplication.run(StateMachineApplication.class, args);
}
}
The full source is also available at GitHub.
Note that the first step in implementing this framework is to create a state transitions table. To run this example, import the source into an IDE like STS and run as a Spring Boot application. The following two APIs can be executed to test the above implementation:
GET http://localhost:8080/order
GET http://localhost:8080/order/cart?payment=123&orderId=123
For quick testing in a browser, both APIs are implemented as GET.
Test#1: When the /order
API is called a response like: "Order created, orderId=123" is returned and console log displays:
Initial state: Default
Pre-event: submit
Post-event: orderCreated
Final state: PaymentPending
Test#2: When the /order/cart
API is called with payment error like:
/order/cart?payment=0&orderId=123
API is called, a response like: "paymentError" is returned and the console log displays:Initial state: PaymentPending
Pre-event: pay
Post-event: paymentError
Final state: PaymentPending
Test#3: When the /order/cart?payment=123&orderId=123
API is called, a response like: "paymentSuccess" is returned and the console log displays:
Initial state: PaymentPending
Pre-event: pay
Post-event: paymentSuccess
Final state: Completed
Test#4: After payment is processed successfully, if
/order/cart?payment=123&orderId=123
is called again then a response like: "Order is completed for orderId=123" is returned and state remains unchanged.
Conclusions
The state machine presented should be easily customized and integrated into process/workflow-oriented Spring Boot projects. Hope you enjoyed!