DEV Community

Cover image for Spring StateMachine Explained: Managing Complex Workflows with Ease
İbrahim Gündüz
İbrahim Gündüz

Posted on • Originally published at Medium

Spring StateMachine Explained: Managing Complex Workflows with Ease

State machines are commonly used in workflow management applications to model state-driven processes. They allow us to define states, transitions, and actions triggered by state changes, enforcing predefined rules. In this article, we’ll focus on the basic usage of the Spring Statemachine component and integration with the persistence layer.

Let’s take a look at the following state diagram.

In the diagram above, we model a simple two-step payment flow with 3DS authentication consisting of five states and four transitions. In the next steps, we will see how to configure Spring Statemachine based on this.

1. Adding Dependency

To get started, we need to add the following dependency definition to the pom.xml file of our example project.

    <dependencies>
<!-- ... -->
        <dependency>
            <groupId>org.springframework.statemachine</groupId>
            <artifactId>spring-statemachine-starter</artifactId>
        </dependency>
<!-- ... -->
    </dependencies>

<dependencyManagement>
  <dependencies>
      <dependency>
          <groupId>org.springframework.statemachine</groupId>
          <artifactId>spring-statemachine-bom</artifactId>
          <version>4.0.0</version>
          <type>pom</type>
          <scope>import</scope>
      </dependency>
  </dependencies>
</dependencyManagement>
Enter fullscreen mode Exit fullscreen mode

2. Defining State Machine Rules

Create the following enums that represent our states and events to be triggered.

public enum PaymentStates {
    INITIAL,
    THREE_DS_AUTHENTICATION_PENDING,
    AUTHORIZED,
    CAPTURED,
    VOIDED
}

public enum PaymentEvents {
    AUTHORIZE,
    AUTHENTICATE,
    CAPTURE,
    VOID
}
Enter fullscreen mode Exit fullscreen mode

Spring Statemachine provides several methods to configure the state machine based on your needs. If you don’t need to manage multiple workflows or build the state machine dynamically, you can configure it using plain configuration objects. To use configuration objects annotated with @Configuration, you must create a configuration class that extends the StateMachineConfigurerAdapter.

package org.example;

import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachine;
import org.springframework.statemachine.config.StateMachineConfigurerAdapter;

@Configuration
@EnableStateMachine
public class StateMachineConfiguration
        extends StateMachineConfigurerAdapter<PaymentStates, PaymentEvents> {

}
Enter fullscreen mode Exit fullscreen mode

StateMachineConfigurerAdapter provides various methods to configure the state machine. You can define states and transitions using different methods, as shown below, or build the state machine using StateMachineBuilder.

2.1. Option 1. — Configuring States and Events in Separate Methods

You can define the statuses and events in separate methods by overriding the following methods from the StateMachineConfigurerAdapter class.

public void configure(StateMachineStateConfigurer<PaymentStates, PaymentEvents> states) throws Exception;
public void configure(StateMachineTransitionConfigurer<PaymentStates, PaymentEvents> transitions) throws Exception; 
Enter fullscreen mode Exit fullscreen mode
@Override
public void configure(StateMachineStateConfigurer<PaymentStates, PaymentEvents> states) throws Exception {
    states
            .withStates()
            .initial(PaymentStates.INITIAL)
            .end(PaymentStates.VOIDED)
            .states(EnumSet.allOf(PaymentStates.class));
}

@Override
public void configure(StateMachineTransitionConfigurer<PaymentStates, PaymentEvents> transitions) throws Exception {
    transitions
            .withExternal()
            .source(PaymentStates.INITIAL)
            .target(PaymentStates.THREE_DS_AUTHENTICATION_PENDING)
            .event(PaymentEvents.AUTHORIZE)
            .and()
            .withExternal()
            .source(PaymentStates.THREE_DS_AUTHENTICATION_PENDING)
            .target(PaymentStates.AUTHORIZED)
            .event(PaymentEvents.AUTHENTICATE)
            .and()
            .withExternal()
            .source(PaymentStates.AUTHORIZED)
            .target(PaymentStates.CAPTURED)
            .event(PaymentEvents.CAPTURE)
            .and()
            .withExternal()
            .source(PaymentStates.AUTHORIZED)
            .target(PaymentStates.VOIDED)
            .event(PaymentEvents.VOID)
            .and()
            .withExternal()
            .source(PaymentStates.CAPTURED)
            .target(PaymentStates.VOIDED)
            .event(PaymentEvents.VOID);
}

@Override
public void configure(StateMachineConfigurationConfigurer<PaymentStates, PaymentEvents> config) throws Exception {
    config.withConfiguration()
            .autoStartup(true);
}
Enter fullscreen mode Exit fullscreen mode

3. Create a State Machine Persister

To allow Spring Statemachine to persist the data in the storage, we need to implement a persister that implements StateMachinePersist. However, you can also use DefaultStateMachinePersist and create the table on the database manually if you don’t want to use JPA. As we prepare the example code using JPA, let’s create the following JPA Entity first.

@Entity
@Table(name = "payment_state_machine")
public class PaymentStateMachineEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long paymentId;

    @Enumerated(EnumType.STRING)
    private PaymentStates state;

    @Column(nullable = false, length = 20)
    private String event;

    private LocalDateTime lastUpdated;

    public PaymentStateMachineEntity() {
        this.lastUpdated = LocalDateTime.now();
    }

    public Long getPaymentId() {
        return paymentId;
    }

    public PaymentStates getState() {
        return state;
    }

    public String getEvent() {
        return event;
    }

    public LocalDateTime getLastUpdated() {
        return lastUpdated;
    }

    public void setPaymentId(Long paymentId) {
        this.paymentId = paymentId;
    }

    public void setState(PaymentStates state) {
        this.state = state;
    }

    public void setEvent(String event) {
        this.event = event;
    }

    public void setLastUpdated(LocalDateTime lastUpdated) {
        this.lastUpdated = lastUpdated;
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, create a JPA repository for the entity.

public interface PaymentStateMachineRepository extends JpaRepository<PaymentStateMachineEntity, Long> {
}
Enter fullscreen mode Exit fullscreen mode

And create a new implementation of StateMachinePersist to read and write the state record from/to the storage.

@Component
public class PaymentStateMachinePersist implements StateMachinePersist<PaymentStates, PaymentEvents, Long> {
    private final PaymentStateMachineRepository repository;

    public PaymentStateMachinePersist(PaymentStateMachineRepository repository) {
        this.repository = repository;
    }

    @Override
    public void write(StateMachineContext<PaymentStates, PaymentEvents> context, Long paymentId) throws Exception {
        PaymentStateMachineEntity entity = repository.findById(paymentId)
                .orElse(new PaymentStateMachineEntity());

        entity.setPaymentId(paymentId);
        entity.setState(context.getState());  // Persist the state
        entity.setEvent(context.getEvent() != null ? context.getEvent().name() : null);
        entity.setLastUpdated(java.time.LocalDateTime.now());

        repository.save(entity);
    }

    @Override
    public StateMachineContext<PaymentStates, PaymentEvents> read(Long paymentId) throws Exception {
        return repository.findById(paymentId)
                .map(entity -> new DefaultStateMachineContext<PaymentStates, PaymentEvents>(
                        entity.getState(),
                        null,
                        null,
                        null
                ))
                .orElse(null);
    }
}
Enter fullscreen mode Exit fullscreen mode

Create a bean for DefaultStateMachinePersister to use the newly implemented PaymentStateMachinePersist class.

@Bean
  public StateMachinePersister<PaymentStates, PaymentEvents, Long> stateMachinePersister(PaymentStateMachinePersist persist) {
      return new DefaultStateMachinePersister<>(persist);
  }
Enter fullscreen mode Exit fullscreen mode

4. Usage

Let’s create a service class like the one below to demonstrate how to read the current state and trigger an event for state transition.

@Component
public class PaymentService {
    private final PaymentRepository paymentRepository;
    private final StateMachinePersister<PaymentStates, PaymentEvents, Long> smPersister;
    private final StateMachine<PaymentStates, PaymentEvents> stateMachine;

    public PaymentService(PaymentRepository paymentRepository,
                          StateMachinePersister<PaymentStates, PaymentEvents, Long> smPersister,
                          StateMachine<PaymentStates, PaymentEvents> stateMachine) {
        this.paymentRepository = paymentRepository;
        this.smPersister = smPersister;
        this.stateMachine = stateMachine;
    }

    public PaymentEntity create(BigDecimal amount) {
        PaymentEntity entity = new PaymentEntity(null, PaymentStates.INITIAL, amount);
        return paymentRepository.save(entity);
    }

    public PaymentEntity authorize(Long paymentId) throws Exception {
        PaymentEntity entity = paymentRepository.findById(paymentId)
                .orElseThrow(() -> new RuntimeException("No payment found with id " + paymentId));

        try {
            smPersister.restore(stateMachine, paymentId);
        } catch (Exception e) {
            throw new RuntimeException("Failed to restore state machine", e);
        }

        stateMachine.sendEvent(Mono.just(
                        MessageBuilder.withPayload(PaymentEvents.AUTHORIZE)
                                .build())
                )
                .subscribe();

        PaymentStates newState = stateMachine.getState().getId();

        PaymentEntity updatedEntity = new PaymentEntity(entity.getId(), newState, entity.getAmount());
        paymentRepository.save(updatedEntity);

        return updatedEntity;
    }

    public PaymentEntity getPayment(Long paymentId) {
        return paymentRepository.findById(paymentId)
                .orElseThrow(() -> new RuntimeException("No payment found with id " + paymentId));
    }
}
Enter fullscreen mode Exit fullscreen mode

Demo time!

Create a CommandLineRunner to see how the example implementation works.

@Component
public class DemoRunner implements CommandLineRunner {
    private final PaymentService paymentService;

    public DemoRunner(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @Override
    public void run(String... args) throws Exception {
        // Create a payment
        PaymentEntity payment = paymentService.create(BigDecimal.valueOf(100));

        System.out.println("Current state of payment(" + payment.getId() + ") is " + payment.getPaymentState().toString());

        // Perform authorize() call to trigger the relevant state transition
        PaymentEntity paymentAfterAuth = paymentService.authorize(payment.getId());

        System.out.println("Current state of payment(" + paymentAfterAuth.getId() + ") is " + paymentAfterAuth.getPaymentState().toString() + " after authorization");

        // Retrieve the payment to see if the current state is persisted in the database.
        PaymentEntity currentPayment = paymentService.getPayment(payment.getId());

        System.out.println("Current state of the retrieved payment(" + currentPayment.getId() + ") is " + currentPayment.getPaymentState().toString());
    }
}
Enter fullscreen mode Exit fullscreen mode

Let’s run the code!

Current state of payment(1) is INITIAL
Current state of payment(1) is THREE_DS_AUTHENTICATION_PENDING after authorization
Current state of the retrieved payment(1) is THREE_DS_AUTHENTICATION_PENDING
Enter fullscreen mode Exit fullscreen mode

I hope you enjoyed reading the post. While I wanted to keep it short for easy reading, Spring Statemachine offers a great solution for more complex needs. I strongly recommend checking out the reference documentation I’ve included in the credits section to explore the full power of Spring Statemachine.

You can find the source code of the example project here:

Spring StateMachine Code Example

Thanks for reading!

Credits

Top comments (0)