Enterprise Java

Spring State Machine: what is it and do you need it?

State machine is a model of computation based on the finite states, as Wikipedia very obligingly says. Usually there are workflows to go with the states, meaning that you can’t just go from any state to any other state: there are rules one should follow. The transitions between these states are limited by the rules.

The Spring framework has a whole library called Spring State Machine. It is an implementation of the concept, intended to simplify the development of the state machine logic for developers, already using Spring framework.

Let’s see how it works.

First we need a Spring Boot application with the dependency on Spring State Machine (and Lombok for simplicity). It’s very easy to generate one from the Spring Starter page or from the IDE like Intellij IDEA which also uses Spring starter templates).

To actually use the state machine, it should be enabled in the application class:

  
 @SpringBootApplication
@EnableStateMachine
public class Application implements CommandLineRunner {
    private final StateMachine<BookStates, BookEvents> stateMachine;

    @Autowired
    public Application(StateMachine<BookStates, BookEvents> stateMachine) {
        this.stateMachine = stateMachine;
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Override
    public void run(String... args) {
        stateMachine.start();
        stateMachine.sendEvent(BookEvents.RETURN);
        stateMachine.sendEvent(BookEvents.BORROW);
        stateMachine.stop();
    }
}

When the @EnableStateMachine annotation is used, it automatically creates a default state machine when the application starts. So it can be injected into the Application class. By default, the bean will be called stateMachine, but it can be given another name. We will also need classes for our events and for the states. Let’s base our simple example on a library. We know that the library books can be borrowed or returned, or maybe damaged and in repair (therefore unable to borrow). So, this is exactly what we put into the model.

  
public enum BookStates {
    AVAILABLE,
    BORROWED,
    IN_REPAIR
}
public enum BookEvents {
    BORROW,
    RETURN,
    START_REPAIR,
    END_REPAIR
}

Then, the state machine should be configured with these transactions and states:

  
    @Override
    public void configure(StateMachineStateConfigurer<BookStates, BookEvents> states) throws Exception {
       states.withStates()
               .initial(BookStates.AVAILABLE)
               .states(EnumSet.allOf(BookStates.class));
    }

    @Override
    public void configure(StateMachineTransitionConfigurer<BookStates, BookEvents> transitions) throws Exception {
        transitions
                .withExternal()
                .source(BookStates.AVAILABLE)
                .target(BookStates.BORROWED)
                .event(BookEvents.BORROW)
                .and()
                .withExternal()
                .source(BookStates.BORROWED)
                .target(BookStates.AVAILABLE)
                .event(BookEvents.RETURN)
                .and()
                .withExternal()
                .source(BookStates.AVAILABLE)
                .target(BookStates.IN_REPAIR)
                .event(BookEvents.START_REPAIR)
                .and()
                .withExternal()
                .source(BookStates.IN_REPAIR)
                .target(BookStates.AVAILABLE)
                .event(BookEvents.END_REPAIR);
}

And the last but not least, we allow the state machine to automatically start (it won’t do that by default).

  
  @Override
    public void configure(StateMachineConfigurationConfigurer<BookStates, BookEvents> config) throws Exception {
        config.withConfiguration()
                .autoStartup(true);
}

Now we can use it in the application and see what happens!

  
  @Override
    public void run(String... args) {
        boolean returnAccepted = stateMachine.sendEvent(BookEvents.RETURN);
        logger.info("return accepted: " + returnAccepted);
        boolean borrowAccepted = stateMachine.sendEvent(BookEvents.BORROW);
        logger.info("borrow accepted: " + borrowAccepted);
}

When we run the application, we see the following in the logs:

2018-07-07 13:46:05.096 INFO 37417 --- [ main] STATE MACHINE : return accepted: false
2018-07-07 13:46:05.098 INFO 37417 --- [ main] STATE MACHINE : borrow accepted: true

I intentionally called RETURN first to see that it will fail. However, it fails with no exceptions: the action was just not accepted and the machine stayed in the AVAILABLE state, which made it possible to execute BORROW again. So, what happens if we swap the two calls?

2018-07-07 13:49:46.218 INFO 37496 --- [ main] STATE MACHINE : borrow accepted: true
2018-07-07 13:49:46.218 INFO 37496 --- [ main] STATE MACHINE : return accepted: true

Which means that the correct interaction is accepted. However, what if we want more visibility on what happens? One way is to configure handlers for our state changes:

  
  @Override
    public void configure(StateMachineStateConfigurer<BookStates, BookEvents> states) throws Exception {
        states.withStates().initial(BookStates.AVAILABLE)
                .state(BookStates.AVAILABLE, entryAction(), exitAction())
                .state(BookStates.BORROWED, entryAction(), exitAction())
                .state(BookStates.IN_REPAIR, entryAction(), exitAction());
    }

    @Bean
    public Action<BookStates, BookEvents> entryAction() {
        return ctx -> LOGGER.info("Entry action {} to get from {} to {}",
                ctx.getEvent(),
                getStateInfo(ctx.getSource()),
                getStateInfo(ctx.getTarget()));
    }

    @Bean
    public Action<BookStates, BookEvents> exitAction() {
        return ctx -> LOGGER.info("Exit action {} to get from {} to {}",
                ctx.getEvent(),
                getStateInfo(ctx.getSource()),
                getStateInfo(ctx.getTarget()));
}
2018-07-07 13:53:59.940 INFO 37579 --- [ main] STATE MACHINE : Entry action null to get from EMPTY STATE to AVAILABLE
2018-07-07 13:54:00.051 INFO 37579 --- [ main] STATE MACHINE : return accepted: false
2018-07-07 13:54:00.052 INFO 37579 --- [ main] STATE MACHINE : Exit action BORROW to get from AVAILABLE to BORROWED
2018-07-07 13:54:00.052 INFO 37579 --- [ main] STATE MACHINE : Entry action BORROW to get from AVAILABLE to BORROWED
2018-07-07 13:54:00.053 INFO 37579 --- [ main] STATE MACHINE : borrow accepted: true
2018-07-07 13:54:00.053 INFO 37579 --- [ main] STATE MACHINE : Exit action RETURN to get from BORROWED to AVAILABLE
2018-07-07 13:54:00.053 INFO 37579 --- [ main] STATE MACHINE : Entry action RETURN to get from BORROWED to AVAILABLE
2018-07-07 13:54:00.053 INFO 37579 --- [ main] STATE MACHINE : return accepted: true

The other way would be to define a full-blown listener:

  
 public class LoggingMashineListener implements StateMachineListener<BookStates, BookEvents> {
    private static final Logger LOGGER = LoggingUtils.LOGGER;

    @Override
    public void stateChanged(State<BookStates, BookEvents> from, State<BookStates, BookEvents> to) {
        LOGGER.info("State changed from {} to {}", getStateInfo(from), getStateInfo(to));
    }

    @Override
    public void stateEntered(State<BookStates, BookEvents> state) {
        LOGGER.info("Entered state {}", getStateInfo(state));
    }

    @Override
    public void stateExited(State<BookStates, BookEvents> state) {
        LOGGER.info("Exited state {}", getStateInfo(state));
    }

    @Override
    public void eventNotAccepted(Message event) {
        LOGGER.error("Event not accepted: {}", event.getPayload());
    }

    @Override
    public void transition(Transition<BookStates, BookEvents> transition) {
        // Too much logging spoils the code =)
    }

    @Override
    public void transitionStarted(Transition<BookStates, BookEvents> transition) {
        // Too much logging spoils the code =)
    }

    @Override
    public void transitionEnded(Transition<BookStates, BookEvents> transition) {
        // Too much logging spoils the code =)
    }

    @Override
    public void stateMachineStarted(StateMachine<BookStates, BookEvents> stateMachine) {
        LOGGER.info("Machine started: {}", stateMachine);
    }

    @Override
    public void stateMachineStopped(StateMachine<BookStates, BookEvents> stateMachine) {
        LOGGER.info("Machine stopped: {}", stateMachine);
    }

    @Override
    public void stateMachineError(StateMachine<BookStates, BookEvents> stateMachine, Exception exception) {
        LOGGER.error("Machine error: {}", stateMachine);
    }

    @Override
    public void extendedStateChanged(Object key, Object value) {
        LOGGER.info("Extended state changed: [{}: {}]", key, value);
    }

    @Override
    public void stateContext(StateContext<BookStates, BookEvents> stateContext) {
        // Too much logging spoils the code =)
    }
}

And link the listener to the machine when it’s configured. Now we can remove our entry and exit listeners and the states configuration will return to our first revision (see above).

  
   @Override
    public void configure(StateMachineConfigurationConfigurer<BookStates, BookEvents> config) throws Exception {
        config.withConfiguration()
                .autoStartup(true)
                .listener(new LoggingMashineListener())
        ;
}

This way, you will have much more insight in what’s happening:

2018-07-07 13:59:22.714 INFO 37684 --- [ main] STATE MACHINE : Entered state AVAILABLE
2018-07-07 13:59:22.716 INFO 37684 --- [ main] STATE MACHINE : State changed from EMPTY STATE to AVAILABLE
2018-07-07 13:59:22.717 INFO 37684 --- [ main] STATE MACHINE : Machine started: IN_REPAIR AVAILABLE BORROWED / AVAILABLE / uuid=815f744e-8c5c-4ab1-88d1-b5223199bc4e / id=null
2018-07-07 13:59:22.835 ERROR 37684 --- [ main] STATE MACHINE : Event not accepted: RETURN
2018-07-07 13:59:22.836 INFO 37684 --- [ main] STATE MACHINE : return accepted: false
2018-07-07 13:59:22.837 INFO 37684 --- [ main] STATE MACHINE : Exited state AVAILABLE
2018-07-07 13:59:22.838 INFO 37684 --- [ main] STATE MACHINE : Entered state BORROWED
2018-07-07 13:59:22.838 INFO 37684 --- [ main] STATE MACHINE : State changed from AVAILABLE to BORROWED
2018-07-07 13:59:22.839 INFO 37684 --- [ main] STATE MACHINE : borrow accepted: true
2018-07-07 13:59:22.839 INFO 37684 --- [ main] STATE MACHINE : Exited state BORROWED
2018-07-07 13:59:22.839 INFO 37684 --- [ main] STATE MACHINE : Entered state AVAILABLE
2018-07-07 13:59:22.839 INFO 37684 --- [ main] STATE MACHINE : State changed from BORROWED to AVAILABLE
2018-07-07 13:59:22.839 INFO 37684 --- [ main] STATE MACHINE : return accepted: true

When is the state machine needed? Spring documentations states that you are already trying to implement a state machine if:

  • Using boolean flags or enums to model situations.
  • Having variables which only have meaning for some part of your application lifecycle.
  • Looping through if/else structure and checking if a particular flag or enum is set and then making further exceptions about what to do when certain combinations of your flags and enums exist or don’t exist together.

I can think of a few examples:

  • Bots. This is usually a great case for a state machine, because a bot usually has only a few states with different actions in between. For example, you have a bot who’s asking questions to book a hotel (a well-known example). You ask a few questions: location,  number of guests, price range etc. Every question is a state. Every answer is an event that allows to transition into the next state.
  • IOT. The simplest state machine has two states: ON and OFF. But with more complex devices than, say, a light switch, there can be more states in between, and more events to make a state transition.

There’s much more that the Spring State Machine can do. For example, the states can be nested. Also, there’s guards that can be configured to check if the transition is allowed or not, and pseudo states that allow to define choice state, junction state etc. The events can be triggered by the actions, or on a timer. The state machines can be persisted to make them more performant. To navigate all that, you need to study Spring State Machine documentation and determine what of it fits your specific case. Here, we only very lightly scratched the surface.

You can watch a video about Spring State Machine, or study the full specification to find out more about the topic.

The project sources for this article can be found here.

Published on Java Code Geeks with permission by Maryna Cherniavska, partner at our JCG program. See the original article here: Spring State Machine: what is it and do you need it?

Opinions expressed by Java Code Geeks contributors are their own.

Maryna Cherniavska

Maryna has productively spent 10+ years in IT industry, designing, developing, building and deploying desktop and web applications, designing database structures and otherwise proving that females have a place among software developers. And this is a good place.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Inline Feedbacks
View all comments
Back to top button