├── .gitignore ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── h2 ├── h2-1.4.196.jar ├── h2.bat ├── h2.sh └── h2w.bat └── src ├── main ├── kotlin │ └── dk │ │ └── lldata │ │ └── axon │ │ └── banking │ │ ├── BankingApplication.kt │ │ ├── account │ │ └── AccountAggregate.kt │ │ ├── balance │ │ ├── AccountBalance.kt │ │ └── TransactionHistory.kt │ │ ├── coreapi │ │ ├── CoreApi.kt │ │ └── Id.kt │ │ ├── rest │ │ └── TestRestService.kt │ │ └── transfer │ │ ├── LoggingEventHandler.kt │ │ ├── MoneyTransfer.kt │ │ └── MoneyTransferSaga.kt └── resources │ ├── application-h2.properties │ └── application.properties └── test └── kotlin └── dk └── lldata └── axon └── banking ├── account ├── AccountIntegrationTest.kt └── AccountTest.kt └── transfer └── MoneyTransferSagaTest.kt /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | 5 | ### STS ### 6 | .apt_generated 7 | .classpath 8 | .factorypath 9 | .project 10 | .settings 11 | .springBeans 12 | 13 | ### IntelliJ IDEA ### 14 | .idea 15 | *.iws 16 | *.iml 17 | *.ipr 18 | 19 | ### NetBeans ### 20 | nbproject/private/ 21 | build/ 22 | nbbuild/ 23 | dist/ 24 | nbdist/ 25 | .nb-gradle/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Axon Banking 2 | --- 3 | My attempt at following the Axon Banking live coding tutorial found at youtube. 4 | 5 | To make it more of a challenge I used Kotlin with Spring Boot 2.0 and Gradle build. 6 | The tutorial uses Java with a little bit of Kotlin, an older Spring Boot and maven rather than Gradle. 7 | 8 | 9 | To run: 10 | * In memory db: start BankingApplication.kt 11 | * Persistent db: 12 | * start h2 (with script h2 folder) 13 | * start BankingApplication.kt with -Dspring.profiles.active=h2 14 | 15 | TODO 16 | * Make more Kotlin idiomatic 17 | * Add more tests 18 | 19 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | kotlinVersion = '1.2.41' 4 | springBootVersion = '2.0.2.RELEASE' 5 | } 6 | repositories { 7 | mavenCentral() 8 | maven { url "https://repo.spring.io/snapshot" } 9 | maven { url "https://repo.spring.io/milestone" } 10 | } 11 | dependencies { 12 | classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") 13 | classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}") 14 | classpath("org.jetbrains.kotlin:kotlin-allopen:${kotlinVersion}") 15 | classpath("org.jetbrains.kotlin:kotlin-noarg:${kotlinVersion}") 16 | } 17 | } 18 | 19 | apply plugin: 'kotlin' 20 | apply plugin: 'kotlin-spring' 21 | apply plugin: 'eclipse' 22 | apply plugin: 'org.springframework.boot' 23 | apply plugin: 'io.spring.dependency-management' 24 | 25 | group = 'dk.lldata.axon' 26 | version = '0.0.1-SNAPSHOT' 27 | sourceCompatibility = 1.8 28 | 29 | compileKotlin { 30 | kotlinOptions.jvmTarget = "1.8" 31 | } 32 | compileTestKotlin { 33 | kotlinOptions.jvmTarget = "1.8" 34 | } 35 | 36 | repositories { 37 | mavenCentral() 38 | maven { url "https://repo.spring.io/snapshot" } 39 | maven { url "https://repo.spring.io/milestone" } 40 | } 41 | 42 | dependencies { 43 | compile("org.springframework.boot:spring-boot-starter-data-jpa") 44 | compile("org.springframework.boot:spring-boot-starter-web") 45 | compile("org.jetbrains.kotlin:kotlin-stdlib-jre8") 46 | compile("org.jetbrains.kotlin:kotlin-reflect") 47 | compile("io.github.microutils:kotlin-logging:1.4.6") 48 | runtime("com.h2database:h2") 49 | compile("org.axonframework:axon-spring-boot-autoconfigure:3.2.1") 50 | testCompile("org.springframework.boot:spring-boot-starter-test") 51 | testCompile("org.axonframework:axon-test:3.2.1") 52 | } 53 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BitzForge/kotlin-banking/d36d027a5bc23827776a3e49a563d5080645a8f5/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Nov 19 11:27:17 CET 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.2-all.zip 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="" 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= 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 | -------------------------------------------------------------------------------- /h2/h2-1.4.196.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BitzForge/kotlin-banking/d36d027a5bc23827776a3e49a563d5080645a8f5/h2/h2-1.4.196.jar -------------------------------------------------------------------------------- /h2/h2.bat: -------------------------------------------------------------------------------- 1 | @java -cp "h2-1.4.196.jar;%H2DRIVERS%;%CLASSPATH%" org.h2.tools.Console %* 2 | @if errorlevel 1 pause -------------------------------------------------------------------------------- /h2/h2.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | dir=$(dirname "$0") 3 | java -cp "$dir/h2-1.4.196.jar:$H2DRIVERS:$CLASSPATH" org.h2.tools.Console "$@" 4 | -------------------------------------------------------------------------------- /h2/h2w.bat: -------------------------------------------------------------------------------- 1 | @start javaw -cp "h2-1.4.196.jar;%H2DRIVERS%;%CLASSPATH%" org.h2.tools.Console %* 2 | @if errorlevel 1 pause -------------------------------------------------------------------------------- /src/main/kotlin/dk/lldata/axon/banking/BankingApplication.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking 2 | 3 | import mu.KotlinLogging 4 | import org.axonframework.config.EventHandlingConfiguration 5 | import org.springframework.beans.factory.annotation.Autowired 6 | import org.springframework.boot.autoconfigure.SpringBootApplication 7 | import org.springframework.boot.runApplication 8 | 9 | private val logger = KotlinLogging.logger {} 10 | 11 | @SpringBootApplication 12 | class BankingApplication { 13 | @Autowired 14 | fun registerTracking(cfg: EventHandlingConfiguration) { 15 | logger.info("<< TrackingProcessingGroup registered") 16 | cfg.registerTrackingProcessor("TrackingProcessingGroup") 17 | } 18 | } 19 | 20 | fun main(args: Array) { 21 | val cfg = runApplication(*args) 22 | //val commandBus = cfg.getBean(CommandBus::class.java) 23 | //commandBus.dispatch(asCommandMessage(CreateAccountCommand("a1", 1000)), LoggingCallback.INSTANCE) 24 | //commandBus.dispatch(asCommandMessage(CreateAccountCommand("a2", 1000)), LoggingCallback.INSTANCE) 25 | //commandBus.dispatch(asCommandMessage(RequestMoneyTransferCommand(ID.uuid(), "a1", "a2", 4000)), LoggingCallback.INSTANCE) 26 | } 27 | 28 | -------------------------------------------------------------------------------- /src/main/kotlin/dk/lldata/axon/banking/account/AccountAggregate.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking.account 2 | 3 | import dk.lldata.axon.banking.coreapi.* 4 | import mu.KotlinLogging 5 | import org.axonframework.commandhandling.CommandHandler 6 | import org.axonframework.commandhandling.model.AggregateIdentifier 7 | import org.axonframework.commandhandling.model.AggregateLifecycle 8 | import org.axonframework.eventsourcing.EventSourcingHandler 9 | import org.axonframework.spring.stereotype.Aggregate 10 | 11 | private val logger = KotlinLogging.logger {} 12 | 13 | @Aggregate 14 | class Account { 15 | constructor() {} 16 | 17 | @AggregateIdentifier 18 | var accountId: String? = null 19 | 20 | var balance: Int = 0 21 | var overdraftLimit: Int = 0 22 | 23 | @CommandHandler 24 | constructor(cmd: CreateAccountCommand) { 25 | AggregateLifecycle.apply(AccountCreatedEvent(cmd.accountId, cmd.accountId, cmd.overdraftLimit, 0)) 26 | } 27 | 28 | @CommandHandler 29 | fun handle(cmd: WithdrawMoneyCommand) { 30 | if (balance + overdraftLimit >= cmd.amount) { 31 | logger.info("Money withdraw accepted account={} balance={}", accountId, balance) 32 | AggregateLifecycle.apply(MoneyWithdrawnEvent(cmd.accountId, cmd.txId, cmd.amount, balance - cmd.amount)) 33 | } else { 34 | logger.info("Money withdraw denied account={} balance={}", accountId, balance) 35 | throw OverdraftLimitExceeded() 36 | } 37 | } 38 | 39 | @CommandHandler 40 | fun handle(cmd: DepositMoneyCommand) { 41 | AggregateLifecycle.apply(MoneyDepositedEvent(cmd.accountId, cmd.txId, cmd.amount, balance + cmd.amount)) 42 | } 43 | 44 | @EventSourcingHandler 45 | fun on(event: AccountCreatedEvent) { 46 | this.accountId = event.accountId 47 | this.overdraftLimit = event.overdraftLimit 48 | } 49 | 50 | @EventSourcingHandler 51 | fun on(event: MoneyWithdrawnEvent) { 52 | this.balance = event.balance 53 | logger.info("Money deposited account={} balance={}", accountId, balance) 54 | } 55 | 56 | @EventSourcingHandler 57 | fun on(event: MoneyDepositedEvent) { 58 | this.balance = event.balance 59 | logger.info("Money deposited account={} balance={}", accountId, balance) 60 | } 61 | } 62 | 63 | class OverdraftLimitExceeded : RuntimeException() -------------------------------------------------------------------------------- /src/main/kotlin/dk/lldata/axon/banking/balance/AccountBalance.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking.balance 2 | 3 | import dk.lldata.axon.banking.coreapi.BalanceUpdatedEvent 4 | import org.axonframework.eventhandling.EventHandler 5 | import org.springframework.data.jpa.repository.JpaRepository 6 | import org.springframework.stereotype.Component 7 | import javax.persistence.Basic 8 | import javax.persistence.Entity 9 | import javax.persistence.Id 10 | 11 | @Entity 12 | class AccountBalance( 13 | @Id var accountId: String = "", 14 | @Basic var balance: Int = 0 15 | ) { 16 | } 17 | 18 | @Component 19 | class AccountBalanceEventHandler(val repo: AccountBalanceRepository) { 20 | 21 | @EventHandler 22 | fun on(event: BalanceUpdatedEvent) { 23 | repo.save(AccountBalance(event.accountId, event.balance)) 24 | } 25 | } 26 | 27 | interface AccountBalanceRepository : JpaRepository -------------------------------------------------------------------------------- /src/main/kotlin/dk/lldata/axon/banking/balance/TransactionHistory.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking.balance 2 | 3 | import dk.lldata.axon.banking.coreapi.BalanceUpdatedEvent 4 | import org.axonframework.config.ProcessingGroup 5 | import org.axonframework.eventhandling.EventHandler 6 | import org.springframework.data.jpa.repository.JpaRepository 7 | import org.springframework.web.bind.annotation.GetMapping 8 | import org.springframework.web.bind.annotation.PathVariable 9 | import org.springframework.web.bind.annotation.RestController 10 | import javax.persistence.Basic 11 | import javax.persistence.Entity 12 | import javax.persistence.GeneratedValue 13 | import javax.persistence.Id 14 | 15 | @ProcessingGroup("TrackingProcessingGroup") 16 | class TransactionHistoryEventHandler( 17 | val repo: TransactionHistoryRepository 18 | ) { 19 | @EventHandler 20 | fun on(event: BalanceUpdatedEvent) { 21 | repo.save(TransactionHistory(event.accountId, event.balance, event.txId)) 22 | } 23 | } 24 | 25 | @RestController 26 | class TransactionHistoryRestController( 27 | val repo: TransactionHistoryRepository 28 | ) { 29 | @GetMapping("/history/{accountId}") 30 | fun history(@PathVariable accountId: String): List { 31 | return repo.findByAccountId(accountId) 32 | } 33 | } 34 | 35 | @Entity 36 | class TransactionHistory( 37 | @Basic var accountId: String = "", 38 | @Basic var balance: Int = 0, 39 | @Basic var txId: String = "", 40 | @GeneratedValue @Id var id: Long = 0 41 | ) { 42 | override fun toString(): String { 43 | return "tx[id=$id, account=$accountId, tx=$txId, balance=$balance]" 44 | } 45 | } 46 | 47 | interface TransactionHistoryRepository { 48 | fun findByAccountId(accountId: String): List 49 | fun save(history: TransactionHistory) 50 | } 51 | 52 | interface TransactionHistoryJpaRepository : TransactionHistoryRepository, JpaRepository -------------------------------------------------------------------------------- /src/main/kotlin/dk/lldata/axon/banking/coreapi/CoreApi.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking.coreapi 2 | 3 | import org.axonframework.commandhandling.TargetAggregateIdentifier 4 | 5 | // Account 6 | class CreateAccountCommand(val accountId: String, val overdraftLimit: Int) 7 | 8 | class WithdrawMoneyCommand(@TargetAggregateIdentifier val accountId: String, val txId: String, val amount: Int) 9 | class DepositMoneyCommand(@TargetAggregateIdentifier val accountId: String, val txId: String, val amount: Int) 10 | 11 | abstract class BalanceUpdatedEvent(val accountId: String, val txId: String, val balance: Int) 12 | class AccountCreatedEvent(accountId: String, txId: String, val overdraftLimit: Int, balance: Int) : BalanceUpdatedEvent(accountId, txId, balance) 13 | class MoneyWithdrawnEvent(accountId: String, txId: String, val amount: Int, balance: Int) : BalanceUpdatedEvent(accountId, txId, balance) 14 | class MoneyDepositedEvent(accountId: String, txId: String, val amount: Int, balance: Int) : BalanceUpdatedEvent(accountId, txId, balance) 15 | 16 | // MoneyTransfer 17 | class RequestMoneyTransferCommand(val transferId: String, val sourceAccountId: String, val targetAccountId: String, val amount: Int) 18 | 19 | class CompleteMoneyTransferCommand(@TargetAggregateIdentifier val transferId: String) 20 | class CancelMoneyTransferCommand(@TargetAggregateIdentifier val transferId: String) 21 | 22 | class MoneyTransferRequestedEvent(val transferId: String, val sourceAccountId: String, val targetAccountId: String, val amount: Int) 23 | class MoneyTransferCompletedEvent(val transferId: String) 24 | class MoneyTransferCancelledEvent(val transferId: String) 25 | -------------------------------------------------------------------------------- /src/main/kotlin/dk/lldata/axon/banking/coreapi/Id.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking.coreapi 2 | 3 | import java.util.* 4 | 5 | class ID(val id: String) { 6 | 7 | companion object { 8 | private var count: Int = 1 9 | fun random() = random("") 10 | fun random(prefix: String) = (prefix + count++) 11 | 12 | fun uuid(): String = UUID.randomUUID().toString() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/dk/lldata/axon/banking/rest/TestRestService.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking.rest 2 | 3 | import dk.lldata.axon.banking.balance.AccountBalanceRepository 4 | import dk.lldata.axon.banking.coreapi.CreateAccountCommand 5 | import dk.lldata.axon.banking.coreapi.ID 6 | import dk.lldata.axon.banking.coreapi.RequestMoneyTransferCommand 7 | import org.axonframework.commandhandling.gateway.CommandGateway 8 | import org.springframework.web.bind.annotation.PathVariable 9 | import org.springframework.web.bind.annotation.RequestMapping 10 | import org.springframework.web.bind.annotation.RequestMethod 11 | import org.springframework.web.bind.annotation.RestController 12 | 13 | @RestController 14 | @RequestMapping(path = ["/test"]) 15 | class TestRestService( 16 | val commandGateway: CommandGateway, 17 | val accountBalanceRepository: AccountBalanceRepository 18 | ) { 19 | 20 | @RequestMapping(path = ["/create/{accountId}"], method = [RequestMethod.GET]) 21 | fun create(@PathVariable accountId: String) { 22 | commandGateway.send(CreateAccountCommand(accountId, 1000)) 23 | } 24 | 25 | @RequestMapping(path = ["/transfer/{from}/{to}"], method = [RequestMethod.GET]) 26 | fun transfer(@PathVariable from: String, @PathVariable to: String) { 27 | commandGateway.send(RequestMoneyTransferCommand(ID.uuid(), from, to, 1 + (Math.random() * 20).toInt())) 28 | } 29 | 30 | @RequestMapping(path = ["/balance/{accountId}"], method = [RequestMethod.GET]) 31 | fun balance(@PathVariable accountId: String) = accountBalanceRepository.findById(accountId).get() 32 | 33 | @RequestMapping(path = ["/hello"], method = [RequestMethod.GET]) 34 | fun hello() = "OK" 35 | } -------------------------------------------------------------------------------- /src/main/kotlin/dk/lldata/axon/banking/transfer/LoggingEventHandler.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking.transfer 2 | 3 | import mu.KotlinLogging 4 | import org.axonframework.eventsourcing.EventSourcingHandler 5 | import org.springframework.stereotype.Component 6 | 7 | private val logger = KotlinLogging.logger {} 8 | 9 | @Component 10 | class LoggingEventHandler { 11 | @EventSourcingHandler 12 | fun on(event: Any) { 13 | logger.info("event {}", event) 14 | } 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/dk/lldata/axon/banking/transfer/MoneyTransfer.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking.transfer 2 | 3 | import dk.lldata.axon.banking.coreapi.* 4 | import org.axonframework.commandhandling.CommandHandler 5 | import org.axonframework.commandhandling.model.AggregateIdentifier 6 | import org.axonframework.commandhandling.model.AggregateLifecycle.apply 7 | import org.axonframework.commandhandling.model.AggregateLifecycle.markDeleted 8 | import org.axonframework.eventsourcing.EventSourcingHandler 9 | import org.axonframework.spring.stereotype.Aggregate 10 | 11 | @Aggregate 12 | class MoneyTransfer { 13 | @AggregateIdentifier 14 | var transferId: String? = null 15 | 16 | constructor() {} 17 | 18 | @CommandHandler 19 | constructor(cmd: RequestMoneyTransferCommand) { 20 | apply(MoneyTransferRequestedEvent(cmd.transferId, cmd.sourceAccountId, cmd.targetAccountId, cmd.amount)) 21 | } 22 | 23 | @CommandHandler 24 | fun handle(cmd: CompleteMoneyTransferCommand) { 25 | apply(MoneyTransferCompletedEvent(cmd.transferId)) 26 | } 27 | 28 | @CommandHandler 29 | fun handle(cmd: CancelMoneyTransferCommand) { 30 | apply(MoneyTransferCancelledEvent(cmd.transferId)) 31 | } 32 | 33 | @EventSourcingHandler 34 | fun on(event: MoneyTransferRequestedEvent) { 35 | this.transferId = event.transferId 36 | } 37 | 38 | @EventSourcingHandler 39 | fun on(event: MoneyTransferCompletedEvent) { 40 | markDeleted() 41 | } 42 | 43 | @EventSourcingHandler 44 | fun on(event: MoneyTransferCancelledEvent) { 45 | markDeleted() 46 | } 47 | } -------------------------------------------------------------------------------- /src/main/kotlin/dk/lldata/axon/banking/transfer/MoneyTransferSaga.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking.transfer 2 | 3 | import dk.lldata.axon.banking.coreapi.* 4 | import mu.KotlinLogging 5 | import org.axonframework.commandhandling.gateway.CommandGateway 6 | import org.axonframework.eventhandling.saga.EndSaga 7 | import org.axonframework.eventhandling.saga.SagaEventHandler 8 | import org.axonframework.eventhandling.saga.StartSaga 9 | import org.axonframework.spring.stereotype.Saga 10 | import org.springframework.beans.factory.annotation.Autowired 11 | 12 | private val logger = KotlinLogging.logger {} 13 | 14 | @Saga 15 | class MoneyTransferSaga { 16 | @Autowired 17 | @Transient 18 | var commandGateway: CommandGateway? = null 19 | 20 | private var targetAccount: String? = null 21 | 22 | @StartSaga 23 | @SagaEventHandler(associationProperty = "transferId") 24 | fun on(event: MoneyTransferRequestedEvent) { 25 | this.targetAccount = event.targetAccountId 26 | logger.info("Money transfer requested from={} to={} amount={}", event.sourceAccountId, event.targetAccountId, event.amount) 27 | try { 28 | commandGateway!!.sendAndWait(WithdrawMoneyCommand(event.sourceAccountId, event.transferId, event.amount)) 29 | logger.info("Money withdraw command success tx={}", event.transferId) 30 | } catch (e: Exception) { 31 | commandGateway!!.send(CancelMoneyTransferCommand(event.transferId)) 32 | } 33 | } 34 | 35 | @SagaEventHandler(associationProperty = "txId", keyName = "transferId") 36 | fun on(event: MoneyWithdrawnEvent) { 37 | commandGateway!!.send(DepositMoneyCommand(this.targetAccount!!, event.txId, event.amount)) 38 | } 39 | 40 | @SagaEventHandler(associationProperty = "txId", keyName = "transferId") 41 | fun on(event: MoneyDepositedEvent) { 42 | commandGateway!!.send(CompleteMoneyTransferCommand(event.txId)) 43 | } 44 | 45 | @EndSaga 46 | @SagaEventHandler(associationProperty = "transferId") 47 | fun on(event: MoneyTransferCompletedEvent) { 48 | // alternative to @EndSaga annotation SagaLifecycle.end() 49 | } 50 | 51 | @EndSaga 52 | @SagaEventHandler(associationProperty = "transferId") 53 | fun on(event: MoneyTransferCancelledEvent) { 54 | // alternative to @EndSaga annotation SagaLifecycle.end() 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /src/main/resources/application-h2.properties: -------------------------------------------------------------------------------- 1 | spring.datasource.url=jdbc:h2:tcp://localhost/~/test 2 | spring.jpa.hibernate.ddl-auto=update 3 | # reset db: 4 | # spring.jpa.hibernate.ddl-auto=create-drop -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | logging.level.dk.lldata=DEBUG 2 | logging.level.org.axonframework=DEBUG 3 | logging.level.root=WARN -------------------------------------------------------------------------------- /src/test/kotlin/dk/lldata/axon/banking/account/AccountIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking.account 2 | 3 | import dk.lldata.axon.banking.balance.TransactionHistory 4 | import dk.lldata.axon.banking.balance.TransactionHistoryEventHandler 5 | import dk.lldata.axon.banking.balance.TransactionHistoryRepository 6 | import dk.lldata.axon.banking.balance.TransactionHistoryRestController 7 | import dk.lldata.axon.banking.coreapi.CreateAccountCommand 8 | import dk.lldata.axon.banking.coreapi.DepositMoneyCommand 9 | import org.assertj.core.api.Assertions.assertThat 10 | import org.axonframework.commandhandling.CommandBus 11 | import org.axonframework.commandhandling.GenericCommandMessage 12 | import org.axonframework.config.DefaultConfigurer 13 | import org.axonframework.config.EventHandlingConfiguration 14 | import org.axonframework.eventhandling.PropagatingErrorHandler 15 | import org.axonframework.eventsourcing.eventstore.inmemory.InMemoryEventStorageEngine 16 | import org.junit.Before 17 | import org.junit.Test 18 | 19 | class AccountIntegrationTest { 20 | val transactionHistoryRepository = TransactionHistoryRepositoryStub() 21 | val transactionHistoryEventHandler = TransactionHistoryEventHandler(transactionHistoryRepository) 22 | val transactionHistoryRestController = TransactionHistoryRestController(transactionHistoryRepository) 23 | var commandBus: CommandBus? = null 24 | 25 | @Before 26 | fun setup() { 27 | val eventhandlers = EventHandlingConfiguration() 28 | .configureListenerInvocationErrorHandler({ c -> PropagatingErrorHandler.instance() }) 29 | .registerEventHandler({ c -> transactionHistoryEventHandler }) 30 | 31 | val config = DefaultConfigurer.defaultConfiguration() 32 | .configureEmbeddedEventStore({ c -> InMemoryEventStorageEngine() }) 33 | .configureAggregate(Account::class.java) 34 | .registerModule(eventhandlers) 35 | .buildConfiguration() 36 | config.start() 37 | 38 | this.commandBus = config.commandBus() 39 | } 40 | 41 | @Test 42 | fun accountAndBalance() { 43 | send(CreateAccountCommand("1234", 1000)) 44 | send(DepositMoneyCommand("1234", "tx1", 500)) 45 | 46 | assertThat(transactionHistoryRestController.history("1234")).usingElementComparatorIgnoringFields("id").containsExactly( 47 | TransactionHistory("1234", 0, "1234"), 48 | TransactionHistory("1234", 500, "tx1") 49 | ) 50 | } 51 | 52 | fun send(cmd: Any) { 53 | commandBus!!.dispatch(GenericCommandMessage(cmd)) 54 | } 55 | 56 | } 57 | 58 | class TransactionHistoryRepositoryStub : TransactionHistoryRepository { 59 | var sequence = 1L 60 | val db = linkedMapOf>() 61 | 62 | override fun save(history: TransactionHistory) { 63 | val list = db[history.accountId] 64 | history.id = sequence++ 65 | if (list == null) { 66 | db[history.accountId] = listOf(history) 67 | } else { 68 | db[history.accountId] = list + history 69 | } 70 | } 71 | 72 | override fun findByAccountId(accountId: String): List { 73 | return db.getOrDefault(accountId, listOf()) 74 | } 75 | 76 | } 77 | 78 | -------------------------------------------------------------------------------- /src/test/kotlin/dk/lldata/axon/banking/account/AccountTest.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking.account 2 | 3 | import dk.lldata.axon.banking.coreapi.* 4 | import org.axonframework.test.aggregate.AggregateTestFixture 5 | import org.junit.Test 6 | 7 | 8 | //import org.axonframework.test.Fixtures; 9 | 10 | class AccountTest { 11 | var fixture = AggregateTestFixture(Account::class.java) 12 | 13 | @Test 14 | fun createAccount() { 15 | fixture.givenNoPriorActivity() 16 | .`when`(CreateAccountCommand("1234", 1000)) 17 | .expectEvents(AccountCreatedEvent("1234", "1234", 1000, 0)) 18 | } 19 | 20 | @Test 21 | fun withDrawReasonableAmount() { 22 | fixture.given(AccountCreatedEvent("1234", ID.uuid(), 1000, 0)) 23 | .`when`(WithdrawMoneyCommand("1234", "tx1", 600)) 24 | .expectEvents(MoneyWithdrawnEvent("1234", "tx1", 600, -600)) 25 | } 26 | 27 | @Test 28 | fun withDrawLargeAmount() { 29 | fixture.given(AccountCreatedEvent("1234", ID.uuid(), 1000, 0)) 30 | .`when`(WithdrawMoneyCommand("1234", "tx1",1001)) 31 | .expectNoEvents() 32 | .expectException(OverdraftLimitExceeded::class.java) 33 | } 34 | 35 | @Test 36 | fun withdrawTwice() { 37 | fixture.given( 38 | AccountCreatedEvent("1234", ID.uuid(), 1000, 0), 39 | MoneyWithdrawnEvent("1234", "tx1", 999, -999) 40 | ) 41 | .`when`(WithdrawMoneyCommand("1234", "tx1", 2)) 42 | .expectNoEvents() 43 | .expectException(OverdraftLimitExceeded::class.java) 44 | } 45 | 46 | @Test 47 | fun deposit() { 48 | fixture.given(AccountCreatedEvent("1234", ID.uuid(), 1000, 0)) 49 | .`when`(DepositMoneyCommand("1234", "tx1", 500)) 50 | .expectEvents(MoneyDepositedEvent("1234","tx1",500, 500)) 51 | } 52 | } -------------------------------------------------------------------------------- /src/test/kotlin/dk/lldata/axon/banking/transfer/MoneyTransferSagaTest.kt: -------------------------------------------------------------------------------- 1 | package dk.lldata.axon.banking.transfer 2 | 3 | import dk.lldata.axon.banking.coreapi.* 4 | import org.axonframework.spring.stereotype.Saga 5 | import org.axonframework.test.saga.SagaTestFixture 6 | import org.junit.Test 7 | 8 | @Saga 9 | class MoneyTransferSagaTest { 10 | val fixture = SagaTestFixture(MoneyTransferSaga::class.java) 11 | 12 | @Test 13 | fun moneyTransferRequestTest() { 14 | fixture.givenNoPriorActivity() 15 | .whenPublishingA(MoneyTransferRequestedEvent("tf1", "a1", "a2", 100)) 16 | .expectActiveSagas(1) 17 | .expectDispatchedCommands(WithdrawMoneyCommand("a1", "tf1",100)) 18 | } 19 | 20 | @Test 21 | fun depositAfterWithdraw() { 22 | fixture 23 | .givenAPublished(MoneyTransferRequestedEvent("tf1", "a1", "a2", 100)) 24 | .whenPublishingA(MoneyWithdrawnEvent("a1", "tf1",100, 500)) 25 | .expectDispatchedCommands(DepositMoneyCommand("a2", "tf1",100)) 26 | } 27 | 28 | @Test 29 | fun transferCompleteAfterDeposit() { 30 | fixture 31 | .givenAPublished(MoneyTransferRequestedEvent("tf1", "a1", "a2", 100)) 32 | .andThenAPublished(MoneyWithdrawnEvent("a1", "tf1", 100, 500)) 33 | .whenPublishingA(MoneyDepositedEvent("a2", "tf1", 100, 400)) 34 | .expectDispatchedCommands(CompleteMoneyTransferCommand("tf1")) 35 | } 36 | 37 | @Test 38 | fun sagaEnds() { 39 | fixture 40 | .givenAPublished(MoneyTransferRequestedEvent("tf1", "a1", "a2", 100)) 41 | .andThenAPublished(MoneyWithdrawnEvent("a1", "tf1", 100, 500)) 42 | .andThenAPublished(MoneyDepositedEvent("a2", "tf1", 100, 400)) 43 | .whenPublishingA(MoneyTransferCompletedEvent("tf1")) 44 | .expectActiveSagas(0) 45 | .expectNoDispatchedCommands() 46 | } 47 | 48 | // Hmmm. Not as easy to test as I imagined .. 49 | // @Test 50 | // fun moneyTransferRequestExceedLimit() { 51 | // fixture.givenAPublished(AccountCreatedEvent("a1", 1000)) 52 | // .andThenAPublished(AccountCreatedEvent("a2", 1000)) 53 | // .whenPublishingA(MoneyTransferRequestedEvent("tf1", "a1", "a2", 10000)) 54 | // .expectActiveSagas(1) 55 | // .expectDispatchedCommands(WithdrawMoneyCommand("a1", "tf1",10000)) 56 | // .expectNoScheduledEvents() 57 | // //.expectActiveSagas(0) 58 | // .expectDispatchedCommands(CancelMoneyTransferCommand("tf1")) 59 | // } 60 | 61 | } --------------------------------------------------------------------------------