├── .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 extends Processor> 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 extends Processor> 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 extends Processor> 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 extends Processor> 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 extends Processor> 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 extends Processor> 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 |
--------------------------------------------------------------------------------