Modern backend systems demand reactive, scalable, and maintainable architectures especially for event driven workflows like document processing, user provisioning, or IoT orchestration. In this blog post, we’ll explore how to build a non blocking, production ready Spring StateMachine using Java 21, Spring Boot 3.3+, and Project Reactor. We'll follow architectural standards, observability best practices, and layer in a moderately complex real world example.
✨ Why Non-Blocking State Machines Matter
A State Machine models the behavior of a system by defining possible states, events, and transitions. Using a non blocking (reactive) implementation brings huge benefits:
-
Improved scalability: Threads aren’t blocked, allowing efficient resource usage
-
Better responsiveness: Especially under high concurrency or I/O load
-
Clean workflow orchestration: Explicitly model and track business state transitions
-
Excellent integration: With Kafka, WebFlux, and Micrometer
🎓 Theory Refresher: What is a Finite State Machine (FSM)?
A Finite State Machine (FSM) consists of:
-
A finite set of states
-
A set of events that trigger transitions
-
Transition functions that define valid state changes
-
Optional entry/exit actions per state
FSMs are ideal for modeling lifecycle workflows like:
-
Order processing
-
User registration
-
Document approval
-
IoT device management
In Spring StateMachine, FSM concepts are implemented with strong typing, clear configuration, and extensible action/guard hooks.
🔄 Architectural Patterns & Principles
Below is a detailed table of architectural best practices and how they apply to Spring StateMachine:
Principle | Explanation |
---|---|
Separation of Concerns | Keep states, transitions, and business logic (actions/guards) clearly separated |
Single Responsibility | Each machine or service should handle a specific workflow |
Event-driven Design | Transitions are triggered by events from Kafka, WebFlux, or internal logic |
Observability | Track metrics, transitions, and errors using Prometheus + Micrometer |
Resilience | Use fallback states, retries, and guards to handle failures |
Configuration over Convention | Define states, transitions, and actions declaratively using DSL |
External Transitions Emphasis | Prefer external transitions with actions for full control and traceability |
Stateless Machines | Machines don’t persist state internally; use Redis or DB externally |
Separation of Actions/Guards | Actions and guards should be defined as Spring beans or components |
🧱 Configuration Example
State & Event Enums
public enum DocState {
NEW, VALIDATING, PROCESSING, REVIEW_PENDING, APPROVED, FAILED
}
public enum DocEvent {
VALIDATE, PROCESS, SEND_FOR_REVIEW, APPROVE, FAIL
}
State Machine Configuration
First, define separate Action classes for clarity and reuse:
@Component
public class ValidateAction implements Action<DocState, DocEvent> {
public void execute(StateContext<DocState, DocEvent> context) {
System.out.println("[Action] Validating document " + context.getStateMachine().getId());
}
}
@Component
public class ProcessAction implements Action<DocState, DocEvent> {
public void execute(StateContext<DocState, DocEvent> context) {
System.out.println("[Action] Processing document " + context.getStateMachine().getId());
}
}
@Component
public class ApproveAction implements Action<DocState, DocEvent> {
public void execute(StateContext<DocState, DocEvent> context) {
System.out.println("[Action] Approving document " + context.getStateMachine().getId());
}
}
Now, wire them into your state machine configuration:java @Configuration
@Configuration
@EnableStateMachineFactory
@RequiredArgsConstructor
public class DocumentStateMachineConfig extends EnumStateMachineConfigurerAdapter<DocState, DocEvent> {
private final ValidateAction validateAction;
private final ProcessAction processAction;
private final ApproveAction approveAction;
@Override
public void configure(StateMachineStateConfigurer<DocState, DocEvent> states) throws Exception {
states.withStates()
.initial(DocState.NEW)
.state(DocState.VALIDATING)
.state(DocState.PROCESSING)
.state(DocState.REVIEW_PENDING)
.end(DocState.APPROVED)
.end(DocState.FAILED);
}
@Override
public void configure(StateMachineTransitionConfigurer<DocState, DocEvent> transitions) throws Exception {
transitions.withExternal().source(DocState.NEW).target(DocState.VALIDATING).event(DocEvent.VALIDATE).action(validateAction)
.and()
.withExternal().source(DocState.VALIDATING).target(DocState.PROCESSING).event(DocEvent.PROCESS).action(processAction)
.and()
.withExternal().source(DocState.PROCESSING).target(DocState.REVIEW_PENDING).event(DocEvent.SEND_FOR_REVIEW)
.and()
.withExternal().source(DocState.REVIEW_PENDING).target(DocState.APPROVED).event(DocEvent.APPROVE).action(approveAction)
.and()
.withExternal().source(DocState.VALIDATING).target(DocState.FAILED).event(DocEvent.FAIL)
.and()
.withExternal().source(DocState.PROCESSING).target(DocState.FAILED).event(DocEvent.FAIL);
}
private Action<DocState, DocEvent> log(String message) {
return context -> System.out.printf("[Action] %s for doc: %s\n", message, context.getStateMachine().getId());
}
}
⚙️ Running a Reactive State Machine
@Service
public class DocumentWorkflowService {
private final StateMachineFactory<DocState, DocEvent> factory;
public DocumentWorkflowService(StateMachineFactory<DocState, DocEvent> factory) {
this.factory = factory;
}
public void runWorkflow(String docId) {
var sm = factory.getStateMachine(docId);
sm.startReactively()
.then(sm.sendEvent(Mono.just(MessageBuilder.withPayload(DocEvent.VALIDATE).build())))
.then(sm.sendEvent(Mono.just(MessageBuilder.withPayload(DocEvent.PROCESS).build())))
.then(sm.sendEvent(Mono.just(MessageBuilder.withPayload(DocEvent.SEND_FOR_REVIEW).build())))
.then(sm.sendEvent(Mono.just(MessageBuilder.withPayload(DocEvent.APPROVE).build())))
.then(sm.stopReactively())
.subscribe();
}
}
❗ Handling Errors in Actions and Ensuring Transitions
In a robust state machine, handling exceptions within Action classes is critical to avoid broken workflows or silent failures.
🔒 Safe Action Execution Pattern
You should catch exceptions within your Action class to prevent the state machine from halting unexpectedly:
@Component
public class ValidateAction implements Action<DocState, DocEvent> {
public void execute(StateContext<DocState, DocEvent> context) {
try {
// Perform validation logic
System.out.println("[Action] Validating " + context.getStateMachine().getId());
} catch (Exception ex) {
context.getExtendedState().getVariables().put("error", ex.getMessage());
context.getStateMachine().sendEvent(DocEvent.FAIL); // Trigger failure transition
}
}
}
🚨 Configure Error Transitions
Make sure to define fallback transitions that catch these programmatic failures and move the machine to a safe state:
.withExternal()
.source(DocState.VALIDATING)
.target(DocState.FAILED)
.event(DocEvent.FAIL)
.action(errorHandler)
Define an optional errorHandler to log or notify:
@Component
public class ErrorHandler implements Action<DocState, DocEvent> {
public void execute(StateContext<DocState, DocEvent> context) {
String reason = (String) context.getExtendedState().getVariables().get("error");
System.err.println("Transitioned to FAILED due to: " + reason);
}
}
🛡️ Global Error Listener (Optional)
Catch unhandled exceptions:
@Override
public void configure(StateMachineConfigurationConfigurer<DocState, DocEvent> config) throws Exception {
config.withConfiguration()
.listener(new StateMachineListenerAdapter<>() {
@Override
public void stateMachineError(StateMachine<DocState, DocEvent> stateMachine, Exception exception) {
log.error("StateMachine encountered an error: ", exception);
}
});
}
📊 Observability and Kafka Integration
Metric Listener with Micrometer
@Bean
public StateMachineListener<DocState, DocEvent> metricListener(MeterRegistry registry) {
return new StateMachineListenerAdapter<>() {
@Override
public void stateChanged(State<DocState, DocEvent> from, State<DocState, DocEvent> to) {
registry.counter("doc_state_transition", "from", from.getId().name(), "to", to.getId().name()).increment();
}
};
}
Kafka Event Trigger
@KafkaListener(topics = "doc.events")
public void handleEvent(String json) {
DocEvent event = parse(json);
String docId = extractId(json);
var sm = factory.getStateMachine(docId);
sm.startReactively()
.then(sm.sendEvent(Mono.just(MessageBuilder.withPayload(event).build())))
.subscribe();
}
🖼️ DOT Export for Visualization
To visualize your state machine, use the StateMachineSerialisationUtils utility provided by Spring StateMachine. Make sure you include the dependency:
<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-kryo</artifactId>
</dependency>
Then export the DOT file like so:
String dot = StateMachineSerialisationUtils.toDot(stateMachine);
Files.writeString(Path.of("statemachine.dot"), dot);
Render via:
dot -Tpng statemachine.dot -o statemachine.png
📏 Additional Standards and Extensions
🧩 State Persistence with Redis or DB
@Bean
public StateMachinePersister<DocState, DocEvent, String> persister() {
return new InMemoryStateMachinePersister<>(); // Replace with Redis or JPA-based implementation
}
Use:
persister.persist(stateMachine, docId);
persister.restore(stateMachine, docId);
📉 Real Time Dashboards (Prometheus + Grafana)
sum by (from, to) (rate(doc_state_transition[5m]))
🧪 Testing with Spring Test Plan
@Test
void shouldTransitionFromNewToValidating() throws Exception {
StateMachine<DocState, DocEvent> machine = factory.getStateMachine();
machine.startReactively().block();
boolean result = machine.sendEvent(Mono.just(MessageBuilder.withPayload(DocEvent.VALIDATE).build())).block();
assertThat(machine.getState().getId()).isEqualTo(DocState.VALIDATING);
}
📐 FAQ: Best Practices
Why emphasize external transitions? Easier to test and debug.
Why stateless machines? More scalable and testable.
Separate actions/guards? Yes — improves traceability and reuse.
Visualize workflows? Use DOT export + Graphviz.
Manage large flows? Use nested or orthogonal states.