Skip to main content

Building a Non Blocking State Machine in Spring Boot (Java 21+)

· 6 min read
Byju Luckose

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:

PrincipleExplanation
Separation of ConcernsKeep states, transitions, and business logic (actions/guards) clearly separated
Single ResponsibilityEach machine or service should handle a specific workflow
Event-driven DesignTransitions are triggered by events from Kafka, WebFlux, or internal logic
ObservabilityTrack metrics, transitions, and errors using Prometheus + Micrometer
ResilienceUse fallback states, retries, and guards to handle failures
Configuration over ConventionDefine states, transitions, and actions declaratively using DSL
External Transitions EmphasisPrefer external transitions with actions for full control and traceability
Stateless MachinesMachines don’t persist state internally; use Redis or DB externally
Separation of Actions/GuardsActions 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.