├── .gitignore ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main ├── java │ └── rnd │ │ └── statemachine │ │ ├── AbstractStateTransitionsManager.java │ │ ├── ProcessData.java │ │ ├── ProcessEvent.java │ │ ├── ProcessException.java │ │ ├── ProcessState.java │ │ ├── Processor.java │ │ ├── StateMachineApplication.java │ │ ├── StateTransitionsManager.java │ │ └── order │ │ ├── OrderController.java │ │ ├── OrderData.java │ │ ├── OrderDbService.java │ │ ├── OrderEvent.java │ │ ├── OrderException.java │ │ ├── OrderProcessor.java │ │ ├── OrderState.java │ │ ├── OrderStateTransitionsManager.java │ │ ├── PaymentException.java │ │ └── PaymentProcessor.java └── resources │ └── application.yml └── test └── java └── rnd └── statemachine ├── StateMachineApplicationTests.java └── order ├── MockData.java └── OrderStateTransitionsManagerTest.java /.gitignore: -------------------------------------------------------------------------------- 1 | # eclipse files 2 | .project 3 | .classpath 4 | .settings 5 | bin/ 6 | build-eclipse/ 7 | 8 | # gradle stuff 9 | .gradle/ 10 | build/ 11 | 12 | # VS Code files 13 | .vscode/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # simple-state-machine 2 | 3 | A simple state machine for Spring Boot projects. This project contains a framework and an illustration of the usage of the framework for a sample project like online order processing. 4 | 5 | ## Benefits 6 | 7 | Enables building robust applications,
8 | Simplifies writing unit tests,
9 | Enables adding new processes faster. 10 | 11 | ## Usage Workflow 12 | 13 | |Initial State |Pre-event | Processor | Post-event | Final State | 14 | | --- | --- | --- | --- | --- | 15 | |DEFAULT ->| submit ->| orderProcessor() ->| orderCreated -> |PMTPENDING | 16 | |PMTPENDING -> | pay ->| paymentProcessor() ->| paymentError -> |PMTPENDING | 17 | |PMTPENDING ->| pay ->| paymentProcessor() ->| paymentSuccess ->| COMPLETED | 18 | 19 | 1. To use this framework first create a state transitions table like above. 20 | 21 | 2. Then implement the interfaces ProcessState and ProcessEvent. 22 | See OrderState and OrderEvent classes for examples 23 | 24 | 3. Identify a primary key for the process. For the order process it would be orderId, for a time sheet application it would be userId-week-ending-date etc. 25 | 26 | 4. Implement the StateTransitionsManager. See the OrderStateTransitionsManager class for an example. 27 | 28 | 5. Implement the Processor class. See the OrderProcessor and the PaymentProcessor classes for examples. 29 | 30 | 6. Create a controller class. See the OrderController for an example. 31 | 32 | ### Build 33 | 34 | Run the command ".\gradlew build" at the project root 35 | 36 | ### Unit Testing 37 | 38 | Unit tests can be run using the ".\gradlew test" command at the project root. 39 | 40 | ### Build and Deploy 41 | 42 | Run the command ".\gradlew bootRun" at the prject root. 43 | 44 | ### Integration Testing 45 | 46 | For the order sample considered in this project, the following two APIs are created to test the order process: 47 | 48 | 1. User request to create an order. This API is implemented as GET so it can be tested quickly in the browser. 49 | http://localhost:8080/order << creates an order and returns an orderId. Selected product ids are not included in this demo example >> 50 | 51 | 2. User makes a payment. This API is also implemented as GET so it can be tested quickly in the browser. 52 | http://localhost:8080/order/cart?payment=123&orderId=123 << where orderId is the UUID returned by the first API. Payment value less than 1.00 is considered for the error transition >> 53 | 54 | << for quick testing in a browser both of the above are implemented as GET APIs >> 55 | When the above APIs are called the console log displays the state transitions that reflect the above table. (Note: payment=0 is used to mock payment error in this example) 56 | 57 | ### Related Projects 58 | 59 | A refactored versin of this exists in the [branch](https://github.com/mapteb/simple-state-machine/tree/refactor-1). 60 | 61 | ### Also See 62 | 63 | Spring Framework's [State Machine libray](https://docs.spring.io/spring-statemachine/docs/current/reference/) 64 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'org.springframework.boot' version '2.1.4.RELEASE' 3 | id 'java' 4 | } 5 | 6 | apply plugin: 'io.spring.dependency-management' 7 | 8 | group = 'rnd.statemachine' 9 | version = '0.0.2-SNAPSHOT' 10 | sourceCompatibility = '11' 11 | 12 | configurations { 13 | compileOnly { 14 | extendsFrom annotationProcessor 15 | } 16 | } 17 | 18 | repositories { 19 | mavenCentral() 20 | } 21 | 22 | dependencies { 23 | implementation 'org.springframework.boot:spring-boot-starter-web' 24 | compileOnly 'org.projectlombok:lombok' 25 | annotationProcessor 'org.projectlombok:lombok' 26 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 27 | } 28 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapteb/simple-state-machine/60eb26798e0d2985b2e2ef90b46715b4213ab596/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Nov 06 13:40:33 EST 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.1-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS='"-Xmx64m"' 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | } 5 | } 6 | rootProject.name = 'simple-state-machine' 7 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/AbstractStateTransitionsManager.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine; 2 | 3 | public abstract class AbstractStateTransitionsManager implements StateTransitionsManager { 4 | protected abstract ProcessData initializeState(ProcessData data) throws ProcessException; 5 | protected abstract ProcessData processStateTransition(ProcessData data) throws ProcessException; 6 | 7 | @Override 8 | public ProcessData processEvent(ProcessData data) throws ProcessException { 9 | initializeState(data); 10 | return processStateTransition(data); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/ProcessData.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine; 2 | 3 | public interface ProcessData { 4 | public ProcessEvent getEvent(); 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/ProcessEvent.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine; 2 | 3 | public interface ProcessEvent { 4 | public abstract Class nextStepProcessor(ProcessEvent event); 5 | public abstract ProcessState nextState(ProcessEvent event); 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/ProcessException.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine; 2 | 3 | public class ProcessException extends Exception { 4 | private static final long serialVersionUID = 1L; 5 | 6 | public ProcessException(String message) { 7 | super(message); 8 | } 9 | 10 | public ProcessException(String message, Throwable e) { 11 | super(message, e); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/ProcessState.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine; 2 | 3 | public interface ProcessState { 4 | } 5 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/Processor.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine; 2 | 3 | public interface Processor { 4 | public ProcessData process(ProcessData data) throws ProcessException; 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/StateMachineApplication.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | /** 7 | * A simple state machine implementation for Spring Boot projects 8 | * Includes a framework and a sample application of the framework 9 | * to an online order process 10 | * 11 | * @author Nalla Senthilnathan https://github.com/mapteb/simple-state-machine 12 | * 13 | */ 14 | @SpringBootApplication 15 | public class StateMachineApplication { 16 | public static void main(String[] args) { 17 | SpringApplication.run(StateMachineApplication.class, args); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/StateTransitionsManager.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine; 2 | 3 | public interface StateTransitionsManager { 4 | public ProcessData processEvent(ProcessData data) throws ProcessException; 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/order/OrderController.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine.order; 2 | 3 | import java.util.UUID; 4 | 5 | import org.springframework.web.bind.annotation.ExceptionHandler; 6 | import org.springframework.web.bind.annotation.GetMapping; 7 | import org.springframework.web.bind.annotation.RequestParam; 8 | import org.springframework.web.bind.annotation.RestController; 9 | 10 | import lombok.RequiredArgsConstructor; 11 | import rnd.statemachine.ProcessException; 12 | 13 | @RequiredArgsConstructor 14 | @RestController 15 | public class OrderController { 16 | private final OrderStateTransitionsManager stateTransitionsManager; 17 | 18 | @GetMapping("/order/cart") 19 | public String handleOrderPayment( 20 | @RequestParam double payment, 21 | @RequestParam UUID orderId) throws Exception { 22 | 23 | OrderData data = new OrderData(); 24 | data.setPayment(payment); 25 | data.setOrderId(orderId); 26 | data.setEvent(OrderEvent.pay); 27 | data = (OrderData) stateTransitionsManager.processEvent(data); 28 | 29 | return ((OrderEvent)data.getEvent()).name(); 30 | } 31 | 32 | @ExceptionHandler(value=OrderException.class) 33 | public String handleOrderException(OrderException e) { 34 | return e.getMessage(); 35 | } 36 | 37 | @GetMapping("/order") 38 | public String handleOrderSubmit() throws ProcessException { 39 | 40 | OrderData data = new OrderData(); 41 | data.setEvent(OrderEvent.submit); 42 | data = (OrderData) stateTransitionsManager.processEvent(data); 43 | 44 | return ((OrderEvent)data.getEvent()).name() + ", orderId = " + data.getOrderId(); 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/order/OrderData.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine.order; 2 | 3 | import java.util.UUID; 4 | 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Getter; 8 | import lombok.NoArgsConstructor; 9 | import lombok.Setter; 10 | import rnd.statemachine.ProcessData; 11 | import rnd.statemachine.ProcessEvent; 12 | 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | @Setter @Getter 16 | @Builder 17 | public class OrderData implements ProcessData { 18 | private double payment; 19 | private ProcessEvent event; 20 | private UUID orderId; 21 | @Override 22 | public ProcessEvent getEvent() { 23 | return this.event; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/order/OrderDbService.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine.order; 2 | 3 | import java.util.UUID; 4 | import java.util.concurrent.ConcurrentHashMap; 5 | 6 | import org.springframework.stereotype.Service; 7 | 8 | @Service 9 | public class OrderDbService { 10 | 11 | private final ConcurrentHashMap states; 12 | 13 | public OrderDbService() { 14 | this.states = new ConcurrentHashMap(); 15 | } 16 | 17 | public ConcurrentHashMap getStates() { 18 | return states; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/order/OrderEvent.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine.order; 2 | 3 | import rnd.statemachine.ProcessState; 4 | import rnd.statemachine.ProcessEvent; 5 | import rnd.statemachine.Processor; 6 | 7 | /** 8 | * DEFAULT - submit -> orderProcessor() -> orderCreated -> PMTPENDING 9 | * PMTPENDING - pay -> paymentProcessor() -> paymentError -> PMTPENDING 10 | * PMTPENDING - pay -> paymentProcessor() -> paymentSuccess -> COMPLETED 11 | */ 12 | public enum OrderEvent implements ProcessEvent { 13 | 14 | submit { 15 | @Override 16 | public Class nextStepProcessor(ProcessEvent event) { 17 | return OrderProcessor.class; 18 | } 19 | 20 | /** 21 | * This event has no effect on state so return current state 22 | */ 23 | @Override 24 | public ProcessState nextState(ProcessEvent event) { 25 | return OrderState.Default; 26 | } 27 | 28 | }, 29 | orderCreated { 30 | /** 31 | * This event does not trigger any process 32 | * So return null 33 | */ 34 | @Override 35 | public Class nextStepProcessor(ProcessEvent event) { 36 | return null; 37 | } 38 | 39 | @Override 40 | public ProcessState nextState(ProcessEvent event) { 41 | return OrderState.PaymentPending; 42 | } 43 | 44 | }, 45 | pay { 46 | @Override 47 | public Class nextStepProcessor(ProcessEvent event) { 48 | return PaymentProcessor.class; 49 | } 50 | 51 | /** 52 | * This event has no effect on state so return current state 53 | */ 54 | @Override 55 | public ProcessState nextState(ProcessEvent event) { 56 | return OrderState.PaymentPending; 57 | } 58 | }, 59 | paymentSuccess { 60 | /** 61 | * This event does not trigger any process 62 | * So return null 63 | */ 64 | @Override 65 | public Class nextStepProcessor(ProcessEvent event) { 66 | return null; 67 | } 68 | @Override 69 | public ProcessState nextState(ProcessEvent event) { 70 | return OrderState.Completed; 71 | } 72 | }, 73 | paymentError { 74 | /** 75 | * This event does not trigger any process 76 | * So return null 77 | */ 78 | @Override 79 | public Class nextStepProcessor(ProcessEvent event) { 80 | return null; 81 | } 82 | 83 | @Override 84 | public ProcessState nextState(ProcessEvent event) { 85 | return OrderState.PaymentPending; 86 | } 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/order/OrderException.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine.order; 2 | 3 | import rnd.statemachine.ProcessException; 4 | 5 | public class OrderException extends ProcessException { 6 | 7 | private static final long serialVersionUID = 5587859227419203629L; 8 | 9 | public OrderException(String message) { 10 | super(message); 11 | } 12 | 13 | public OrderException(String message, Throwable e) { 14 | super(message, e); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/order/OrderProcessor.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine.order; 2 | 3 | import org.springframework.stereotype.Service; 4 | 5 | import rnd.statemachine.ProcessData; 6 | import rnd.statemachine.Processor; 7 | import rnd.statemachine.ProcessException; 8 | 9 | @Service 10 | public class OrderProcessor implements Processor { 11 | @Override 12 | public ProcessData process(ProcessData data) throws ProcessException{ 13 | ((OrderData)data).setEvent(OrderEvent.orderCreated); 14 | return data; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/order/OrderState.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine.order; 2 | 3 | import rnd.statemachine.ProcessState; 4 | 5 | /** 6 | * DEFAULT - submit -> orderProcessor() -> orderCreated -> PMTPENDING 7 | * PMTPENDING - pay -> paymentProcessor() -> paymentError -> PMTPENDING 8 | * PMTPENDING - pay -> paymentProcessor() -> paymentSuccess -> COMPLETED 9 | */ 10 | public enum OrderState implements ProcessState { 11 | Default, 12 | PaymentPending, 13 | Completed; 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/order/OrderStateTransitionsManager.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine.order; 2 | 3 | import java.util.UUID; 4 | import java.util.concurrent.ConcurrentHashMap; 5 | 6 | import org.springframework.context.ApplicationContext; 7 | import org.springframework.stereotype.Service; 8 | 9 | import lombok.RequiredArgsConstructor; 10 | import lombok.extern.slf4j.Slf4j; 11 | import rnd.statemachine.AbstractStateTransitionsManager; 12 | import rnd.statemachine.ProcessData; 13 | import rnd.statemachine.ProcessException; 14 | 15 | /** 16 | * This class manages various state transitions 17 | * based on the event 18 | * The superclass AbstractStateTransitionsManager 19 | * calls the two methods initializeState and 20 | * processStateTransition in that order 21 | */ 22 | @RequiredArgsConstructor 23 | @Slf4j 24 | @Service 25 | public class OrderStateTransitionsManager extends AbstractStateTransitionsManager { 26 | 27 | private final ApplicationContext context; 28 | private final OrderDbService dbService; 29 | 30 | @Override 31 | protected ProcessData processStateTransition(ProcessData sdata) throws ProcessException { 32 | 33 | OrderData data = (OrderData) sdata; 34 | 35 | try { 36 | log.info("Pre-event: " + data.getEvent().toString()); 37 | data = (OrderData) this.context.getBean(data.getEvent().nextStepProcessor(data.getEvent())).process(data); 38 | log.info("Post-event: " + data.getEvent().toString()); 39 | dbService.getStates().put(data.getOrderId(), (OrderState)data.getEvent().nextState(data.getEvent())); 40 | log.info("Final state: " + dbService.getStates().get(data.getOrderId()).name()); 41 | log.info("??*************************************"); 42 | 43 | } catch (OrderException e) { 44 | log.info("Post-event: " + ((OrderEvent) data.getEvent()).name()); 45 | dbService.getStates().put(data.getOrderId(), (OrderState)data.getEvent().nextState(data.getEvent())); 46 | log.info("Final state: " + dbService.getStates().get(data.getOrderId()).name()); 47 | log.info("??*************************************"); 48 | throw new OrderException(((OrderEvent) data.getEvent()).name(), e); 49 | } 50 | return data; 51 | } 52 | 53 | private OrderData checkStateForReturningCustomers(OrderData data) throws OrderException { 54 | // returning customers must have a state 55 | if (data.getOrderId() != null) { 56 | if (this.dbService.getStates().get(data.getOrderId()) == null) { 57 | throw new OrderException("No state exists for orderId=" + data.getOrderId()); 58 | } else if (this.dbService.getStates().get(data.getOrderId()) == OrderState.Completed) { 59 | throw new OrderException("Order is completed for orderId=" + data.getOrderId()); 60 | } else { 61 | log.info("Initial state: " + dbService.getStates().get(data.getOrderId()).name()); 62 | } 63 | } 64 | return data; 65 | } 66 | 67 | @Override 68 | protected ProcessData initializeState(ProcessData sdata) throws OrderException { 69 | 70 | OrderData data = (OrderData) sdata; 71 | 72 | if (data.getOrderId() != null) { 73 | return checkStateForReturningCustomers(data); 74 | } 75 | 76 | UUID orderId = UUID.randomUUID(); 77 | data.setOrderId(orderId); 78 | dbService.getStates().put(orderId, (OrderState) OrderState.Default); 79 | 80 | log.info("Initial state: " + dbService.getStates().get(data.getOrderId()).name()); 81 | return data; 82 | } 83 | 84 | public ConcurrentHashMap getStates() { 85 | return dbService.getStates(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/order/PaymentException.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine.order; 2 | 3 | public class PaymentException extends OrderException { 4 | 5 | private static final long serialVersionUID = -4582470401926451120L; 6 | 7 | public PaymentException(String message) { 8 | super(message); 9 | } 10 | 11 | public PaymentException(String message, Throwable e) { 12 | super(message, e); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/rnd/statemachine/order/PaymentProcessor.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine.order; 2 | 3 | import org.springframework.stereotype.Service; 4 | 5 | import rnd.statemachine.ProcessData; 6 | import rnd.statemachine.ProcessException; 7 | import rnd.statemachine.Processor; 8 | 9 | @Service 10 | public class PaymentProcessor implements Processor { 11 | @Override 12 | public ProcessData process(ProcessData data) throws ProcessException { 13 | if(((OrderData)data).getPayment() < 1.00) { 14 | ((OrderData)data).setEvent(OrderEvent.paymentError); 15 | throw new PaymentException(OrderEvent.paymentError.name()); 16 | } else { 17 | ((OrderData)data).setEvent(OrderEvent.paymentSuccess); 18 | } 19 | return data; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/test/java/rnd/statemachine/StateMachineApplicationTests.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine; 2 | 3 | import org.junit.Test; 4 | import org.junit.runner.RunWith; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | import org.springframework.test.context.junit4.SpringRunner; 7 | 8 | import rnd.statemachine.order.PaymentProcessor; 9 | import rnd.statemachine.order.OrderProcessor; 10 | 11 | @RunWith(SpringRunner.class) 12 | @SpringBootTest(classes= {PaymentProcessor.class,OrderProcessor.class}) 13 | public class StateMachineApplicationTests { 14 | 15 | @Test 16 | public void contextLoads() { 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/test/java/rnd/statemachine/order/MockData.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine.order; 2 | 3 | import java.util.UUID; 4 | 5 | class MockData { 6 | 7 | static final UUID orderId = UUID.fromString("cacb4fd3-0139-4402-8ad7-9e8c5aba368a"); 8 | static final UUID unknownOrderId = UUID.fromString("cacb4fd3-0139-4402-8ad7-9e8c5aba368b"); 9 | static final String illegalStateMessage = "Unknown orderId"; 10 | 11 | static OrderData SubmitSuccessData() { 12 | return OrderData.builder() 13 | .orderId(orderId) 14 | .event(OrderEvent.orderCreated) 15 | .build(); 16 | } 17 | 18 | static OrderData paymentSuccessData() { 19 | return OrderData.builder() 20 | .orderId(orderId) 21 | .event(OrderEvent.paymentSuccess) 22 | .build(); 23 | } 24 | 25 | static OrderData paymentErrorData() { 26 | return OrderData.builder() 27 | .event(OrderEvent.paymentError) 28 | .orderId(orderId) 29 | .build(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/rnd/statemachine/order/OrderStateTransitionsManagerTest.java: -------------------------------------------------------------------------------- 1 | package rnd.statemachine.order; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.mockito.ArgumentMatchers.any; 5 | import static org.mockito.Mockito.when; 6 | 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.mock.mockito.MockBean; 11 | import org.springframework.context.ApplicationContext; 12 | import org.springframework.test.context.junit4.SpringRunner; 13 | 14 | @RunWith(SpringRunner.class) 15 | public class OrderStateTransitionsManagerTest { 16 | 17 | @Autowired 18 | private ApplicationContext context; 19 | 20 | @MockBean 21 | private OrderProcessor orderProcessor; 22 | 23 | 24 | private OrderDbService dbService = new OrderDbService(); 25 | 26 | @MockBean 27 | PaymentProcessor paymentProcessor; 28 | 29 | private OrderStateTransitionsManager transitionsManager; 30 | 31 | @Test 32 | public void givenOrderSubmit_whenOrderCreated_thenAssertPaymentPendingState() throws Exception { 33 | OrderData data = OrderData.builder().event(OrderEvent.submit).build(); 34 | 35 | transitionsManager = new OrderStateTransitionsManager(context, dbService); 36 | when(orderProcessor.process(any())).thenReturn(MockData.SubmitSuccessData()); 37 | data = (OrderData)transitionsManager.processEvent(data); 38 | 39 | assertThat(transitionsManager.getStates().get(data.getOrderId())).isEqualTo(OrderState.PaymentPending); 40 | } 41 | 42 | @Test 43 | public void givenOrderPay_whenPaymentEror_thenAssertPaymentPendingState() throws Exception { 44 | OrderData data = OrderData.builder() 45 | .orderId(MockData.orderId) 46 | .event(OrderEvent.pay).payment(0.00).build(); 47 | dbService.getStates().put(MockData.orderId, OrderState.PaymentPending); 48 | transitionsManager = new OrderStateTransitionsManager(context, dbService); 49 | when(paymentProcessor.process(any())).thenReturn(MockData.paymentErrorData()); 50 | data = (OrderData)transitionsManager.processEvent(data); 51 | 52 | assertThat(transitionsManager.getStates().get(data.getOrderId())).isEqualTo(OrderState.PaymentPending); 53 | } 54 | 55 | @Test 56 | public void givenOrderPay_whenPaymentSuccess_thenAssertCreatedState() throws Exception { 57 | OrderData data = OrderData.builder() 58 | .orderId(MockData.orderId) 59 | .event(OrderEvent.pay).payment(1.00).build(); 60 | 61 | dbService.getStates().put(MockData.orderId, OrderState.PaymentPending); 62 | transitionsManager = new OrderStateTransitionsManager(context, dbService); 63 | 64 | when(paymentProcessor.process(any())).thenReturn(MockData.paymentSuccessData()); 65 | data = (OrderData)transitionsManager.processEvent(data); 66 | 67 | assertThat(transitionsManager.getStates().get(data.getOrderId())).isEqualTo(OrderState.Completed); 68 | } 69 | 70 | @Test(expected = OrderException.class) 71 | public void givenOrderPayWithUnknownOrderId_thenAssertOrderExceptionIsThrown() throws Exception { 72 | OrderData data = OrderData.builder() 73 | .event(OrderEvent.pay) 74 | .orderId(MockData.unknownOrderId) 75 | .payment(0.00) 76 | .build(); 77 | 78 | transitionsManager = new OrderStateTransitionsManager(context, dbService); 79 | data = (OrderData)transitionsManager.processEvent(data); 80 | } 81 | } 82 | --------------------------------------------------------------------------------