├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── build.gradle.kts ├── diagram.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src ├── main ├── kotlin │ └── eventsourcing │ │ ├── Application.kt │ │ ├── api │ │ ├── CommandApi.kt │ │ ├── CommandDispatcher.kt │ │ ├── ErrorResource.kt │ │ ├── ReadModelsApi.kt │ │ ├── StudentCommandController.kt │ │ ├── StudentReadController.kt │ │ ├── TrainingClassCommandController.kt │ │ └── TrainingClassReadController.kt │ │ ├── domain │ │ ├── AggregateRoot.kt │ │ ├── Commands.kt │ │ ├── EventSourcedRepository.kt │ │ ├── EventStore.kt │ │ ├── Events.kt │ │ ├── Index.kt │ │ ├── MessageBus.kt │ │ ├── RegisteredEmailsIndex.kt │ │ ├── Repository.kt │ │ ├── Results.kt │ │ ├── Student.kt │ │ ├── StudentCommandHandlers.kt │ │ ├── StudentRepository.kt │ │ ├── TrainingClass.kt │ │ ├── TrainingClassCommandHandlers.kt │ │ └── TrainingClassRepository.kt │ │ ├── eventstore │ │ ├── BaseEventStore.kt │ │ └── InMemoryEventStore.kt │ │ ├── messagebus │ │ ├── AsyncInMemoryBus.kt │ │ └── InMemoryBus.kt │ │ └── readmodels │ │ ├── DocumentStore.kt │ │ ├── Projections.kt │ │ ├── studentdetails │ │ └── StudentDetailsReadModel.kt │ │ ├── studentlist │ │ └── StudentListReadModel.kt │ │ └── trainingclasses │ │ ├── TrainingClassProjection.kt │ │ └── TrainingClassReadModel.kt └── resources │ └── application.yml └── test ├── kotlin └── eventsourcing │ ├── Retry.kt │ ├── TestUtils.kt │ ├── api │ ├── StudentCommandControllerIT.kt │ ├── StudentReadControllerIT.kt │ ├── TrainingClassCommandControllerIT.kt │ └── TrainingClassReadControllerIT.kt │ ├── domain │ ├── EventSourcedRepositoryTest.kt │ ├── StudentCommandHandlersTest.kt │ ├── StudentTest.kt │ ├── TrainingClassCommandHandlersTest.kt │ └── TrainingClassTest.kt │ ├── end2end │ ├── AvailableSpotsRuleViolationE2ETest.kt │ ├── BaseE2EJourneyTest.kt │ ├── ConcurrentChangeDetectedE2ETest.kt │ ├── DuplicateStudentEmailRuleViolationE2ETest.kt │ ├── HappyJourneyE2ETest.kt │ └── UnenrollNotEnrolledStudentRuleViolationE2ETest.kt │ ├── eventstore │ └── InMemoryEventStoreTest.kt │ ├── messagebus │ ├── AsyncInMemoryBusTest.kt │ └── InMemoryBusTest.kt │ └── readmodels │ ├── InMemoryDocumentStoreTest.kt │ ├── studentdetails │ ├── StudentDetailsProjectionTest.kt │ └── StudentDetailsReadModelTest.kt │ ├── studentlist │ ├── StudentListProjectionTest.kt │ └── StudentListReadModelTest.kt │ └── trainingclasses │ ├── TrainingClassProjectionTest.kt │ └── TrainingClassReadModelTest.kt └── resources ├── logback-test.xml └── mockito-extensions └── org.mockito.plugins.MockMaker /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/** 5 | !**/src/test/** 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | out/ 22 | 23 | ### NetBeans ### 24 | /nbproject/private/ 25 | /nbbuild/ 26 | /dist/ 27 | /nbdist/ 28 | /.nb-gradle/ 29 | 30 | ### VS Code ### 31 | .vscode/ 32 | 33 | ### OSX ### 34 | # General 35 | .DS_Store 36 | .AppleDouble 37 | .LSOverride 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | jdk: 3 | - openjdk8 4 | 5 | script: 6 | - ./gradlew test build 7 | 8 | before_cache: 9 | - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock 10 | - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ 11 | 12 | cache: 13 | directories: 14 | - $HOME/.gradle/caches/ 15 | - $HOME/.gradle/wrapper/ 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Lorenzo Nicora 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Event-Sourcing/CQRS example, in Kotlin 2 | 3 | [![Build Status](https://travis-ci.org/nicusX/kotlin-event-sourcing-example.svg?branch=master)](https://travis-ci.org/nicusX/kotlin-event-sourcing-example) 4 | 5 | This project is for demonstration purposes. 6 | 7 | It demonstrate a classing Event-Sourcing system and it is loosely based on [Greg Young's SimpleCQRS](https://github.com/gregoryyoung/m-r), 8 | but with a different domain and additional features. 9 | 10 | Differently from Greg Young's SimpleCQRS, the implementation is a bit more *functional* (still quite OOP), 11 | avoiding for example to use Exceptions as signals and embracing immutability. 12 | And it is obviously written in Kotlin rather than C# :) 13 | 14 | 15 | ## The Domain 16 | 17 | The domain implemented is a training class management system. 18 | 19 | Supported Commands are: 20 | 21 | * Schedule New Class 22 | * Register New Student 23 | * Enroll a Student to a Class 24 | * Unenroll a Student from a Class 25 | 26 | Exposed Read Models: 27 | 28 | * Student Details 29 | * List of Students (this is implemented as a separate Read Model for demonstration purposes) 30 | * List of Classes and Class Details, including contacts of enrolled Students 31 | 32 | All Commands and Read Models are exposed as a REST API, notably following a 33 | [REST-without-PUT](https://www.thoughtworks.com/insights/blog/rest-api-design-resource-modeling) approach for writed. 34 | This fits well with CQRS. 35 | 36 | No API documentation. Endpoints may be easily inferred looking at the [implementation of the API](src/main/kotlin/eventsourcing/api) 37 | 38 | Some basic business rules (invariants) are enforced: not enrolling the same student twice, creating a class with no seats, not unenrolling a student never actually enrolled, do not register a student with an email already in use. 39 | 40 | More business rules may be implemented, and also some non-idempotent side-effect, like sending a (fake) welcome email to a 41 | newly registered Student. 42 | 43 | ## The implementation 44 | 45 | ![Architecture of the system](./diagram.png) 46 | 47 | ## The "C"-side 48 | 49 | The Write side of the system supports a version-based concurrency control, to optimistically prevent from concurrent changes to an Aggregate. 50 | Read Models provide the version of Aggregates and Commands contain the version of Aggregate they are expected to be applied to. 51 | 52 | The Write side of the system is completely synchronous and blocking. 53 | 54 | The Event Store is in-memory. 55 | 56 | A simple Service has been included, maintaining an auxiliary Read Model, to enforce email uniqueness. This is a simplification that would not work in a real, distributed system. 57 | 58 | ## The "Q"-side 59 | 60 | The Read side of the system implements multiple, independent Read Models. 61 | They run in-process but they are designed as if they where in separate applications (the only dependency to the Domain are the Events: for simplicity, they are not serialised when sent through the message bus). 62 | 63 | The message bus is in-memory, but asynchronous. 64 | Read Models are updated asynchronously and only eventually consistent with the state of the Aggregates on the Write side, to behave similarly to a real, distributed CQRS system. 65 | Though, in this case, latency is negligible (but it is possible to simulate an higher latency!). 66 | 67 | "Datastores" backing Read Models are also in-memory. 68 | 69 | ### Why Arrow? 70 | 71 | [Arrow](https://arrow-kt.io) Kotlin functional library is used only for 72 | [`Either`](https://arrow-kt.io/docs/apidocs/arrow-core-data/arrow.core/-either/index.html), 73 | [`Option`](https://arrow-kt.io/docs/apidocs/arrow-core-data/arrow.core/-option/index.html) and few other bits, to allow 74 | nicer patterns compared to Kotlin native constructs. 75 | 76 | The code is far from purely functional. 77 | 78 | ### Why SpringBoot? 79 | 80 | Because I am lazy ;) and I do not want to spend time on the boilerplate. My focus here is different 81 | Spring is not actually used much other than the REST API layer and for running some E2E in-process tests. 82 | All dependencies are initialised and wired manually in [`Application`](src/main/kotlin/eventsourcing/Application.kt) 83 | 84 | ### Other implementation notes 85 | 86 | To allow mocking non-open classes, the Mockito `mock-maker-inline` has been enabled. See https://antonioleiva.com/mockito-2-kotlin/ 87 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | id("org.springframework.boot") version "2.1.6.RELEASE" 5 | id("io.spring.dependency-management") version "1.0.7.RELEASE" 6 | kotlin("jvm") version "1.3.41" 7 | kotlin("plugin.spring") version "1.3.41" 8 | } 9 | 10 | group = "it.nicus" 11 | version = "0.0.2-SNAPSHOT" 12 | java.sourceCompatibility = JavaVersion.VERSION_1_8 13 | 14 | repositories { 15 | mavenCentral() 16 | jcenter() 17 | maven( url ="https://dl.bintray.com/arrow-kt/arrow-kt/" ) 18 | } 19 | 20 | dependencies { 21 | val coroutineVersion = "1.3.0-RC" 22 | val arrowVersion = "0.9.0" 23 | 24 | implementation("org.springframework.boot:spring-boot-starter-web") 25 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 26 | implementation("org.jetbrains.kotlin:kotlin-reflect") 27 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") 28 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion") 29 | implementation( "io.arrow-kt:arrow-core-data:$arrowVersion") 30 | 31 | testImplementation("org.springframework.boot:spring-boot-starter-test") { 32 | exclude(module = "junit") 33 | } 34 | testImplementation("org.junit.jupiter:junit-jupiter-api") 35 | testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") 36 | testImplementation( "com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0") 37 | testImplementation("org.apache.commons:commons-lang3") 38 | testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineVersion") 39 | } 40 | 41 | tasks.withType { 42 | kotlinOptions { 43 | freeCompilerArgs = listOf( 44 | "-Xjsr305=strict", 45 | "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", 46 | "-Xuse-experimental=kotlinx.coroutines.ObsoleteCoroutinesApi" 47 | ) 48 | jvmTarget = "1.8" 49 | } 50 | } 51 | 52 | tasks.withType { 53 | useJUnitPlatform() 54 | } 55 | -------------------------------------------------------------------------------- /diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicusX/kotlin-event-sourcing-example/0a231308eb6bd9ad04abbe14aea30e710d075012/diagram.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicusX/kotlin-event-sourcing-example/0a231308eb6bd9ad04abbe14aea30e710d075012/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /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.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "kotlin-event-sourcing-example" 2 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/Application.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing 2 | 3 | import eventsourcing.api.CommandDispatcher 4 | import eventsourcing.domain.* 5 | import eventsourcing.eventstore.InMemoryEventStore 6 | import eventsourcing.messagebus.AsyncInMemoryBus 7 | import eventsourcing.readmodels.InMemoryDocumentStore 8 | import eventsourcing.readmodels.InMemorySingleDocumentStore 9 | import eventsourcing.readmodels.studentdetails.StudentDetails 10 | import eventsourcing.readmodels.studentdetails.StudentDetailsProjection 11 | import eventsourcing.readmodels.studentdetails.StudentDetailsReadModel 12 | import eventsourcing.readmodels.studentlist.StudentList 13 | import eventsourcing.readmodels.studentlist.StudentListProjection 14 | import eventsourcing.readmodels.studentlist.StudentListReadModel 15 | import eventsourcing.readmodels.trainingclasses.* 16 | import kotlinx.coroutines.GlobalScope 17 | import org.springframework.boot.autoconfigure.SpringBootApplication 18 | import org.springframework.boot.runApplication 19 | import org.springframework.context.annotation.Bean 20 | 21 | @SpringBootApplication 22 | class EventSourcingApplication { 23 | 24 | // Manually wiring up all dependencies 25 | 26 | // Student Details Read Model 27 | private val studentDetailsDatastore = InMemoryDocumentStore() 28 | private val studentDetailsProjection = StudentDetailsProjection(studentDetailsDatastore) 29 | private val studentDetailsReadModelFacade = StudentDetailsReadModel(studentDetailsDatastore) 30 | 31 | // Student List Read Model 32 | private val studentListDatastore = InMemorySingleDocumentStore(emptyList()) 33 | private val studentListProjection = StudentListProjection(studentListDatastore) 34 | private val studentListReadModelFacade = StudentListReadModel(studentListDatastore) 35 | 36 | // Training Class Read Model 37 | private val trainingClassDetailsStore = InMemoryDocumentStore() 38 | private val trainingClassListStore = InMemorySingleDocumentStore(emptyList()) 39 | private val studentsContactsStore = InMemoryDocumentStore() 40 | private val trainingClassProjection = TrainingClassProjection(trainingClassDetailsStore, trainingClassListStore, studentsContactsStore) 41 | private val trainingClassReadModel = TrainingClassReadModel(trainingClassDetailsStore, trainingClassListStore) 42 | 43 | private val registeredEmailIndex = RegisteredEmailsIndex(InMemoryIndex()) 44 | 45 | // Event Bus 46 | private val eventBus : EventPublisher = AsyncInMemoryBus(GlobalScope) 47 | .register(studentDetailsProjection) 48 | .register(studentListProjection) 49 | .register(trainingClassProjection) 50 | .register(registeredEmailIndex) 51 | 52 | // Event Store and Event-Sourced Repositories 53 | private val eventStore : EventStore = InMemoryEventStore(eventBus) 54 | private val classRepository = TrainingClassRepository(eventStore) 55 | private val studentRepository = StudentRepository(eventStore) 56 | private val commandDispatcher : CommandDispatcher = CommandDispatcher(classRepository, studentRepository, registeredEmailIndex) 57 | 58 | 59 | // The only Spring Beans are read models and the command handler dispatcher, to be injected in Controllers 60 | 61 | // These Beans are injected in the MVC Controllers 62 | @Bean fun studentDetailsReadModelFacade() = studentDetailsReadModelFacade 63 | @Bean fun studentListReadModelFacade() = studentListReadModelFacade 64 | @Bean fun trainingClassReadModel() = trainingClassReadModel 65 | @Bean fun trainingClassCommandHandler() = commandDispatcher 66 | } 67 | 68 | fun main(args: Array) { 69 | runApplication(*args) 70 | } 71 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/api/CommandApi.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.api 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.http.ResponseEntity 5 | 6 | fun serverErrorResponse(errorMessage: String = "Server Error"): ResponseEntity = 7 | ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorResource(errorMessage)) 8 | 9 | fun unprocessableEntityResponse(errorMessage: String): ResponseEntity = 10 | ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(ErrorResource(errorMessage)) 11 | 12 | fun notFoundResponse(errorMessage: String): ResponseEntity = 13 | ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResource(errorMessage)) 14 | 15 | fun conflictResponse(errorMessage: String) : ResponseEntity = 16 | ResponseEntity.status(HttpStatus.CONFLICT).body(ErrorResource(errorMessage)) -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/api/CommandDispatcher.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.api 2 | 3 | import eventsourcing.domain.* 4 | import org.slf4j.Logger 5 | import org.slf4j.LoggerFactory 6 | 7 | 8 | /** 9 | * Command handler dispatcher 10 | * This class is doing nothing more than wiring up all command handlers and their dependencies, to be injected 11 | * into command controllers 12 | */ 13 | class CommandDispatcher(private val classRepo: TrainingClassRepository, private val studentRepo : StudentRepository, private val registeredEmailsIndex: RegisteredEmailsIndex) { 14 | 15 | val scheduleNewClassHandler: (ScheduleNewClass) -> Result = handleScheduleNewClass(classRepo) 16 | val enrollStudentHandler: (EnrollStudent) -> Result = handleEnrollStudent(classRepo) 17 | val unenrollStudentHandler: (UnenrollStudent) -> Result = handleUnenrollStudent(classRepo) 18 | val registerNewStudentHandler: (RegisterNewStudent) -> Result = handleRegisterNewStudent(studentRepo, registeredEmailsIndex) 19 | 20 | // Dispatches 21 | fun handle(command: Command) : Result { 22 | log.debug("Handing command: {}", command) 23 | return when(command) { 24 | is ScheduleNewClass -> scheduleNewClassHandler(command) 25 | is EnrollStudent -> enrollStudentHandler(command) 26 | is UnenrollStudent -> unenrollStudentHandler(command) 27 | is RegisterNewStudent -> registerNewStudentHandler(command) 28 | 29 | else -> throw UnhandledCommandException(command) 30 | } 31 | } 32 | 33 | companion object { 34 | val log : Logger = LoggerFactory.getLogger(CommandDispatcher::class.java) 35 | } 36 | } 37 | 38 | class UnhandledCommandException(command: Command) : Exception("Command ${command::class.simpleName} is not handled") 39 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/api/ErrorResource.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.api 2 | 3 | data class ErrorResource(val message: String) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/api/ReadModelsApi.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.api 2 | 3 | import org.springframework.http.ResponseEntity 4 | 5 | fun notFoundResponse() : ResponseEntity = ResponseEntity.notFound().build() -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/api/StudentCommandController.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.api 2 | 3 | import arrow.core.getOrHandle 4 | import eventsourcing.domain.* 5 | import org.springframework.http.HttpHeaders 6 | import org.springframework.http.HttpStatus 7 | import org.springframework.http.ResponseEntity 8 | import org.springframework.web.bind.annotation.PostMapping 9 | import org.springframework.web.bind.annotation.RequestBody 10 | import org.springframework.web.bind.annotation.RestController 11 | import javax.validation.Valid 12 | 13 | // TODO rewrite using Routing DSL and coRouter 14 | @RestController 15 | class StudentCommandController(private val dispatcher: CommandDispatcher) { 16 | @PostMapping("/students/register") 17 | fun registerNewStudent(@Valid @RequestBody req: RegisterNewStudentRequest): ResponseEntity<*> = 18 | dispatcher.handle(req.toCommand()).map { success -> 19 | val studentId = (success as RegisterNewStudentSuccess).studentID 20 | acceptedResponse(studentId) 21 | }.mapLeft { failure -> 22 | when (failure) { 23 | is StudentInvariantViolation.EmailAlreadyInUse -> unprocessableEntityResponse("Duplicate email") 24 | is AggregateNotFound -> notFoundResponse("Aggregate not Found") 25 | is EventStoreFailure.ConcurrentChangeDetected -> conflictResponse("Concurrent change detected") 26 | else -> serverErrorResponse() 27 | } 28 | }.getOrHandle { errorResponse -> errorResponse } 29 | } 30 | 31 | data class RegisterNewStudentRequest( 32 | val email: String, 33 | val fullName: String) { 34 | fun toCommand() = RegisterNewStudent(email, fullName) 35 | } 36 | 37 | private fun acceptedResponse(studentId: String): ResponseEntity { 38 | val headers = HttpHeaders() 39 | headers.location = StudentReadController.studentResourceLocation(studentId) 40 | return ResponseEntity(headers, HttpStatus.ACCEPTED) 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/api/StudentReadController.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.api 2 | 3 | import arrow.core.getOrElse 4 | import eventsourcing.readmodels.studentlist.Student 5 | import eventsourcing.readmodels.studentdetails.StudentDetails 6 | import eventsourcing.readmodels.studentdetails.StudentDetailsReadModel 7 | import eventsourcing.readmodels.studentlist.StudentListReadModel 8 | import org.springframework.http.ResponseEntity 9 | import org.springframework.web.bind.annotation.GetMapping 10 | import org.springframework.web.bind.annotation.PathVariable 11 | import org.springframework.web.bind.annotation.RestController 12 | import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder 13 | import java.net.URI 14 | import kotlin.reflect.jvm.javaMethod 15 | 16 | // TODO rewrite using Routing DSL and coRouter 17 | @RestController 18 | class StudentReadController(private val studentDetails: StudentDetailsReadModel, private val studentList: StudentListReadModel) { 19 | 20 | @GetMapping("/students") 21 | fun listTrainingClasses(): ResponseEntity> = 22 | ResponseEntity.ok(studentList.allStudents()) 23 | 24 | @GetMapping("/students/{studentId}") 25 | fun getStudent(@PathVariable studentId: String): ResponseEntity = 26 | studentDetails.getStudentById(studentId) 27 | .map { it.toResponse() } 28 | .getOrElse { notFoundResponse() } 29 | 30 | companion object { 31 | fun studentResourceLocation(studentId: String): URI = 32 | MvcUriComponentsBuilder.fromMethod( 33 | StudentReadController::class.java, 34 | StudentReadController::getStudent.javaMethod!!, 35 | studentId) 36 | .build(studentId) 37 | } 38 | } 39 | 40 | private fun StudentDetails.toResponse() : ResponseEntity = ResponseEntity.ok(this) 41 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/api/TrainingClassCommandController.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.api 2 | 3 | import arrow.core.getOrHandle 4 | import com.fasterxml.jackson.annotation.JsonFormat 5 | import eventsourcing.domain.* 6 | import org.springframework.http.HttpHeaders 7 | import org.springframework.http.HttpStatus 8 | import org.springframework.http.ResponseEntity 9 | import org.springframework.web.bind.annotation.PathVariable 10 | import org.springframework.web.bind.annotation.PostMapping 11 | import org.springframework.web.bind.annotation.RequestBody 12 | import org.springframework.web.bind.annotation.RestController 13 | import java.time.LocalDate 14 | import javax.validation.Valid 15 | 16 | // TODO rewrite using Routing DSL and coRouter 17 | // eg https://medium.com/@hantsy/using-kotlin-coroutines-with-spring-d2784a300bda 18 | @RestController 19 | class TrainingClassCommandController(private val dispatcher: CommandDispatcher) { 20 | 21 | @PostMapping("/classes/schedule_new") 22 | fun scheduleNewClass(@Valid @RequestBody req: ScheduleNewClassRequest): ResponseEntity<*> = 23 | dispatcher.handle(req.toCommand()).map { success: Success -> 24 | val classId = (success as ScheduleNewClassSuccess).classId 25 | acceptedResponse(classId) 26 | }.mapLeft { failure: Failure -> 27 | when (failure) { 28 | is TrainingClassInvariantViolation.InvalidClassSize -> 29 | unprocessableEntityResponse("Invalid Class Size") 30 | is AggregateNotFound -> notFoundResponse("Aggregate not Found") 31 | is EventStoreFailure.ConcurrentChangeDetected -> conflictResponse("Concurrent change detected") 32 | else -> serverErrorResponse() 33 | } 34 | }.getOrHandle { errorResponse -> errorResponse } 35 | 36 | 37 | @PostMapping("/classes/{classId}/enroll_student") 38 | fun enrollStudent(@PathVariable classId: String, @Valid @RequestBody req: EnrollStudentRequest): ResponseEntity<*> = 39 | dispatcher.handle(req.toCommandWithClassId(classId)).map { _: Success -> 40 | acceptedResponse(classId) 41 | }.mapLeft { failure -> 42 | when (failure) { 43 | is TrainingClassInvariantViolation.StudentAlreadyEnrolled -> unprocessableEntityResponse("Student already enrolled") 44 | is TrainingClassInvariantViolation.ClassHasNoAvailableSpots -> unprocessableEntityResponse("No available spots") 45 | is AggregateNotFound -> notFoundResponse("Aggregate not Found") 46 | is EventStoreFailure.ConcurrentChangeDetected -> conflictResponse("Concurrent change detected") 47 | else -> serverErrorResponse() 48 | } 49 | }.getOrHandle { errorResponse -> errorResponse } 50 | 51 | 52 | @PostMapping("/classes/{classId}/unenroll_student") 53 | fun unenrollStudent(@PathVariable classId: String, @Valid @RequestBody req: UnenrollStudentRequest): ResponseEntity<*> = 54 | dispatcher.handle(req.toCommandWithClassId(classId)).map { _: Success -> 55 | acceptedResponse(classId) 56 | }.mapLeft { failure -> 57 | when (failure) { 58 | is TrainingClassInvariantViolation.UnenrollingNotEnrolledStudent -> unprocessableEntityResponse("Student not enrolled") 59 | is AggregateNotFound -> notFoundResponse() 60 | is EventStoreFailure.ConcurrentChangeDetected -> conflictResponse("Concurrent change detected") 61 | else -> serverErrorResponse() 62 | } 63 | }.getOrHandle { errorResponse -> errorResponse } 64 | } 65 | 66 | 67 | private fun acceptedResponse(classId: String): ResponseEntity { 68 | val headers = HttpHeaders() 69 | headers.location = TrainingClassReadController.classResourceLocation(classId) 70 | return ResponseEntity(headers, HttpStatus.ACCEPTED) 71 | } 72 | 73 | data class ScheduleNewClassRequest( 74 | val title: String, 75 | @JsonFormat(pattern = "yyyy-MM-dd", shape = JsonFormat.Shape.STRING) 76 | val date: LocalDate, 77 | val size: Int) { 78 | } 79 | 80 | data class EnrollStudentRequest(val studentId: String, val classVersion: Long) 81 | 82 | data class UnenrollStudentRequest(val studentId: String, val reason: String, val classVersion: Long) 83 | 84 | 85 | private fun ScheduleNewClassRequest.toCommand(): ScheduleNewClass = 86 | ScheduleNewClass(this.title, this.date, this.size) 87 | 88 | private fun EnrollStudentRequest.toCommandWithClassId(classId: String): EnrollStudent = 89 | EnrollStudent(classId, studentId, classVersion) 90 | 91 | private fun UnenrollStudentRequest.toCommandWithClassId(classId: String): UnenrollStudent = 92 | UnenrollStudent(classId, studentId, reason, classVersion) 93 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/api/TrainingClassReadController.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.api 2 | 3 | import arrow.core.getOrElse 4 | import eventsourcing.readmodels.trainingclasses.TrainingClass 5 | import eventsourcing.readmodels.trainingclasses.TrainingClassDetails 6 | import eventsourcing.readmodels.trainingclasses.TrainingClassReadModel 7 | import org.springframework.http.ResponseEntity 8 | import org.springframework.web.bind.annotation.GetMapping 9 | import org.springframework.web.bind.annotation.PathVariable 10 | import org.springframework.web.bind.annotation.RestController 11 | import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder 12 | import java.net.URI 13 | import kotlin.reflect.jvm.javaMethod 14 | 15 | // TODO rewrite using Routing DSL and coRouter 16 | @RestController 17 | class TrainingClassReadController(private val trainingClassReadModel: TrainingClassReadModel) { 18 | 19 | @GetMapping("/classes") 20 | fun listTrainingClasses(): ResponseEntity> = ResponseEntity.ok(trainingClassReadModel.allClasses()) 21 | 22 | @GetMapping("/classes/{classId}") 23 | fun getTrainingClass(@PathVariable classId: String): ResponseEntity = 24 | trainingClassReadModel.getTrainingClassDetailsById(classId) 25 | .map { it.toResponse()} 26 | .getOrElse { notFoundResponse() } 27 | 28 | companion object { 29 | fun classResourceLocation(classId: String): URI = 30 | MvcUriComponentsBuilder.fromMethod( 31 | TrainingClassReadController::class.java, 32 | TrainingClassReadController::getTrainingClass.javaMethod!!, 33 | classId) 34 | .build(classId) 35 | } 36 | } 37 | 38 | private fun TrainingClassDetails.toResponse(): ResponseEntity = ResponseEntity.ok(this) 39 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/AggregateRoot.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import java.util.* 6 | 7 | typealias AggregateID = String 8 | 9 | interface AggregateType 10 | 11 | /** 12 | * Groups common behaviours of all Aggregates 13 | */ 14 | abstract class AggregateRoot(val id: AggregateID) { 15 | 16 | abstract fun aggregateType(): AggregateType 17 | 18 | private val uncommittedChanges = ArrayList() 19 | 20 | fun getUncommittedChanges(): Iterable = uncommittedChanges.toList().asIterable() 21 | 22 | fun markChangesAsCommitted() { 23 | log.debug("Marking all changes as committed") 24 | uncommittedChanges.clear() 25 | } 26 | 27 | protected fun applyAndQueueEvent(event: Event): A { 28 | log.debug("Applying {}", event) 29 | applyEvent(event) // Remember: this never fails 30 | 31 | log.debug("Queueing uncommitted change {}", event) 32 | uncommittedChanges.add(event) 33 | return this as A 34 | } 35 | 36 | protected abstract fun applyEvent(event: Event): AggregateRoot 37 | 38 | companion object { 39 | val log: Logger = LoggerFactory.getLogger(AggregateRoot::class.java) 40 | 41 | /** 42 | * Rebuild the state of the Aggregate from its Events 43 | */ 44 | fun loadFromHistory(aggregate: A, history: Iterable): A { 45 | log.debug("Reloading aggregate {} state from history", aggregate) 46 | 47 | // Rebuilding an Aggregate state from Events is a 'fold' operation 48 | return history.fold( aggregate, { agg, event -> agg.applyEvent(event) as A }) 49 | } 50 | } 51 | } 52 | 53 | class UnsupportedEventException(eventClass: Class) 54 | : Exception("Unsupported event ${eventClass.canonicalName}") 55 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/Commands.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import java.time.Instant 4 | import java.time.LocalDate 5 | 6 | abstract class Command(val createdAt: Instant = Instant.now()) 7 | 8 | data class ScheduleNewClass( 9 | val title: String, 10 | val date: LocalDate, 11 | val size: Int) : Command() 12 | 13 | data class EnrollStudent( 14 | val classId: ClassID, 15 | val studentId: StudentID, 16 | val expectedVersion: Long) : Command() 17 | 18 | data class UnenrollStudent( 19 | val classId: ClassID, 20 | val studentId: StudentID, 21 | val reason: String, 22 | val expectedVersion: Long) : Command() 23 | 24 | // TODO Add CancelTrainingClass, notifying all enrolled Students (behaviour with side effects) 25 | 26 | data class RegisterNewStudent( 27 | val email: EMail, 28 | val fullName: String) : Command() 29 | 30 | // TODO Add UnregisterStudent, removing the student from all classes (command affecting multiple aggregates) 31 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/EventSourcedRepository.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.Option 4 | import arrow.core.Right 5 | import arrow.core.flatMap 6 | import org.slf4j.Logger 7 | import org.slf4j.LoggerFactory 8 | 9 | /** 10 | * Repository implementing persistence through Event Sourcing: 11 | * - save uncommited changes (Events) of an Aggregate 12 | * - rebuild Aggregate from its Events 13 | */ 14 | abstract class EventSourcedRepository(eventStore: EventStore) : Repository { 15 | 16 | private val store = eventStore 17 | 18 | override fun save(aggregate: A, expectedVersion: Option) : Result { 19 | log.debug("Storing uncommitted event for '${aggregate.aggregateType()}:$aggregate.id'") 20 | val uncommitedChanges = aggregate.getUncommittedChanges() 21 | 22 | return store.saveEvents(aggregate.aggregateType(), aggregate.id, uncommitedChanges, expectedVersion) 23 | .flatMap{ _ -> 24 | aggregate.markChangesAsCommitted() 25 | Right(ChangesSuccessfullySaved) 26 | } 27 | } 28 | 29 | override fun getById(id: AggregateID): Option { 30 | 31 | val aggregate = new(id) 32 | val aggregateType = aggregate.aggregateType() 33 | log.debug("Retrieve {} by id:{}", aggregateType, id) 34 | return store.getEventsForAggregate(aggregate.aggregateType(), id) 35 | .map { events -> AggregateRoot.loadFromHistory(aggregate, events)} 36 | } 37 | 38 | companion object { 39 | val log : Logger = LoggerFactory.getLogger(EventSourcedRepository::class.java) 40 | } 41 | } 42 | 43 | object ChangesSuccessfullySaved : Success 44 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/EventStore.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.None 4 | import arrow.core.Option 5 | 6 | /** 7 | * Interface for a simple Event Store 8 | */ 9 | interface EventStore { 10 | fun saveEvents( 11 | aggregateType: AggregateType, 12 | aggregateId: AggregateID, 13 | events: Iterable, 14 | expectedVersion: Option = None) : Result> 15 | 16 | fun getEventsForAggregate(aggregateType: AggregateType, aggregateId: AggregateID): Option> 17 | } 18 | 19 | 20 | sealed class EventStoreFailure : Failure { 21 | object ConcurrentChangeDetected : EventStoreFailure() 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/Events.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import java.time.Instant 4 | import java.time.LocalDate 5 | 6 | abstract class Event(private val version: Long?, val eventTime: Instant = Instant.now()) { 7 | // It would be better to inject a clock, but we have no logic to test around eventTime 8 | 9 | // The version is assigned only then the Event is stored in the EventStore 10 | // There are effectively two types of Events: before and after they are stored in the Event Store. 11 | // TODO find a more elegant solution for event version 12 | fun version(): Long? = version 13 | abstract fun copyWithVersion(version: Long): Event 14 | } 15 | 16 | data class NewClassScheduled ( 17 | val classId: ClassID, 18 | val title: String, 19 | val date: LocalDate, 20 | val classSize: Int, 21 | val version: Long? = null) : Event(version) { 22 | 23 | override fun copyWithVersion(version: Long): NewClassScheduled = 24 | this.copy(version = version) 25 | } 26 | 27 | data class StudentEnrolled ( 28 | val classId: ClassID, 29 | val studentId: StudentID, 30 | val version: Long? = null) : Event(version) { 31 | override fun copyWithVersion(version: Long): StudentEnrolled = 32 | this.copy(version = version) 33 | } 34 | 35 | data class StudentUnenrolled (val classId: ClassID, 36 | val studentId: StudentID, 37 | val reason: String, 38 | val version: Long? = null) : Event(version) { 39 | override fun copyWithVersion(version: Long): StudentUnenrolled = 40 | this.copy(version = version) 41 | } 42 | 43 | data class NewStudentRegistered ( 44 | val studentId: StudentID, 45 | val email : EMail, 46 | val fullName: String, 47 | val version: Long? = null) : Event(version) { 48 | override fun copyWithVersion(version: Long): NewStudentRegistered = 49 | this.copy(version = version) 50 | } 51 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/Index.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import kotlin.jvm.Synchronized 4 | 5 | interface Index { 6 | fun contains(entry: T): Boolean 7 | fun add(entry: T): Boolean 8 | fun remove(entry: T): Boolean 9 | } 10 | 11 | /** 12 | * Nothing more than a synchronised wrapper around a MutableSet 13 | */ 14 | class InMemoryIndex : Index { 15 | private val set: MutableSet = mutableSetOf() 16 | 17 | @Synchronized override fun add(entry: T): Boolean = set.add(entry) 18 | 19 | @Synchronized override fun contains(entry: T): Boolean = set.contains(entry) 20 | 21 | @Synchronized override fun remove(entry: T): Boolean = set.remove(entry) 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/MessageBus.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | interface EventPublisher { 4 | fun publish(event : E) 5 | fun register(eventHandler: Handles) : EventPublisher 6 | } 7 | 8 | interface Handles { 9 | fun handle(event : E) 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/RegisteredEmailsIndex.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | 6 | /** 7 | * Service using an auxiliary read model, to check whether an email is already in use 8 | * 9 | * This is an all-in-one: Service, underlying Read Model and Projection (handling NewStudentRegistered events) 10 | 11 | */ 12 | class RegisteredEmailsIndex(private val emailIndex: Index) : Handles { 13 | 14 | // TODO Support rebuilding index reconsuming all events 15 | 16 | override fun handle(event: Event) { 17 | when (event) { 18 | is NewStudentRegistered -> { 19 | log.debug("Add '{}' to the index", event.email) 20 | emailIndex.add(event.email) 21 | } 22 | } 23 | } 24 | 25 | fun isEmailAlreadyInUse(email: EMail): Boolean = emailIndex.contains(email) 26 | 27 | companion object { 28 | private val log: Logger = LoggerFactory.getLogger(RegisteredEmailsIndex::class.java) 29 | } 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/Repository.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.None 4 | import arrow.core.Option 5 | 6 | /** 7 | * Interface for a simple Repository, supporting version-based concurrency control 8 | */ 9 | interface Repository { 10 | fun getById(id: AggregateID): Option 11 | fun save(aggregate: A, expectedVersion: Option = None) : Result 12 | fun new(id: AggregateID) : A // Delegating the creation of a new Aggregate to the Repository, for simplicity 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/Results.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.Either 4 | 5 | interface Failure 6 | 7 | interface Success 8 | 9 | typealias Result = Either 10 | 11 | object AggregateNotFound : Failure 12 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/Student.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.Left 4 | import arrow.core.Right 5 | import org.slf4j.Logger 6 | import org.slf4j.LoggerFactory 7 | import java.util.* 8 | 9 | typealias StudentID = String 10 | typealias EMail = String 11 | 12 | /** 13 | * Aggregate root representing a Student 14 | */ 15 | class Student(id: StudentID) : AggregateRoot(id) { 16 | 17 | object TYPE : AggregateType { 18 | override fun toString() = "student" 19 | } 20 | 21 | override fun aggregateType() = TYPE 22 | 23 | override fun applyEvent(event: Event): Student = 24 | when (event) { 25 | is NewStudentRegistered -> apply(event) 26 | else -> throw UnsupportedEventException(event::class.java) 27 | } 28 | 29 | 30 | private fun apply(event: NewStudentRegistered): Student { 31 | // Nothing special to do here. 32 | // We are keeping the index of registered emails in an auxiliary read model within the RegisteredEmailsIndex service 33 | return this 34 | } 35 | 36 | companion object { 37 | 38 | // This is the only behaviour of this Aggregate at the moment 39 | fun registerNewStudent(email: EMail, fullname: String, repository: StudentRepository, registeredEmailsIndex: RegisteredEmailsIndex): Result = 40 | when { 41 | 42 | // This is an example of an invariant across multiple aggregates. 43 | // In a distributed system it should be something more complex than this, to guarantee uniqueness across multiple nodes. 44 | registeredEmailsIndex.isEmailAlreadyInUse(email) -> Left(StudentInvariantViolation.EmailAlreadyInUse) 45 | 46 | // Success 47 | else -> { 48 | val studentId = UUID.randomUUID().toString() 49 | val student = Student(studentId) 50 | // TODO notify the Student (i.e. a non-idempotent side effect) 51 | Right(student.applyAndQueueEvent(NewStudentRegistered(studentId, email, fullname))) 52 | } 53 | } 54 | 55 | val log: Logger = LoggerFactory.getLogger(Student::class.java) 56 | } 57 | } 58 | 59 | sealed class StudentInvariantViolation : Failure { 60 | object EmailAlreadyInUse : StudentInvariantViolation() 61 | } 62 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/StudentCommandHandlers.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.flatMap 4 | 5 | 6 | data class RegisterNewStudentSuccess(val studentID: StudentID) : Success 7 | 8 | fun handleRegisterNewStudent(studentRepository: StudentRepository, registeredEmailsIndex: RegisteredEmailsIndex) 9 | : (RegisterNewStudent) -> Result = { command: RegisterNewStudent -> 10 | Student.registerNewStudent(command.email, command.fullName, studentRepository, registeredEmailsIndex) 11 | .flatMap { student -> 12 | studentRepository.save(student) 13 | .map { RegisterNewStudentSuccess(student.id) } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/StudentRepository.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | class StudentRepository(eventStore: EventStore) : EventSourcedRepository(eventStore) { 4 | override fun new(id: AggregateID): Student = Student(id) 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/TrainingClass.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.Left 4 | import arrow.core.Right 5 | import org.slf4j.Logger 6 | import org.slf4j.LoggerFactory 7 | import java.time.LocalDate 8 | import java.util.* 9 | 10 | typealias ClassID = String 11 | 12 | /** 13 | * Aggregate root representing a Training Class 14 | */ 15 | class TrainingClass(id: ClassID) : AggregateRoot(id) { 16 | 17 | object TYPE : AggregateType { 18 | override fun toString() = "class" 19 | } 20 | 21 | override fun aggregateType() = TYPE 22 | 23 | // Note that the Aggregate does not keep its full state 24 | // It only keeps what is required to enforce invariants 25 | 26 | private var availableSpots = 0 27 | private var enrolledStudents: MutableSet = mutableSetOf() 28 | 29 | 30 | // Apply Event methods 31 | // - change the state of the aggregate 32 | // - no side-effect 33 | // - NEVER FAILS 34 | 35 | override fun applyEvent(event: Event): TrainingClass = 36 | when (event) { 37 | is NewClassScheduled -> apply(event) 38 | is StudentEnrolled -> apply(event) 39 | is StudentUnenrolled -> apply(event) 40 | else -> throw UnsupportedEventException(event::class.java) 41 | } 42 | 43 | private fun apply(event: StudentEnrolled): TrainingClass { 44 | this.availableSpots-- 45 | this.enrolledStudents.add(event.studentId) 46 | return this 47 | } 48 | 49 | private fun apply(event: StudentUnenrolled): TrainingClass { 50 | this.availableSpots++ 51 | this.enrolledStudents.remove(event.studentId) 52 | return this 53 | } 54 | 55 | private fun apply(event: NewClassScheduled): TrainingClass { 56 | this.availableSpots = event.classSize 57 | return this 58 | } 59 | 60 | // Behaviours: 61 | // 1. check invariants 62 | // 2. if successful, apply changes and queue the new event 63 | // may have side-effects 64 | 65 | fun enrollStudent(studentId: StudentID): Result { 66 | log.debug("Enrolling student {} to class {}", studentId, this.id) 67 | return when { 68 | // Invariants violations 69 | this.enrolledStudents.contains(studentId) -> Left(TrainingClassInvariantViolation.StudentAlreadyEnrolled) 70 | this.availableSpots < 1 -> Left(TrainingClassInvariantViolation.ClassHasNoAvailableSpots) 71 | 72 | // Success 73 | else -> Right( applyAndQueueEvent(StudentEnrolled(this.id, studentId)) ) 74 | } 75 | } 76 | 77 | fun unenrollStudent(studentId: StudentID, reason: String): Result { 78 | log.debug("Enrolling student {} from class {}. Reason: '{}'", studentId, this.id, reason) 79 | return when { 80 | // Invariants violations 81 | !this.enrolledStudents.contains(studentId) -> Left(TrainingClassInvariantViolation.UnenrollingNotEnrolledStudent) 82 | 83 | // Success 84 | else -> Right(applyAndQueueEvent(StudentUnenrolled(this.id, studentId, reason))) 85 | } 86 | } 87 | 88 | companion object { 89 | 90 | // This is another behaviour 91 | fun scheduleNewClass(title: String, date: LocalDate, size: Int): Result = 92 | when { 93 | // Invariants violations 94 | size <= 0 -> Left(TrainingClassInvariantViolation.InvalidClassSize) 95 | 96 | // Success 97 | else -> { 98 | val classId = UUID.randomUUID().toString() 99 | Right(TrainingClass(classId) 100 | .applyAndQueueEvent(NewClassScheduled(classId, title, date, size))) 101 | 102 | } 103 | } 104 | 105 | val log: Logger = LoggerFactory.getLogger(TrainingClass::class.java) 106 | } 107 | } 108 | 109 | // Possible Failures on handling behaviours 110 | sealed class TrainingClassInvariantViolation : Failure { 111 | object StudentAlreadyEnrolled : TrainingClassInvariantViolation() 112 | object UnenrollingNotEnrolledStudent : TrainingClassInvariantViolation() 113 | object ClassHasNoAvailableSpots : TrainingClassInvariantViolation() 114 | object InvalidClassSize : TrainingClassInvariantViolation() 115 | } 116 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/TrainingClassCommandHandlers.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.Some 4 | import arrow.core.flatMap 5 | 6 | fun handleScheduleNewClass(classRepository: TrainingClassRepository) 7 | : (ScheduleNewClass) -> Result = { command: ScheduleNewClass -> 8 | TrainingClass.scheduleNewClass(command.title, command.date, command.size) 9 | .flatMap { clazz -> 10 | classRepository.save(clazz) 11 | .map { ScheduleNewClassSuccess(clazz.id) } 12 | } 13 | } 14 | 15 | fun handleEnrollStudent(classRepository: TrainingClassRepository) 16 | : (EnrollStudent) -> Result = { command: EnrollStudent -> 17 | classRepository.getById(command.classId) 18 | .toEither { AggregateNotFound } 19 | .flatMap { clazz -> clazz.enrollStudent(command.studentId)} 20 | .flatMap { clazz -> classRepository.save(clazz, Some(command.expectedVersion))} 21 | .map { EnrollStudentSuccess } 22 | } 23 | 24 | 25 | fun handleUnenrollStudent(classRepository: TrainingClassRepository) 26 | : (UnenrollStudent) -> Result = { command : UnenrollStudent -> 27 | classRepository.getById(command.classId) 28 | .toEither { AggregateNotFound } 29 | .flatMap { clazz -> clazz.unenrollStudent(command.studentId, command.reason)} 30 | .flatMap { clazz -> classRepository.save(clazz, Some(command.expectedVersion))} 31 | .map { UnenrollStudentSuccess } 32 | } 33 | 34 | data class ScheduleNewClassSuccess(val classId: ClassID) : Success 35 | 36 | object EnrollStudentSuccess : Success 37 | 38 | object UnenrollStudentSuccess : Success 39 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/domain/TrainingClassRepository.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | class TrainingClassRepository(eventStore: EventStore) : EventSourcedRepository(eventStore) { 4 | override fun new(id: AggregateID): TrainingClass = TrainingClass(id) 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/eventstore/BaseEventStore.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.eventstore 2 | 3 | import arrow.core.Left 4 | import arrow.core.Option 5 | import arrow.core.Right 6 | import arrow.core.getOrElse 7 | import eventsourcing.domain.* 8 | import org.slf4j.Logger 9 | import org.slf4j.LoggerFactory 10 | 11 | /** 12 | * Implements most of the logic of the Event Store, keeping the actual storage abstract 13 | */ 14 | abstract class BaseEventStore(private val eventPublisher : EventPublisher) : EventStore { 15 | protected data class StreamKey(val aggregateType: AggregateType, val aggregateID: AggregateID) { 16 | override fun toString(): String = "$aggregateType:$aggregateID" 17 | } 18 | 19 | protected data class EventDescriptor(val streamKey: StreamKey, val version: Long, val event: Event) 20 | 21 | protected abstract fun stream(key: StreamKey): Option> 22 | 23 | protected abstract fun appendEventDescriptor(key: StreamKey, eventDescriptor: EventDescriptor) 24 | 25 | 26 | override fun getEventsForAggregate(aggregateType: AggregateType, aggregateId: AggregateID): Option> { 27 | log.debug("Retrieving events for aggregate {}:{}", aggregateType, aggregateId) 28 | val key = StreamKey(aggregateType, aggregateId) 29 | return stream(key).map{ it.map { it.event } } 30 | } 31 | 32 | override fun saveEvents(aggregateType: AggregateType, aggregateId: AggregateID, events: Iterable, expectedVersion: Option) : Result> { 33 | val streamKey = StreamKey(aggregateType, aggregateId) 34 | log.debug("Saving new events for {}. Expected version: {}", streamKey, expectedVersion) 35 | 36 | return if ( stream(streamKey).concurrentChangeDetected(expectedVersion) ) { 37 | log.debug("Concurrent change detected") 38 | Left(EventStoreFailure.ConcurrentChangeDetected) 39 | } else { 40 | log.debug("Appending and publishing {} events", events.count()) 41 | Right(appendAndPublish(streamKey, events, expectedVersion)) 42 | } 43 | } 44 | 45 | 46 | private fun Option>.concurrentChangeDetected(expectedVersion: Option) : Boolean = 47 | expectedVersion.map { expVersion -> 48 | this.map { events -> events.last() } 49 | .exists { event -> event.version != expVersion } 50 | }.getOrElse { false } 51 | 52 | 53 | 54 | private fun appendAndPublish(streamKey: StreamKey, events: Iterable, previousAggregateVersion: Option ) : Iterable { 55 | val baseVersion : Long = previousAggregateVersion.getOrElse { -1 } 56 | return sequence { 57 | for ( (i, event) in events.withIndex()) { 58 | val eventVersion = baseVersion + i + 1 59 | 60 | // Events have a version when stored in a stream and published 61 | val versionedEvent = event.copyWithVersion(eventVersion) 62 | yield(versionedEvent) 63 | 64 | val eventDescriptor = EventDescriptor(streamKey, eventVersion, versionedEvent) 65 | 66 | log.debug("Appending event {} to Stream {}", eventDescriptor, streamKey) 67 | appendEventDescriptor(streamKey, eventDescriptor ) 68 | 69 | log.trace("Publishing event: {}", versionedEvent) 70 | eventPublisher.publish(versionedEvent) 71 | } 72 | }.toList() 73 | } 74 | 75 | companion object { 76 | val log : Logger = LoggerFactory.getLogger(BaseEventStore::class.java) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/eventstore/InMemoryEventStore.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.eventstore 2 | 3 | import arrow.core.Option 4 | import eventsourcing.domain.Event 5 | import eventsourcing.domain.EventPublisher 6 | 7 | /** 8 | * Store event streams in memory 9 | * This is not meant to be optimised 10 | */ 11 | class InMemoryEventStore(eventPublisher : EventPublisher) : BaseEventStore(eventPublisher) { 12 | private val streams: MutableMap> = mutableMapOf() 13 | 14 | @Synchronized override fun stream(key: StreamKey): Option> = 15 | Option.fromNullable( streams[key] ).map { it.toList() } 16 | 17 | @Synchronized override fun appendEventDescriptor(key: StreamKey, eventDescriptor: EventDescriptor) { 18 | val stream = streams[key] ?: mutableListOf() 19 | stream.add(eventDescriptor) 20 | streams[key] = stream 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/messagebus/AsyncInMemoryBus.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.messagebus 2 | 3 | import eventsourcing.domain.Event 4 | import eventsourcing.domain.EventPublisher 5 | import eventsourcing.domain.Handles 6 | import kotlinx.coroutines.* 7 | import kotlinx.coroutines.channels.BroadcastChannel 8 | import kotlinx.coroutines.channels.consumeEach 9 | import org.slf4j.LoggerFactory 10 | 11 | /** 12 | * Implementation of an in-memory event-bus using coroutines to registered events-handler asynchronously 13 | * 14 | * The interface to publisher is still blocking 15 | * 16 | * Setting simulateLatency (msec) simulate a distributed system, with a latency on dispatching messages 17 | */ 18 | class AsyncInMemoryBus(private val scope: CoroutineScope, bufferSize: Int = 100, private val simulateLatency : Long? = null): EventPublisher { 19 | 20 | private val bus = BroadcastChannel(bufferSize) 21 | 22 | override fun publish(event: Event) = runBlocking { 23 | log.debug("Publishing event {}", event) 24 | bus.send(event) 25 | log.trace("Event published") 26 | } 27 | 28 | override fun register(eventHandler: Handles) : EventPublisher { 29 | log.info("Registering handler: {}", eventHandler) 30 | scope.launch { broadcastTo(eventHandler) } 31 | return this 32 | } 33 | 34 | private suspend fun broadcastTo(handler: Handles) = coroutineScope { 35 | log.debug("Starting handler {}", handler) 36 | bus.consumeEach { 37 | 38 | if (simulateLatency != null ) { 39 | log.trace("Simulating {}ms latency", log.trace("Forcibly delaying handling")) 40 | delay(simulateLatency) 41 | } 42 | 43 | log.trace("Handler '{}' is handling '{}'", handler, it) 44 | handler.handle(it) 45 | } 46 | } 47 | 48 | fun shutdown() { 49 | bus.close() 50 | } 51 | 52 | companion object { 53 | private val log = LoggerFactory.getLogger(AsyncInMemoryBus::class.java) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/messagebus/InMemoryBus.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.messagebus 2 | 3 | import eventsourcing.domain.Event 4 | import eventsourcing.domain.EventPublisher 5 | import eventsourcing.domain.Handles 6 | import org.slf4j.LoggerFactory 7 | 8 | /** 9 | * Simple, in-memory event-bus, broadcasting events to all registered handlers synchronously 10 | */ 11 | class InMemoryBus : EventPublisher { 12 | private val handlers: MutableList> = mutableListOf() 13 | 14 | override fun publish(event: Event) { 15 | log.debug("Event published: {}", event) 16 | handlers.forEach { it.handle(event) } 17 | } 18 | 19 | override fun register(eventHandler: Handles): EventPublisher { 20 | log.info("Registering a new event handler {}", eventHandler) 21 | handlers += eventHandler 22 | return this 23 | } 24 | 25 | companion object { 26 | private val log = LoggerFactory.getLogger(InMemoryBus::class.java) 27 | } 28 | } -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/readmodels/DocumentStore.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels 2 | 3 | import arrow.core.Option 4 | 5 | // Different datastores backing read models 6 | 7 | /** 8 | * A generic, basic document store 9 | * containing a single type of document 10 | */ 11 | interface DocumentStore { 12 | fun save(key: String, document: D) 13 | fun get(key: String) : Option 14 | } 15 | 16 | /** 17 | * Super-simple document store, containing a single document 18 | */ 19 | interface SingleDocumentStore { 20 | fun save(document: D) 21 | fun get() : D 22 | } 23 | 24 | /** 25 | * Implementation of DocumentStore, keeping everything in memory but thread-safe 26 | */ 27 | class InMemoryDocumentStore : DocumentStore { 28 | private val store: MutableMap = mutableMapOf() 29 | 30 | @Synchronized override fun get(key: String): Option = Option.fromNullable(store[key]) 31 | 32 | @Synchronized override fun save(key: String, document: D) { 33 | store[key] = document 34 | } 35 | 36 | fun clear() { 37 | store.clear() 38 | } 39 | } 40 | 41 | /** 42 | * Implementation of SingleDocumentStore, keeping the document in memory, but thread-safe 43 | */ 44 | class InMemorySingleDocumentStore(private val initialValue: D) : SingleDocumentStore { 45 | private var document: D = initialValue 46 | 47 | @Synchronized override fun get() = document 48 | 49 | @Synchronized 50 | override fun save(document: D) { 51 | this.document = document 52 | } 53 | 54 | fun clear() { 55 | document = initialValue 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/readmodels/Projections.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels 2 | 3 | object InconsistentReadModelException : Exception("The read model is in an inconsistent state") 4 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/readmodels/studentdetails/StudentDetailsReadModel.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels.studentdetails 2 | 3 | import arrow.core.Option 4 | import eventsourcing.domain.Event 5 | import eventsourcing.domain.Handles 6 | import eventsourcing.domain.NewStudentRegistered 7 | import eventsourcing.readmodels.DocumentStore 8 | import org.slf4j.Logger 9 | import org.slf4j.LoggerFactory 10 | 11 | data class StudentDetails( 12 | val studentId: String, 13 | val email: String, 14 | val fullName: String, 15 | val version: Long) 16 | 17 | 18 | // TODO Add the ability to rebuild the Read Model, re-streaming all events 19 | 20 | class StudentDetailsProjection(private val studentDetailsStore: DocumentStore) : Handles { 21 | 22 | override fun handle(event: Event) { 23 | when (event) { 24 | is NewStudentRegistered -> { 25 | val new = event.toStudentDetails() 26 | log.debug("Save Student {} into the read-model", new) 27 | studentDetailsStore.save(new.studentId, new) 28 | } 29 | } 30 | } 31 | 32 | private fun NewStudentRegistered.toStudentDetails() = 33 | StudentDetails(this.studentId, this.email, this.fullName, this.version!!) 34 | 35 | companion object { 36 | private val log: Logger = LoggerFactory.getLogger(StudentDetailsProjection::class.java) 37 | } 38 | } 39 | 40 | /** 41 | * External, read-only facade for the read model 42 | */ 43 | class StudentDetailsReadModel(private val studentDetailsStore: DocumentStore) { 44 | fun getStudentById(studentId: String): Option = studentDetailsStore.get(studentId) 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/readmodels/studentlist/StudentListReadModel.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels.studentlist 2 | 3 | import eventsourcing.domain.Event 4 | import eventsourcing.domain.Handles 5 | import eventsourcing.domain.NewStudentRegistered 6 | import eventsourcing.readmodels.SingleDocumentStore 7 | import org.slf4j.Logger 8 | import org.slf4j.LoggerFactory 9 | 10 | data class Student(val studentId: String, val fullName: String) 11 | 12 | typealias StudentList = Iterable 13 | 14 | // Separating the list of Student from a read model containing each Student is clearly an overkill in this case 15 | // but it is meant to demonstrate read models may be very specialised and decoupled 16 | 17 | class StudentListReadModel(private val studentListStore: SingleDocumentStore) { 18 | fun allStudents(): StudentList = studentListStore.get() 19 | } 20 | 21 | class StudentListProjection(private val studentListStore: SingleDocumentStore) : Handles { 22 | override fun handle(event: Event) { 23 | when (event) { 24 | is NewStudentRegistered -> { 25 | val new = event.toStudent() 26 | log.debug("Add new Student {} to the read-model and sort the list alphabetically by fullName", new) 27 | val newSortedList = ((studentListStore.get()) + new).sortedBy { it.fullName } 28 | studentListStore.save(newSortedList) 29 | } 30 | } 31 | } 32 | 33 | private fun NewStudentRegistered.toStudent() = Student(this.studentId, this.fullName) 34 | 35 | companion object { 36 | private val log: Logger = LoggerFactory.getLogger(StudentListProjection::class.java) 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/readmodels/trainingclasses/TrainingClassProjection.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels.trainingclasses 2 | 3 | import arrow.core.getOrElse 4 | import eventsourcing.domain.* 5 | import eventsourcing.readmodels.DocumentStore 6 | import eventsourcing.readmodels.InconsistentReadModelException 7 | import eventsourcing.readmodels.SingleDocumentStore 8 | import org.slf4j.Logger 9 | import org.slf4j.LoggerFactory 10 | 11 | // TODO Add the ability to rebuild the Read Model, re-streaming all events. Possibly on InconsistentReadModelException 12 | 13 | // Note the projection assumes events are always consistent. 14 | // No need to validate data and any exceptions mean something went wrong. 15 | 16 | class TrainingClassProjection( 17 | private val trainingClassDetailsStore: DocumentStore, 18 | private val trainingClassListStore: SingleDocumentStore, 19 | private val studentsContactsStore: DocumentStore) 20 | : Handles { 21 | 22 | override fun handle(event: Event) { 23 | when (event) { 24 | 25 | is NewClassScheduled -> { 26 | log.debug("Adding new TrainingClass {} in read model", event.classId) 27 | trainingClassDetailsStore.addNewClass(event.toTrainingClassDetails()) 28 | trainingClassListStore.addNewClassAndSort(event.toTrainingClass()) 29 | } 30 | 31 | is StudentEnrolled -> { 32 | val classId = event.classId 33 | val studentId = event.studentId 34 | log.debug("Adding Student {} to Class {} in read model", studentId, classId) 35 | val student: EnrolledStudent = studentsContactsStore.lookupByStudentIdOrFail(studentId).toEnrolledStudent() 36 | trainingClassDetailsStore.addStudentToClass(classId, student, event.version!!) 37 | // No need to update the class list 38 | } 39 | 40 | is StudentUnenrolled -> { 41 | log.debug("Removing Student {} from Class {} in read model", event.studentId, event.classId) 42 | val student = studentsContactsStore.lookupByStudentIdOrFail(event.studentId).toEnrolledStudent() 43 | trainingClassDetailsStore.removeStudentFromClass(event.classId, student, event.version!!) 44 | // No need to update the class list 45 | } 46 | 47 | is NewStudentRegistered -> { 48 | log.debug("New Student {}. Adding to list of Student Contacts in read model", event.studentId) 49 | studentsContactsStore.addNewStudentContacts(event.toStudentContacts()) 50 | } 51 | } 52 | } 53 | 54 | companion object { 55 | val log: Logger = LoggerFactory.getLogger(TrainingClassProjection::class.java) 56 | } 57 | } 58 | 59 | private fun NewClassScheduled.toTrainingClassDetails(): TrainingClassDetails = 60 | TrainingClassDetails( 61 | classId = this.classId, 62 | title = this.title, 63 | date = this.date, 64 | totalSize = this.classSize, 65 | availableSpots = this.classSize, 66 | students = emptyList(), 67 | version = this.version!!) 68 | 69 | private fun NewClassScheduled.toTrainingClass(): TrainingClass = 70 | TrainingClass( 71 | classId = this.classId, 72 | title = this.title, 73 | date = this.date) 74 | 75 | private fun StudentContacts.toEnrolledStudent() = 76 | EnrolledStudent( 77 | studentId = this.studentId, 78 | contactType = "email", 79 | contact = this.email) // We only support email contacts ;) 80 | 81 | private fun NewStudentRegistered.toStudentContacts() = StudentContacts(this.studentId, this.email) 82 | 83 | private fun DocumentStore.addNewClass(newClass: TrainingClassDetails) { 84 | TrainingClassProjection.log.trace("Add Class to TrainingClassDetails view: {}", newClass) 85 | this.save(newClass.classId, newClass) 86 | } 87 | 88 | private fun SingleDocumentStore.addNewClassAndSort(newClass: TrainingClass) { 89 | TrainingClassProjection.log.trace("Add Class to TrainingClassList view: {}", newClass) 90 | this.save(((this.get()) + newClass).sortedBy { it.date }) 91 | } 92 | 93 | private fun DocumentStore.lookupByStudentIdOrFail(studentId: String): StudentContacts = 94 | // If the StudentContacts is not there, the Read Model is stale 95 | this.get(studentId).getOrElse { throw InconsistentReadModelException } 96 | 97 | 98 | private fun DocumentStore.lookupByClassIdOrFail(classId: ClassID): TrainingClassDetails = 99 | // If TrainingClassDetails is not there, the Read Model is stale 100 | this.get(classId).getOrElse { throw InconsistentReadModelException } 101 | 102 | 103 | private fun DocumentStore.addStudentToClass(classId: String, student: EnrolledStudent, newVersion: Long) { 104 | val old: TrainingClassDetails = this.lookupByClassIdOrFail(classId) 105 | val new = old.copy( 106 | availableSpots = old.availableSpots - 1, 107 | students = old.students + student, 108 | version = newVersion) 109 | TrainingClassProjection.log.trace("Adding Student to Class in TrainingClassDetails view. Updating {} -> {}", old, new) 110 | this.save(classId, new) 111 | } 112 | 113 | private fun DocumentStore.removeStudentFromClass(classId: String, student: EnrolledStudent, newVersion: Long) { 114 | val old: TrainingClassDetails = this.lookupByClassIdOrFail(classId) 115 | val new = old.copy( 116 | availableSpots = old.availableSpots + 1, 117 | students = old.students - student, 118 | version = newVersion) 119 | 120 | TrainingClassProjection.log.trace("Removing Student from Class in TrainingClassDetails view. Updating {} -> {}", old, new) 121 | this.save(classId, new) 122 | } 123 | 124 | private fun DocumentStore.addNewStudentContacts(newStudent: StudentContacts) { 125 | TrainingClassProjection.log.trace("Adding StudentContacts to StudentContacts view: {}", newStudent) 126 | this.save(newStudent.studentId, newStudent) 127 | } -------------------------------------------------------------------------------- /src/main/kotlin/eventsourcing/readmodels/trainingclasses/TrainingClassReadModel.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels.trainingclasses 2 | 3 | import arrow.core.Option 4 | import eventsourcing.readmodels.DocumentStore 5 | import eventsourcing.readmodels.SingleDocumentStore 6 | import java.time.LocalDate 7 | 8 | // This Read Model keep an internal view with Student contacts of ALL students (not just those enrolled). This provides 9 | // the information required to add the enrolled student to the class and not included in the StudentEnrolled 10 | // While the TrainingClass state is updated listening to NewClassScheduled, StudentEnrolled, StudentUnenrolled events 11 | // the secondary view is updated when a NewStudentRegistered is received 12 | 13 | data class TrainingClassDetails ( 14 | val classId: String, 15 | val title : String, 16 | val date: LocalDate, 17 | val totalSize: Int, 18 | val availableSpots: Int, 19 | val students : List, 20 | val version: Long ) 21 | 22 | data class EnrolledStudent ( 23 | val studentId: String, 24 | val contactType : String, 25 | val contact: String ) 26 | 27 | data class StudentContacts ( 28 | val studentId: String, 29 | val email: String ) 30 | 31 | data class TrainingClass ( 32 | val classId: String, 33 | val title : String, 34 | val date: LocalDate) 35 | 36 | typealias TrainingClassList = List 37 | 38 | 39 | class TrainingClassReadModel ( 40 | private val trainingClassDetailsStore: DocumentStore, 41 | private val trainingClassListStore: SingleDocumentStore) { 42 | 43 | fun allClasses() : TrainingClassList = 44 | trainingClassListStore.get() 45 | 46 | fun getTrainingClassDetailsById(classId: String) : Option = 47 | trainingClassDetailsStore.get(classId) 48 | 49 | // Note the Student Contacts view is not exposed. It is only used internally 50 | } 51 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | logging: 2 | level: 3 | org.springframework: ERROR 4 | eventsourcing: TRACE 5 | 6 | spring: 7 | main: 8 | banner-mode: "off" 9 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/Retry.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing 2 | 3 | import org.opentest4j.AssertionFailedError 4 | import org.slf4j.Logger 5 | import org.slf4j.LoggerFactory 6 | 7 | object Retry { 8 | val log: Logger = LoggerFactory.getLogger(Retry::class.java) 9 | 10 | /** 11 | * Non-suspended, retry on AssertionFailedError 12 | */ 13 | inline fun retryOnAssertionFailure(times: Int, retryDelay: Long = 100L, block: (Int) -> T): T { 14 | var ex: Throwable? = null 15 | repeat(times) { i -> 16 | try { 17 | return block(i) 18 | } catch (e: AssertionFailedError) { 19 | log.trace("Retry #{} failed with {}", i, e) 20 | Thread.sleep(retryDelay) 21 | ex = e 22 | } 23 | } 24 | throw ex!! /* rethrow last failure */ 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/TestUtils.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing 2 | 3 | import arrow.core.* 4 | import eventsourcing.domain.AggregateRoot 5 | import eventsourcing.domain.Event 6 | import eventsourcing.domain.Result 7 | import org.assertj.core.api.AbstractAssert 8 | import org.assertj.core.api.Assertions 9 | import kotlin.reflect.KClass 10 | 11 | internal class EventsAssert(actual: Option>) : AbstractAssert>>(actual, EventsAssert::class.java) { 12 | 13 | private fun Option>.safeExtract() : Iterable = 14 | this.getOrElse { emptyList() } 15 | 16 | fun isDefined() : EventsAssert { 17 | if ( actual is None ) 18 | failWithMessage("Expected Some> but was ") 19 | return this 20 | } 21 | 22 | fun isNone() : EventsAssert { 23 | if ( actual is Some<*>) 24 | failWithMessage("Expected None but was %s", actual) 25 | return this 26 | } 27 | 28 | fun containsNoEvent(): EventsAssert { 29 | Assertions.assertThat(actual.safeExtract()).isEmpty() 30 | return this 31 | } 32 | 33 | fun contains(expectedSize: Int): EventsAssert { 34 | Assertions.assertThat(actual.safeExtract()).hasSize(expectedSize) 35 | return this 36 | } 37 | 38 | fun containsAllInOrder(expected: List): EventsAssert { 39 | for ((i, actualEvent) in actual.safeExtract().withIndex()) { 40 | Assertions.assertThat(actualEvent).isEqualTo(expected[i]) 41 | } 42 | return this 43 | } 44 | 45 | fun onlyContainsInOrder(expected: List): EventsAssert = 46 | this.contains(expected.size).containsAllInOrder(expected) 47 | 48 | fun onlyContains(expected: Event): EventsAssert = 49 | this.onlyContainsInOrder(listOf(expected)) 50 | 51 | fun containsAllEventTypesInOrder(expected: List>): EventsAssert { 52 | for ((i, actualEvent) in actual.safeExtract().withIndex()) { 53 | Assertions.assertThat(actualEvent).isInstanceOf(expected[i]) 54 | } 55 | return this 56 | } 57 | 58 | fun onlyContainsEventTypesInOrder(expected: List>): EventsAssert = 59 | this.contains(expected.size).containsAllEventTypesInOrder(expected) 60 | 61 | fun onlyContainsAnEventOfType(expected: Class<*>): EventsAssert = 62 | this.onlyContainsEventTypesInOrder(listOf(expected)) 63 | 64 | 65 | fun containsNoEvents(): EventsAssert = contains(0) 66 | 67 | 68 | companion object { 69 | fun assertThatAggregateUncommitedChanges(aggregate: AggregateRoot?): EventsAssert = 70 | EventsAssert(Some(aggregate?.getUncommittedChanges() ?: emptyList() )) 71 | 72 | fun assertThatEvents(actual: Option>): EventsAssert = EventsAssert(actual) 73 | 74 | fun assertThatEvents(actual: Iterable?): EventsAssert = EventsAssert(Option.fromNullable(actual)) 75 | } 76 | } 77 | 78 | internal class ResultAssert(actual: Result) : AbstractAssert, Result>(actual, ResultAssert::class.java) { 79 | 80 | fun isFailure() : ResultAssert { 81 | if ( actual.isRight()) 82 | failWithMessage("Expected but was <%s>", actual) 83 | return this 84 | } 85 | 86 | fun isSuccess() : ResultAssert { 87 | if ( actual.isLeft()) 88 | failWithMessage("Expected but was <%s>", actual) 89 | return this 90 | } 91 | 92 | fun failureIsEqualTo(expected: A) : ResultAssert { 93 | if( expected != actual.swap().getOrElse { null } ) 94 | failWithMessage("Expected but was <%s>", expected, actual) 95 | return this 96 | } 97 | 98 | inline fun failureIsA(): ResultAssert { 99 | if ( actual.isRight() || actual.getOrElse { null } is T ) 100 | failWithMessage("Expected > but was <%s>", T::class.simpleName, typeOfFailure().map { it.simpleName } ) 101 | return this 102 | } 103 | 104 | fun successIsEqualTo(expected: B) : ResultAssert { 105 | if ( expected != actual.getOrElse { null }) 106 | failWithMessage("Expected but was <%s>", expected, actual) 107 | return this 108 | } 109 | 110 | inline fun successIsA(): ResultAssert { 111 | if ( actual.isLeft() || actual.swap().getOrElse { null } is T) 112 | failWithMessage("Expected > but was <%s>", T::class.simpleName, typeOfSuccess().map { it.simpleName } ) 113 | return this 114 | } 115 | 116 | private fun typeOfSuccess(): Option> { 117 | val right : Any? = actual.getOrElse { null } 118 | return when (right) { 119 | is Any -> Some(right::class) 120 | else -> None 121 | } 122 | } 123 | 124 | private fun typeOfFailure(): Option> { 125 | val left : Any? = actual.swap().getOrElse { null } 126 | return when (left) { 127 | is Any -> Some(left::class) 128 | else -> None 129 | } 130 | } 131 | 132 | fun extractSuccess(): B? = actual.getOrElse { null } 133 | 134 | fun extractFailure(): A? = actual.swap().getOrElse { null } 135 | 136 | companion object { 137 | fun assertThatResult(actual: Result) : ResultAssert = ResultAssert(actual) 138 | } 139 | } 140 | 141 | internal class OptionAssert(actual: Option): AbstractAssert, Option>(actual, OptionAssert::class.java) { 142 | 143 | fun isEmpty(): OptionAssert { 144 | if ( actual.isDefined()) failWithMessage("Expected but was <%s>", actual) 145 | return this 146 | } 147 | 148 | fun isDefined(): OptionAssert { 149 | if ( actual.isEmpty()) failWithMessage("Expected but was <%s>", actual) 150 | return this 151 | } 152 | 153 | fun extract(): A? = actual.getOrElse { null } 154 | 155 | fun contains(expected: A): OptionAssert { 156 | if( expected != actual.getOrElse { null }) failWithMessage("Expected but was <%s>", expected, actual) 157 | return this 158 | } 159 | 160 | companion object { 161 | fun assertThatOption(actual: Option) : OptionAssert = OptionAssert(actual) 162 | } 163 | } 164 | 165 | 166 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/api/StudentCommandControllerIT.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.api 2 | 3 | import arrow.core.Right 4 | import com.nhaarman.mockitokotlin2.* 5 | import eventsourcing.api.TrainingClassCommandControllerIT.Companion.asJsonString 6 | import eventsourcing.domain.RegisterNewStudent 7 | import eventsourcing.domain.RegisterNewStudentSuccess 8 | import org.assertj.core.api.Assertions 9 | import org.hamcrest.core.StringEndsWith 10 | import org.junit.jupiter.api.BeforeEach 11 | import org.junit.jupiter.api.Test 12 | import org.junit.jupiter.api.extension.ExtendWith 13 | import org.springframework.http.MediaType 14 | import org.springframework.test.context.junit.jupiter.SpringExtension 15 | import org.springframework.test.web.servlet.MockMvc 16 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders 17 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers 18 | import org.springframework.test.web.servlet.setup.MockMvcBuilders 19 | 20 | @ExtendWith(SpringExtension::class) 21 | internal class StudentCommandControllerIT() { 22 | lateinit var mvc : MockMvc 23 | lateinit var dispatcher: CommandDispatcher 24 | 25 | @BeforeEach 26 | fun setup() { 27 | dispatcher = mock() 28 | mvc = MockMvcBuilders.standaloneSetup(StudentCommandController(dispatcher)).build() 29 | } 30 | 31 | private val aStudentId = "STUDENT-42" 32 | 33 | @Test 34 | fun `given a POST to Register new Student endpoint, when command processing succeeds, then it returns 202 ACCEPTED with Student Location header`() { 35 | whenever(dispatcher.handle(any())) 36 | .thenReturn( Right(RegisterNewStudentSuccess(aStudentId))) 37 | 38 | val request = RegisterNewStudentRequest("test@ema.il", "Full Name") 39 | mvc.perform(MockMvcRequestBuilders.post("/students/register") 40 | .contentType(MediaType.APPLICATION_JSON) 41 | .content(request.asJsonString())) 42 | .andExpect(MockMvcResultMatchers.status().isAccepted) 43 | .andExpect(MockMvcResultMatchers.header().string("Location", StringEndsWith.endsWith("/students/$aStudentId"))) 44 | 45 | verify(dispatcher).handle( check { 46 | Assertions.assertThat(it.email).isEqualTo( request.email ) 47 | Assertions.assertThat(it.fullName).isEqualTo( request.fullName) 48 | }) 49 | verifyNoMoreInteractions(dispatcher) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/api/StudentReadControllerIT.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.api 2 | 3 | import arrow.core.None 4 | import arrow.core.Some 5 | import com.nhaarman.mockitokotlin2.eq 6 | import com.nhaarman.mockitokotlin2.mock 7 | import com.nhaarman.mockitokotlin2.whenever 8 | import eventsourcing.readmodels.studentdetails.StudentDetails 9 | import eventsourcing.readmodels.studentdetails.StudentDetailsReadModel 10 | import eventsourcing.readmodels.studentlist.Student 11 | import eventsourcing.readmodels.studentlist.StudentListReadModel 12 | import org.junit.jupiter.api.BeforeEach 13 | import org.junit.jupiter.api.Test 14 | import org.junit.jupiter.api.extension.ExtendWith 15 | import org.springframework.http.MediaType 16 | import org.springframework.test.context.junit.jupiter.SpringExtension 17 | import org.springframework.test.web.servlet.MockMvc 18 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders 19 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers 20 | import org.springframework.test.web.servlet.setup.MockMvcBuilders 21 | 22 | @ExtendWith(SpringExtension::class) 23 | internal class StudentReadControllerIT { 24 | lateinit var mvc : MockMvc 25 | lateinit var studentDetailsReadModel : StudentDetailsReadModel 26 | lateinit var studentListReadModel : StudentListReadModel 27 | 28 | @BeforeEach 29 | fun setup() { 30 | studentDetailsReadModel = mock() 31 | studentListReadModel = mock() 32 | mvc = MockMvcBuilders.standaloneSetup(StudentReadController(studentDetailsReadModel, studentListReadModel)).build() 33 | } 34 | 35 | @Test 36 | fun `when I hit the GET Student endpoint with the Student ID, then it returns the Student representation in JSON`() { 37 | whenever(studentDetailsReadModel.getStudentById(eq("STUDENT001"))).thenReturn(Some(aStudentDetails)) 38 | 39 | mvc.perform(MockMvcRequestBuilders.get("/students/STUDENT001").accept(MediaType.APPLICATION_JSON)) 40 | .andExpect(MockMvcResultMatchers.status().isOk) 41 | .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON_UTF8)) 42 | .andExpect(MockMvcResultMatchers.jsonPath("""$.studentId""").value(aStudentDetails.studentId)) 43 | .andExpect(MockMvcResultMatchers.jsonPath("""$.version""").value(aStudentDetails.version)) 44 | } 45 | 46 | @Test 47 | fun `when I hit the GET Student endpoint with a non-existing Student ID, then it returns 404`() { 48 | whenever(studentDetailsReadModel.getStudentById(eq("DO-NOT-EXISTS"))).thenReturn( None ) 49 | 50 | mvc.perform(MockMvcRequestBuilders.get("/classes/001").accept(MediaType.APPLICATION_JSON)) 51 | .andExpect(MockMvcResultMatchers.status().isNotFound) 52 | } 53 | 54 | @Test 55 | fun `when I hit the GET all Students, then it returns a JSON representation of a list containing all Students`() { 56 | whenever(studentListReadModel.allStudents()).thenReturn( listOf(aStudent, anotherStudent)) 57 | 58 | mvc.perform(MockMvcRequestBuilders.get("/students").accept(MediaType.APPLICATION_JSON)) 59 | .andExpect(MockMvcResultMatchers.status().isOk) 60 | .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON_UTF8)) 61 | .andExpect(MockMvcResultMatchers.jsonPath("""$.[0].studentId""").value(aStudent.studentId)) 62 | .andExpect(MockMvcResultMatchers.jsonPath("""$.[1].studentId""").value(anotherStudent.studentId)) 63 | } 64 | } 65 | 66 | private val aStudentDetails = StudentDetails( 67 | studentId = "STUDENT001", 68 | email = "test@ema.il", 69 | fullName = "Full Name", 70 | version = 0L 71 | ) 72 | 73 | private val anotherStudentDetails = StudentDetails( 74 | studentId = "STUDENT002", 75 | email = "test2@ema.il", 76 | fullName = "Another Name", 77 | version = 2L 78 | ) 79 | 80 | private val aStudent = Student(aStudentDetails.studentId, aStudentDetails.fullName) 81 | 82 | private val anotherStudent = Student(anotherStudentDetails.studentId, anotherStudentDetails.fullName) 83 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/api/TrainingClassCommandControllerIT.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.api 2 | 3 | import arrow.core.Left 4 | import arrow.core.Right 5 | import com.fasterxml.jackson.databind.ObjectMapper 6 | import com.nhaarman.mockitokotlin2.* 7 | import eventsourcing.domain.* 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.hamcrest.core.StringEndsWith.endsWith 10 | import org.junit.jupiter.api.BeforeEach 11 | import org.junit.jupiter.api.Test 12 | import org.junit.jupiter.api.extension.ExtendWith 13 | import org.springframework.http.MediaType 14 | import org.springframework.test.context.junit.jupiter.SpringExtension 15 | import org.springframework.test.web.servlet.MockMvc 16 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post 17 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* 18 | import org.springframework.test.web.servlet.setup.MockMvcBuilders 19 | import java.time.LocalDate 20 | 21 | @ExtendWith(SpringExtension::class) 22 | internal class TrainingClassCommandControllerIT() { 23 | 24 | lateinit var mvc : MockMvc 25 | lateinit var dispatcher: CommandDispatcher 26 | 27 | @BeforeEach 28 | fun setup() { 29 | dispatcher = mock() 30 | mvc = MockMvcBuilders.standaloneSetup(TrainingClassCommandController(dispatcher)).build() 31 | } 32 | 33 | private val aClassId = "CLASS001" 34 | 35 | @Test 36 | fun `given a POST to Schedule New Class endpoint, when command handling succeeds, then it returns 202 ACCEPTED with Class Location header`() { 37 | whenever(dispatcher.handle(any())) 38 | .thenReturn(Right(ScheduleNewClassSuccess(aClassId))) 39 | 40 | val request = ScheduleNewClassRequest("Class Title", LocalDate.now(), 10) 41 | mvc.perform(post("/classes/schedule_new") 42 | .contentType(MediaType.APPLICATION_JSON) 43 | .content(request.asJsonString())) 44 | .andExpect(status().isAccepted) 45 | .andExpect(header().string("Location", endsWith("/classes/$aClassId"))) 46 | 47 | verify(dispatcher).handle( check { 48 | assertThat(it.title).isEqualTo( request.title ) 49 | assertThat(it.date).isEqualTo( request.date) 50 | assertThat(it.size).isEqualTo( request.size) 51 | }) 52 | verifyNoMoreInteractions(dispatcher) 53 | } 54 | 55 | 56 | 57 | private val studentId = "STUDENT-42" 58 | private val enrollStudentRequest = EnrollStudentRequest(studentId, 0L) 59 | 60 | @Test 61 | fun `given a POST to Enroll Student endpoint, when command handling succeeds, then it returns 202 ACCEPTED with class Location header`() { 62 | whenever(dispatcher.handle(any())) 63 | .thenReturn(Right(EnrollStudentSuccess)) 64 | 65 | mvc.perform(post("/classes/$aClassId/enroll_student") 66 | .contentType(MediaType.APPLICATION_JSON) 67 | .content(enrollStudentRequest.asJsonString())) 68 | .andExpect(status().isAccepted) 69 | .andExpect(header().string("Location", endsWith("/classes/$aClassId"))) 70 | 71 | verify(dispatcher).handle(check{ 72 | assertThat(it.classId).isEqualTo(aClassId) 73 | assertThat(it.studentId).isEqualTo(enrollStudentRequest.studentId) 74 | assertThat(it.expectedVersion).isEqualTo(enrollStudentRequest.classVersion) 75 | }) 76 | verifyNoMoreInteractions(dispatcher) 77 | } 78 | 79 | @Test 80 | fun `given a POST to Enroll Student endpoint, when command handling fails because the Class does not exist, then it returns 404 NOT FOUND`(){ 81 | whenever(dispatcher.handle(any())) 82 | .thenReturn(Left(AggregateNotFound)) 83 | 84 | mvc.perform(post("/classes/$aClassId/enroll_student") 85 | .contentType(MediaType.APPLICATION_JSON) 86 | .content(enrollStudentRequest.asJsonString())) 87 | .andExpect(status().isNotFound) 88 | } 89 | 90 | @Test 91 | fun `given a POST to Enroll Student endpoint, when command handling fails because of insufficient spots in the class, then it returns 422 UNPROCESSABLE ENTITY and a JSON body containing the error`() { 92 | whenever(dispatcher.handle(any())) 93 | .thenReturn(Left(TrainingClassInvariantViolation.ClassHasNoAvailableSpots)) 94 | 95 | mvc.perform(post("/classes/$aClassId/enroll_student") 96 | .contentType(MediaType.APPLICATION_JSON) 97 | .content(enrollStudentRequest.asJsonString())) 98 | .andExpect(status().isUnprocessableEntity) 99 | .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8)) 100 | .andExpect(jsonPath("""$.message""").value("No available spots")) 101 | } 102 | 103 | @Test 104 | fun `given a POST to Enroll Student endpoint, when command handling fails because the student is already enrolled, then it returns 422 UNPROCESSABLE ENTITY and a JSON body containing the error`(){ 105 | whenever(dispatcher.handle(any())) 106 | .thenReturn(Left(TrainingClassInvariantViolation.StudentAlreadyEnrolled)) 107 | 108 | mvc.perform(post("/classes/$aClassId/enroll_student") 109 | .contentType(MediaType.APPLICATION_JSON) 110 | .content(enrollStudentRequest.asJsonString())) 111 | .andExpect(status().isUnprocessableEntity) 112 | .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8)) 113 | .andExpect(jsonPath("""$.message""").value("Student already enrolled")) 114 | } 115 | 116 | @Test 117 | fun `given a POST to Enroll Student endpoint, when command handling fails for a concurrency issue, then it returns 409 CONFLICT and a JSON body containing the error`(){ 118 | whenever(dispatcher.handle(any())) 119 | .thenReturn(Left(EventStoreFailure.ConcurrentChangeDetected)) 120 | 121 | 122 | mvc.perform(post("/classes/$aClassId/enroll_student") 123 | .contentType(MediaType.APPLICATION_JSON) 124 | .content(enrollStudentRequest.asJsonString())) 125 | .andExpect(status().isConflict) 126 | .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8)) 127 | .andExpect(jsonPath("""$.message""").value("Concurrent change detected")) 128 | } 129 | 130 | 131 | val unenrollStudentRequest = UnenrollStudentRequest(studentId, "some reasons", 1L) 132 | 133 | @Test 134 | fun `given a POST to Unenroll Student endpoint, when command handling succeeds, then it returns 202 ACCEPTED with class Location header`() { 135 | whenever(dispatcher.handle(any())) 136 | .thenReturn(Right(UnenrollStudentSuccess)) 137 | 138 | mvc.perform(post("/classes/$aClassId/unenroll_student") 139 | .contentType(MediaType.APPLICATION_JSON) 140 | .content(unenrollStudentRequest.asJsonString())) 141 | .andExpect(status().isAccepted) 142 | .andExpect(header().string("Location", endsWith("/classes/$aClassId"))) 143 | 144 | verify(dispatcher).handle(check{ 145 | assertThat(it.classId).isEqualTo(aClassId) 146 | assertThat(it.studentId).isEqualTo(unenrollStudentRequest.studentId) 147 | assertThat(it.expectedVersion).isEqualTo(unenrollStudentRequest.classVersion) 148 | }) 149 | verifyNoMoreInteractions(dispatcher) 150 | 151 | } 152 | 153 | @Test 154 | fun `given a POST to Unenroll Student endpoint, when command handling fails because the Class does not exist, then it returns 404 NOT FOUND`(){ 155 | whenever(dispatcher.handle(any())) 156 | .thenReturn(Left(AggregateNotFound)) 157 | 158 | mvc.perform(post("/classes/$aClassId/unenroll_student") 159 | .contentType(MediaType.APPLICATION_JSON) 160 | .content(unenrollStudentRequest.asJsonString())) 161 | .andExpect(status().isNotFound) 162 | } 163 | 164 | @Test 165 | fun `given a POST to Unenroll Student endpoint, when command handling fails because the student is not enrolled, then it returns 422 UNPROCESSABLE ENTITY and a JSON body containing the error`(){ 166 | whenever(dispatcher.handle(any())) 167 | .thenReturn(Left(TrainingClassInvariantViolation.UnenrollingNotEnrolledStudent)) 168 | 169 | mvc.perform(post("/classes/$aClassId/unenroll_student") 170 | .contentType(MediaType.APPLICATION_JSON) 171 | .content(unenrollStudentRequest.asJsonString())) 172 | .andExpect(status().isUnprocessableEntity) 173 | .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8)) 174 | .andExpect(jsonPath("""$.message""").value("Student not enrolled")) 175 | } 176 | 177 | @Test 178 | fun `given POST to Unenroll Student endpoint, when command handling fails for a concurrency issue, then it returns 409 CONFLICT and a JSON body containing the error`(){ 179 | whenever(dispatcher.handle(any())) 180 | .thenReturn(Left(EventStoreFailure.ConcurrentChangeDetected)) 181 | 182 | mvc.perform(post("/classes/$aClassId/unenroll_student") 183 | .contentType(MediaType.APPLICATION_JSON) 184 | .content(unenrollStudentRequest.asJsonString())) 185 | .andExpect(status().isConflict) 186 | .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8)) 187 | .andExpect(jsonPath("""$.message""").value("Concurrent change detected")) 188 | } 189 | 190 | companion object { 191 | private val mapper = ObjectMapper().findAndRegisterModules() 192 | fun Any.asJsonString(): String = mapper.writeValueAsString(this) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/api/TrainingClassReadControllerIT.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.api 2 | 3 | import arrow.core.None 4 | import arrow.core.Some 5 | import com.nhaarman.mockitokotlin2.eq 6 | import com.nhaarman.mockitokotlin2.mock 7 | import com.nhaarman.mockitokotlin2.whenever 8 | import eventsourcing.readmodels.trainingclasses.EnrolledStudent 9 | import eventsourcing.readmodels.trainingclasses.TrainingClass 10 | import eventsourcing.readmodels.trainingclasses.TrainingClassDetails 11 | 12 | import eventsourcing.readmodels.trainingclasses.TrainingClassReadModel 13 | import org.junit.jupiter.api.BeforeEach 14 | import org.junit.jupiter.api.Test 15 | import org.junit.jupiter.api.extension.ExtendWith 16 | import org.springframework.http.MediaType 17 | import org.springframework.test.context.junit.jupiter.SpringExtension 18 | import org.springframework.test.web.servlet.MockMvc 19 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get 20 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* 21 | import org.springframework.test.web.servlet.setup.MockMvcBuilders 22 | import java.time.LocalDate 23 | import java.util.* 24 | 25 | 26 | @ExtendWith(SpringExtension::class) 27 | internal class TrainingClassReadControllerIT { 28 | 29 | lateinit var mvc : MockMvc 30 | lateinit var trainingClassReadModel: TrainingClassReadModel 31 | 32 | @BeforeEach 33 | fun setup() { 34 | trainingClassReadModel = mock() 35 | mvc = MockMvcBuilders.standaloneSetup(TrainingClassReadController(trainingClassReadModel)) 36 | .build() 37 | } 38 | 39 | @Test 40 | fun `when I hit the GET Class endpoint with the class ID, then it returns the class representation in JSON`() { 41 | whenever(trainingClassReadModel.getTrainingClassDetailsById(eq("001"))).thenReturn( Some(aClassDetails) ) 42 | 43 | mvc.perform(get("/classes/001").accept(MediaType.APPLICATION_JSON)) 44 | .andExpect(status().isOk) 45 | .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8)) 46 | .andExpect(jsonPath("""$.classId""").value(aClassDetails.classId)) 47 | .andExpect(jsonPath("""$.version""").value(aClassDetails.version)) 48 | } 49 | 50 | @Test 51 | fun `when I hit the GET Class endpoint with a non-existing Class ID, then it returns 404`() { 52 | whenever(trainingClassReadModel.getTrainingClassDetailsById(eq("001"))).thenReturn(None) 53 | 54 | 55 | mvc.perform(get("/classes/001").accept(MediaType.APPLICATION_JSON)) 56 | .andExpect(status().isNotFound) 57 | } 58 | 59 | @Test 60 | fun `when I hit the GET all Classes, then it returns a JSON representation of a list containing all Classes`() { 61 | whenever(trainingClassReadModel.allClasses()).thenReturn( listOf(aClass, anotherClass)) 62 | 63 | mvc.perform(get("/classes").accept(MediaType.APPLICATION_JSON)) 64 | .andExpect(status().isOk) 65 | .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8)) 66 | .andExpect(jsonPath("""$.[0].classId""").value(aClass.classId)) 67 | .andExpect(jsonPath("""$.[1].classId""").value(anotherClass.classId)) 68 | } 69 | } 70 | 71 | private val aClassDetails = TrainingClassDetails( 72 | classId = "001", 73 | title = "Class title", 74 | date = LocalDate.now(), 75 | totalSize = 10, 76 | availableSpots = 9, 77 | students = listOf( EnrolledStudent("STUDENT-001", "email","test1@ema.il") ), 78 | version = 47L) 79 | 80 | private val aClass = TrainingClass( 81 | classId = aClassDetails.classId, 82 | title = aClassDetails.title, 83 | date = aClassDetails.date) 84 | 85 | private val anotherClassDetails = TrainingClassDetails( 86 | classId = "002", 87 | title = "Another class title", 88 | date = LocalDate.now(), 89 | totalSize = 15, 90 | availableSpots = 13, 91 | students = listOf( 92 | EnrolledStudent("STUDENT-001", "email","test1@ema.il"), 93 | EnrolledStudent("STUDENT-002", "email","test2@ema.il")), 94 | version = 3L) 95 | 96 | private val anotherClass = TrainingClass( 97 | classId = anotherClassDetails.classId, 98 | title = anotherClassDetails.title, 99 | date = anotherClassDetails.date) -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/domain/EventSourcedRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.* 4 | import com.nhaarman.mockitokotlin2.* 5 | import eventsourcing.EventsAssert.Companion.assertThatAggregateUncommitedChanges 6 | import eventsourcing.OptionAssert.Companion.assertThatOption 7 | import eventsourcing.domain.TrainingClass.Companion.scheduleNewClass 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.junit.jupiter.api.Test 10 | import java.time.LocalDate 11 | 12 | internal class EventSourcedRepositoryTest { 13 | 14 | @Test 15 | fun `given an empty event-sourced repo, when I save a new Aggregate, then all uncommitted events are saved to the Event Store and no uncommited events remains in the Aggregate`() { 16 | val eventStore = givenAnEventStore() 17 | val sut = eventSourcedRepo(eventStore) 18 | 19 | val clazz = justScheduledTrainingClass().withStudentEnrollment() 20 | assertThatAggregateUncommitedChanges(clazz).contains(2) 21 | 22 | sut.save(clazz) 23 | 24 | assertThat(clazz.getUncommittedChanges()).isEmpty() 25 | verify(eventStore, times(1)).saveEvents(eq(TrainingClass.TYPE), eq(clazz.id), argForWhich { count() == 2 }, eq(None)) 26 | verifyNoMoreInteractions(eventStore) 27 | } 28 | 29 | @Test 30 | fun `given an event-sourced repo containing an Aggregate, when I get the Aggregate by ID, then all Events of the Aggregate are retrieved from the Event Store and I have an Aggregate with no uncommitted Events`() { 31 | val classId = "class-id" 32 | val eventStore = givenAnEventStoreContainingATrainingClass(classId) 33 | val sut = eventSourcedRepo(eventStore) 34 | 35 | val result = sut.getById(classId) 36 | 37 | val actualClass = assertThatOption(result).isDefined() 38 | .extract() 39 | assertThatAggregateUncommitedChanges(actualClass).containsNoEvent() 40 | 41 | verify(eventStore).getEventsForAggregate(eq(TrainingClass.TYPE), eq(classId)) 42 | verifyNoMoreInteractions(eventStore) 43 | } 44 | 45 | @Test 46 | fun `given an empty event-sourced repo, when I get an Aggregate by ID, then all Aggregate Events are requested to the Event Store but no Aggregate is returned`(){ 47 | val classId = "class-id" 48 | val eventStore = givenAnEventStore() 49 | val sut = eventSourcedRepo(eventStore) 50 | 51 | val clazz = sut.getById(classId) 52 | 53 | assertThatOption(clazz).isEmpty() 54 | 55 | verify(eventStore).getEventsForAggregate(eq(TrainingClass.TYPE), eq(classId)) 56 | verifyNoMoreInteractions(eventStore) 57 | } 58 | 59 | } 60 | 61 | private fun givenAnEventStore(): EventStore = mock() { 62 | on { saveEvents(any(), any(), any(), any()) }.thenReturn(Right(emptyList())) 63 | on { getEventsForAggregate(any(), any()) }.doReturn(None) 64 | } 65 | 66 | private fun givenAnEventStoreContainingATrainingClass(classId: ClassID): EventStore { 67 | val es = givenAnEventStore() 68 | whenever(es.getEventsForAggregate(eq(TrainingClass.TYPE), eq(classId))) 69 | .thenReturn(Some(listOf( 70 | NewClassScheduled(classId, "some-title", LocalDate.now(), 10), 71 | StudentEnrolled(classId, "a-student")))) 72 | return es 73 | } 74 | 75 | private fun justScheduledTrainingClass(): TrainingClass = 76 | scheduleNewClass("some-title", LocalDate.now(), 10).getOrElse { null }!! 77 | 78 | private fun TrainingClass.withStudentEnrollment(): TrainingClass = 79 | this.enrollStudent("A-STUDENT").getOrElse { null }!! 80 | 81 | private fun eventSourcedRepo(eventStore: EventStore): EventSourcedRepository = object : EventSourcedRepository(eventStore) { 82 | override fun new(id: AggregateID): TrainingClass = TrainingClass(id) 83 | } -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/domain/StudentCommandHandlersTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.None 4 | import arrow.core.Right 5 | import com.nhaarman.mockitokotlin2.* 6 | import eventsourcing.ResultAssert.Companion.assertThatResult 7 | import eventsourcing.EventsAssert.Companion.assertThatAggregateUncommitedChanges 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.junit.jupiter.api.Test 10 | 11 | class StudentCommandHandlersTest { 12 | 13 | @Test 14 | fun `given a Register New Student Command, when it is handled without any email clashes, then a new Student should be saved and a success containing the Student ID returned`(){ 15 | val command = RegisterNewStudent("test@ema.il", "Full Name") 16 | val repository = mockRepository() 17 | val emailIndex = mockEmptyRegisteredEmailIndex() 18 | val fut = handleRegisterNewStudent(repository, emailIndex) 19 | 20 | val result = fut(command) 21 | 22 | assertThatResult(result) 23 | .isSuccess() 24 | .successIsA() 25 | 26 | verify(emailIndex).isEmailAlreadyInUse(eq("test@ema.il")) 27 | verify(repository).save(check { 28 | assertThat(it).isInstanceOf(Student::class.java) 29 | assertThatAggregateUncommitedChanges(it).onlyContainsAnEventOfType(NewStudentRegistered::class.java) 30 | }, eq(None)) 31 | verifyNoMoreInteractions(repository) 32 | } 33 | 34 | @Test 35 | fun `given a Register New Student Command, when it is handled with a clash on duplicate email, then it fails with Email Already In Use`() { 36 | val duplicateEmail = "duplicate@ema.il" 37 | val command = RegisterNewStudent(duplicateEmail, "Full Name") 38 | val repository = mockRepository() 39 | val emailIndex = mockRegisteredEmailIndexContainingEmails() 40 | val fut = handleRegisterNewStudent(repository, emailIndex) 41 | 42 | val result = fut(command) 43 | 44 | assertThatResult(result) 45 | .isFailure() 46 | .failureIsA() 47 | 48 | verify(emailIndex).isEmailAlreadyInUse(eq("duplicate@ema.il")) 49 | verifyNoMoreInteractions(repository) 50 | } 51 | } 52 | 53 | private fun mockRepository(): StudentRepository = mock { 54 | on { getById(any()) }.doReturn(None) 55 | on { save(any(), any()) }.thenReturn(Right(ChangesSuccessfullySaved)) 56 | } 57 | 58 | 59 | 60 | private fun mockEmptyRegisteredEmailIndex(): RegisteredEmailsIndex = mock { 61 | on { isEmailAlreadyInUse(any())}.thenReturn(false) 62 | } 63 | 64 | private fun mockRegisteredEmailIndexContainingEmails(): RegisteredEmailsIndex = mock { 65 | on { isEmailAlreadyInUse(any())}.thenReturn(true) 66 | } 67 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/domain/StudentTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.None 4 | import arrow.core.Right 5 | import com.nhaarman.mockitokotlin2.* 6 | import eventsourcing.EventsAssert.Companion.assertThatAggregateUncommitedChanges 7 | import eventsourcing.ResultAssert.Companion.assertThatResult 8 | import org.junit.jupiter.api.Test 9 | 10 | internal class StudentTest { 11 | 12 | @Test 13 | fun `given a new student registration request, when the new Student email is not already in use, then it succeeds and creates a new Student and a NewStudentRegistered event is queued`() { 14 | val repository = mockRepository() 15 | val emailsIndex = mockEmptyRegisteredEmailIndex() 16 | val fut = Student.Companion::registerNewStudent 17 | 18 | val email = "test@ema.il" 19 | val result = fut(email, "John Doe", repository, emailsIndex) 20 | 21 | val newStudent = assertThatResult(result).isSuccess() 22 | .extractSuccess() 23 | 24 | assertThatAggregateUncommitedChanges(newStudent) 25 | .onlyContainsAnEventOfType(NewStudentRegistered::class.java) 26 | 27 | verify(emailsIndex).isEmailAlreadyInUse(eq(email)) 28 | verifyNoMoreInteractions(repository) 29 | } 30 | 31 | @Test 32 | fun `given a new student registration request, when email is already in use, then it fails with EmailAlreadyInUse`() { 33 | val email = "test@ema.il" 34 | val repository = mockRepository() 35 | val emailsIndex = mockRegisteredEmailIndexContainingEmails() 36 | val fut = Student.Companion::registerNewStudent 37 | 38 | val result = fut(email, "John Doe", repository, emailsIndex) 39 | 40 | assertThatResult(result) 41 | .isFailure() 42 | .failureIsA() 43 | 44 | verify(emailsIndex).isEmailAlreadyInUse(eq(email)) 45 | } 46 | } 47 | 48 | private fun mockRepository(): StudentRepository = mock { 49 | on { getById(any()) }.doReturn(None) 50 | on { save(any(), any()) }.thenReturn(Right(ChangesSuccessfullySaved)) 51 | } 52 | 53 | private fun mockEmptyRegisteredEmailIndex(): RegisteredEmailsIndex = mock { 54 | on { isEmailAlreadyInUse(any()) }.thenReturn(false) 55 | } 56 | 57 | private fun mockRegisteredEmailIndexContainingEmails(): RegisteredEmailsIndex = mock { 58 | on { isEmailAlreadyInUse(any()) }.thenReturn(true) 59 | } 60 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/domain/TrainingClassCommandHandlersTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.None 4 | import arrow.core.Right 5 | import arrow.core.Some 6 | import com.nhaarman.mockitokotlin2.* 7 | import eventsourcing.ResultAssert.Companion.assertThatResult 8 | import eventsourcing.EventsAssert.Companion.assertThatAggregateUncommitedChanges 9 | import org.assertj.core.api.Assertions.assertThat 10 | import org.junit.jupiter.api.Test 11 | import java.time.LocalDate 12 | 13 | internal class TrainingClassCommandHandlersTest { 14 | 15 | @Test 16 | fun `given a ScheduleNewClass command, when handled, then it succeeds, saves a new Class with a NewClassScheduled uncommited event and return a successful result with the ID of the class`() { 17 | val repository = mockEmptyRepository() 18 | val fut = handleScheduleNewClass(repository) 19 | 20 | val command = ScheduleNewClass("class-title", LocalDate.now(), 10) 21 | val result = fut(command) 22 | 23 | assertThatResult(result) 24 | .isSuccess() 25 | .successIsA() 26 | 27 | verify(repository).save(check { 28 | assertThat(it).isInstanceOf(TrainingClass::class.java) 29 | assertThatAggregateUncommitedChanges(it).onlyContainsAnEventOfType(NewClassScheduled::class.java) 30 | }, eq(None)) 31 | } 32 | 33 | 34 | @Test 35 | fun `given an EnrollStudent command, when successfully handled, then it retrieves the class by ID, enroll the student and save it back with the expected version, and return a successful result`() { 36 | val classId: ClassID = "a-class" 37 | val clazz = mock { 38 | on { enrollStudent(any()) }.thenAnswer { Right(this.mock) } 39 | } 40 | val repository = mockRepositoryContaining(clazz) 41 | val fut = handleEnrollStudent(repository) 42 | 43 | val command = EnrollStudent(classId, "a-student", 43L) 44 | val result = fut(command) 45 | 46 | assertThatResult(result).isSuccess() 47 | 48 | verify(clazz).enrollStudent(eq("a-student")) 49 | verify(repository).getById(eq(classId)) 50 | verify(repository).save(any(), eq(Some(43L))) 51 | } 52 | 53 | @Test 54 | fun `given an UnenrollStudent command, when handled, it should retrieve the class by ID, unenroll the student and save it back with the expected version, and return a successful result`() { 55 | val classId: ClassID = "a-class" 56 | val clazz = mock { 57 | on { unenrollStudent(any(), any()) }.thenAnswer { Right(this.mock) } 58 | } 59 | val repository = mockRepositoryContaining(clazz) 60 | val fut = handleUnenrollStudent(repository) 61 | 62 | val command = UnenrollStudent(classId, "a-student", "some reasons", 43L) 63 | val result = fut(command) 64 | 65 | assertThatResult(result).isSuccess() 66 | 67 | verify(clazz).unenrollStudent(eq("a-student"), eq("some reasons")) 68 | verify(repository).getById(eq(classId)) 69 | verify(repository).save(any(), eq(Some(43L))) 70 | } 71 | } 72 | 73 | private fun mockRepositoryContaining(clazz: TrainingClass): TrainingClassRepository = mock { 74 | on { getById(any()) }.doReturn(Some(clazz)) 75 | on { save(any(), any()) }.thenReturn(Right(ChangesSuccessfullySaved)) 76 | } 77 | 78 | private fun mockEmptyRepository(): TrainingClassRepository = mock { 79 | on { getById(any()) }.doReturn(None) 80 | on { save(any(), any()) }.thenReturn(Right(ChangesSuccessfullySaved)) 81 | } 82 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/domain/TrainingClassTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.domain 2 | 3 | import arrow.core.getOrElse 4 | import eventsourcing.ResultAssert.Companion.assertThatResult 5 | import eventsourcing.EventsAssert.Companion.assertThatAggregateUncommitedChanges 6 | import eventsourcing.domain.TrainingClass.Companion.scheduleNewClass 7 | import org.junit.jupiter.api.Test 8 | import java.time.LocalDate 9 | 10 | internal class TrainingClassTest { 11 | 12 | @Test 13 | fun `given a new Training Class scheduling request, when size greater than 0, then it succeeds returning te new class with a NewClassScheduled event queued`() { 14 | val fut = TrainingClass.Companion::scheduleNewClass 15 | 16 | val result = fut("Class Title", LocalDate.now(), 10) 17 | 18 | val newClass = assertThatResult(result) 19 | .isSuccess() 20 | .extractSuccess() 21 | assertThatAggregateUncommitedChanges(newClass) 22 | .onlyContainsAnEventOfType(NewClassScheduled::class.java) 23 | } 24 | 25 | @Test 26 | fun `given a new Training Class scheduling request, when size is less than 1, then it fails with an InvalidClassSize `() { 27 | val fut = TrainingClass.Companion::scheduleNewClass 28 | 29 | val result = fut("Class Title", LocalDate.now(), -1) 30 | 31 | assertThatResult(result) 32 | .isFailure() 33 | .failureIsA() 34 | } 35 | 36 | @Test 37 | fun `given a Training Class with size 10 and no enrolled Students, when I enroll a Student, then it succeeds and a StudentEnrolled event is queued`() { 38 | val sut = givenTrainingClassWithSize(10) 39 | 40 | val result = sut.enrollStudent("student-001") 41 | 42 | assertThatResult(result).isSuccess() 43 | 44 | assertThatAggregateUncommitedChanges(sut) 45 | .onlyContainsInOrder(listOf(StudentEnrolled(sut.id, "student-001"))) 46 | } 47 | 48 | @Test 49 | fun `given a Training Class with size of 1 and 1 enrolled Student, when I enroll a new Student, then it fails with ClassHasNoAvailableSpots and no event is queued `() { 50 | val sut = givenTrainingClassWithSizeAndOneEnrolledStudent(1, "STUDENT001") 51 | 52 | val result = sut.enrollStudent("ANOTHER-STUDENT") 53 | 54 | assertThatResult(result) 55 | .isFailure() 56 | .failureIsA() 57 | 58 | assertThatAggregateUncommitedChanges(sut).containsNoEvents() 59 | } 60 | 61 | @Test 62 | fun `given a Training Class with size 10 and 1 enrolled Student, when I enroll the same Student, then it fails with a StudentAlreadyEnrolled and no event is queued`() { 63 | val sut = givenTrainingClassWithSizeAndOneEnrolledStudent(10, "STUDENT001") 64 | 65 | val result = sut.enrollStudent("STUDENT001") 66 | 67 | assertThatResult(result) 68 | .isFailure() 69 | .failureIsA() 70 | 71 | assertThatAggregateUncommitedChanges(sut).containsNoEvents() 72 | } 73 | 74 | @Test 75 | fun `given a Training Class with many spots and an enrolled student, when I unenroll the student, then it succeeds and a StudentEnrolled event is queued`() { 76 | val sut = givenTrainingClassWithSizeAndOneEnrolledStudent(10, "STUDENT001") 77 | 78 | val result = sut.unenrollStudent("STUDENT001", "some reasons") 79 | 80 | assertThatResult(result).isSuccess() 81 | 82 | assertThatAggregateUncommitedChanges(sut) 83 | .onlyContainsInOrder(listOf(StudentUnenrolled(sut.id, "STUDENT001", "some reasons"))) 84 | } 85 | 86 | @Test 87 | fun `given a Training Class with no enrolled student, when I unenroll a Student, then it fails with UnenrollingNotEnrolledStudent and no queued event`() { 88 | val sut = givenTrainingClassWithSize(10) 89 | 90 | val result = sut.unenrollStudent("student-001", "some reasons") 91 | 92 | assertThatResult(result) 93 | .isFailure() 94 | .failureIsA() 95 | 96 | assertThatAggregateUncommitedChanges(sut).containsNoEvents() 97 | } 98 | 99 | } 100 | 101 | private fun givenTrainingClassWithSize(size: Int): TrainingClass { 102 | val clazz = scheduleNewClass("some-title", LocalDate.now(), size).getOrElse { null }!! 103 | clazz.markChangesAsCommitted() 104 | return clazz 105 | } 106 | 107 | private fun givenTrainingClassWithSizeAndOneEnrolledStudent(size: Int, studentId: StudentID): TrainingClass { 108 | val clazz = givenTrainingClassWithSize(size) 109 | clazz.enrollStudent(studentId) 110 | clazz.markChangesAsCommitted() 111 | return clazz 112 | } 113 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/end2end/AvailableSpotsRuleViolationE2ETest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.end2end 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.springframework.beans.factory.annotation.Autowired 5 | import org.springframework.boot.test.web.client.TestRestTemplate 6 | 7 | internal class AvailableSpotsRuleViolationE2ETest(@Autowired template : TestRestTemplate) : BaseE2EJourneyTest(template) { 8 | 9 | @Test 10 | fun `UNHAPPY | Schedule Class + Enroll too many Students and get rejected`() { 11 | val classURI = scheduleNewClass_withSize_isAccepted(2) 12 | var expectedClassVersion = 0L 13 | 14 | enrollStudent_isAccepted(classURI, "STUDENT001", expectedClassVersion) 15 | expectedClassVersion++ 16 | 17 | enrollStudent_isAccepted(classURI, "STUDENT002", expectedClassVersion) 18 | expectedClassVersion++ 19 | 20 | enrollStudent_isRejectedWith422(classURI, "STUDENT002", expectedClassVersion) 21 | } 22 | } -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/end2end/BaseE2EJourneyTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.end2end 2 | 3 | import eventsourcing.Retry.retryOnAssertionFailure 4 | import eventsourcing.api.EnrollStudentRequest 5 | import eventsourcing.api.RegisterNewStudentRequest 6 | import eventsourcing.api.ScheduleNewClassRequest 7 | import eventsourcing.api.UnenrollStudentRequest 8 | import eventsourcing.readmodels.studentdetails.StudentDetails 9 | import eventsourcing.readmodels.trainingclasses.TrainingClassDetails 10 | import org.assertj.core.api.AbstractAssert 11 | import org.assertj.core.api.Assertions 12 | import org.assertj.core.api.Assertions.assertThat 13 | import org.springframework.boot.test.context.SpringBootTest 14 | import org.springframework.boot.test.web.client.TestRestTemplate 15 | import org.springframework.http.HttpStatus 16 | import org.springframework.http.ResponseEntity 17 | import org.springframework.test.annotation.DirtiesContext 18 | import java.net.URI 19 | import java.time.LocalDate 20 | import org.apache.commons.lang3.RandomStringUtils 21 | 22 | @DirtiesContext // E2E tests change the state of event-store and read-models 23 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 24 | internal abstract class BaseE2EJourneyTest(val template: TestRestTemplate) { 25 | 26 | // TODO Configure the application to apply simulated latency in the message bus 27 | 28 | // Because read models are eventually consistent, all GET operations retries if they initially fail 29 | // You would not probably notice any error when running on a local machine, as the latency is negligible. But if you 30 | // amplify the latency (set a `simulateLatency` in AsyncInMemoryBus, you will start observing delay, as in a real 31 | // system, and these E2E tests will start randomly failing, getting a 404 instead of 200 on reads to read models 32 | 33 | // Retries on assertion errors 34 | val maxRetries = 30 35 | val retryDelay = 100L 36 | 37 | protected fun listClasses_isOk(): Int = retryOnAssertionFailure(maxRetries, retryDelay) { 38 | return template.assertThatApiGet>("/classes") 39 | .returnsStatusCode(HttpStatus.OK) 40 | .extractBody().size 41 | } 42 | 43 | 44 | protected fun listClasses_isOk_withNofClasses(expectedNofClasses: Int): Int { 45 | val size = listClasses_isOk() 46 | assertThat(size).isEqualTo(expectedNofClasses) 47 | return size 48 | } 49 | 50 | protected fun listStudents_isOk(): Int = retryOnAssertionFailure(maxRetries, retryDelay) { 51 | return template.assertThatApiGet>("/students") 52 | .returnsStatusCode(HttpStatus.OK) 53 | .extractBody().size 54 | } 55 | 56 | protected fun listStudents_isOk_withNofStudents(expectedNofStudents: Int): Int { 57 | val size = listStudents_isOk() 58 | assertThat(size).isEqualTo(expectedNofStudents) 59 | return size 60 | } 61 | 62 | protected fun registerStudent_withEmail_isAccepted(email: String): URI = 63 | template.assertThatApiPost("/students/register", 64 | RegisterNewStudentRequest( 65 | email = email, 66 | fullName = RandomStringUtils.randomAlphabetic(10, 20))) 67 | .returnsStatusCode(HttpStatus.ACCEPTED) 68 | .extractLocation() 69 | 70 | protected fun registerStudent_withEmail_isRejectedWith422(email: String) { 71 | template.assertThatApiPost("/students/register", 72 | RegisterNewStudentRequest( 73 | email = email, 74 | fullName = RandomStringUtils.randomAlphabetic(10, 20))) 75 | .returnsStatusCode(HttpStatus.UNPROCESSABLE_ENTITY) 76 | } 77 | 78 | protected fun scheduleNewClass_withSize_isAccepted(size: Int): URI = 79 | template.assertThatApiPost("/classes/schedule_new", 80 | ScheduleNewClassRequest( 81 | title = "Class title", 82 | date = LocalDate.now(), 83 | size = size)) 84 | .returnsStatusCode(HttpStatus.ACCEPTED) 85 | .extractLocation() 86 | 87 | protected fun getClass_isOK_withVersion(classUri: URI, expectedVersion: Long): TrainingClassDetails = retryOnAssertionFailure(maxRetries, retryDelay) { 88 | return template.assertThatApiGet(classUri) 89 | .returnsStatusCode(HttpStatus.OK) 90 | .returnsClassWithVersion(expectedVersion) 91 | .extractBody() 92 | } 93 | 94 | protected fun getClass_isOk_withVersion_andWithStudents(classUri: URI, expectedVersion: Long, vararg studentIds: String): TrainingClassDetails { 95 | val clazz = getClass_isOK_withVersion(classUri, expectedVersion) 96 | for (studentId in studentIds) 97 | Assertions.assertThat(clazz.students) 98 | .extracting("studentId") 99 | .contains(studentId) 100 | return clazz 101 | } 102 | 103 | protected fun getStudent_isOk_withVersion(studentUri: URI, expectedVersion: Long): StudentDetails = retryOnAssertionFailure(maxRetries, retryDelay) { 104 | return template.assertThatApiGet(studentUri) 105 | .returnsStatusCode(HttpStatus.OK) 106 | .returnsStudentWithVersion(expectedVersion) 107 | .extractBody() 108 | } 109 | 110 | 111 | protected fun enrollStudent_isAccepted(classUri: URI, student: String, classVersion: Long) { 112 | template.assertThatApiPost("$classUri/enroll_student", 113 | EnrollStudentRequest( 114 | studentId = student, 115 | classVersion = classVersion)) 116 | .returnsStatusCode(HttpStatus.ACCEPTED) 117 | } 118 | 119 | protected fun enrollStudent_isRejectedWith409(classUri: URI, student: String, wrongClassVersion: Long) { 120 | template.assertThatApiPost("$classUri/enroll_student", 121 | EnrollStudentRequest( 122 | studentId = student, 123 | classVersion = wrongClassVersion)) 124 | .returnsStatusCode(HttpStatus.CONFLICT) 125 | } 126 | 127 | protected fun enrollStudent_isRejectedWith422(classUri: URI, student: String, classVersion: Long) { 128 | template.assertThatApiPost("$classUri/enroll_student", 129 | EnrollStudentRequest( 130 | studentId = student, 131 | classVersion = classVersion)) 132 | .returnsStatusCode(HttpStatus.UNPROCESSABLE_ENTITY) 133 | } 134 | 135 | protected fun unenrollStudent_isAccepted(classUri: URI, student: String, classVersion: Long) { 136 | template.assertThatApiPost("$classUri/unenroll_student", UnenrollStudentRequest( 137 | studentId = student, 138 | reason = "some reasons", 139 | classVersion = classVersion)) 140 | .returnsStatusCode(HttpStatus.ACCEPTED) 141 | } 142 | 143 | protected fun unenrollStudent_isRejectedWith422(classUri: URI, student: String, classVersion: Long) { 144 | template.assertThatApiPost("$classUri/unenroll_student", UnenrollStudentRequest( 145 | studentId = student, 146 | reason = "some reasons", 147 | classVersion = classVersion)) 148 | .returnsStatusCode(HttpStatus.UNPROCESSABLE_ENTITY) 149 | } 150 | 151 | 152 | private class ApiAssert(actual: ResponseEntity) : AbstractAssert, ResponseEntity>(actual, ApiAssert::class.java) { 153 | 154 | fun returnsStatusCode(expectedStatus: HttpStatus): ApiAssert { 155 | Assertions.assertThat(actual.statusCode).isEqualTo(expectedStatus) 156 | return this 157 | } 158 | 159 | fun returnsBodyWithListOfSize(expectedSize: Int): ApiAssert { 160 | Assertions.assertThat(actual.body as List<*>).hasSize(expectedSize) 161 | return this 162 | } 163 | 164 | fun returnsClassWithVersion(expectedVersion: Long): ApiAssert { 165 | Assertions.assertThat((actual.body as TrainingClassDetails).version).isEqualTo(expectedVersion) 166 | return this 167 | } 168 | 169 | fun returnsStudentWithVersion(expectedVersion: Long): ApiAssert { 170 | Assertions.assertThat((actual.body as StudentDetails).version).isEqualTo(expectedVersion) 171 | return this 172 | } 173 | 174 | fun extractLocation(): URI = actual.headers.location!! 175 | 176 | fun extractBody(): E = actual.body!! 177 | } 178 | 179 | private inline fun TestRestTemplate.assertThatApiGet(uri: URI): ApiAssert = 180 | ApiAssert(this.getForEntity(uri, E::class.java)) 181 | 182 | private inline fun TestRestTemplate.assertThatApiGet(uri: String): ApiAssert = 183 | this.assertThatApiGet(URI(uri)) 184 | 185 | private inline fun TestRestTemplate.assertThatApiPost(uri: URI, requestBody: R): ApiAssert = 186 | ApiAssert(this.postForEntity(uri, requestBody, E::class.java)) 187 | 188 | private inline fun TestRestTemplate.assertThatApiPost(uri: String, requestBody: R): ApiAssert = 189 | assertThatApiPost(URI(uri), requestBody) 190 | } 191 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/end2end/ConcurrentChangeDetectedE2ETest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.end2end 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.springframework.beans.factory.annotation.Autowired 5 | import org.springframework.boot.test.web.client.TestRestTemplate 6 | 7 | internal class ConcurrentChangeDetectedE2ETest(@Autowired template : TestRestTemplate) : BaseE2EJourneyTest(template) { 8 | 9 | @Test 10 | fun `UNHAPPY | Schedule Class + Enroll Student but with wrong expected version and get rejected`() { 11 | val classURI = scheduleNewClass_withSize_isAccepted(10) 12 | 13 | var expectedClassVersion = 0L 14 | getClass_isOK_withVersion(classURI, expectedClassVersion) 15 | 16 | enrollStudent_isRejectedWith409(classURI, "STUDENT001", expectedClassVersion + 1) 17 | } 18 | } -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/end2end/DuplicateStudentEmailRuleViolationE2ETest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.end2end 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.springframework.beans.factory.annotation.Autowired 5 | import org.springframework.boot.test.web.client.TestRestTemplate 6 | 7 | internal class DuplicateStudentEmailRuleViolationE2ETest(@Autowired template : TestRestTemplate) : BaseE2EJourneyTest(template) { 8 | 9 | @Test 10 | fun `UNHAPPY | Register a Student, Register another Student with the same email and get rejected`(){ 11 | val aStudentURI = registerStudent_withEmail_isAccepted("student1@ema.il") 12 | getStudent_isOk_withVersion(aStudentURI, 0L) 13 | 14 | registerStudent_withEmail_isRejectedWith422("student1@ema.il") 15 | } 16 | } -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/end2end/HappyJourneyE2ETest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.end2end 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.springframework.beans.factory.annotation.Autowired 5 | import org.springframework.boot.test.web.client.TestRestTemplate 6 | 7 | internal class HappyJourneyE2ETest(@Autowired template : TestRestTemplate) : BaseE2EJourneyTest(template) { 8 | 9 | @Test 10 | fun `HAPPY | Register a Student + Register another Student + Schedule Class + Enroll a Student + Enroll another Student + Unenroll first Student`() { 11 | 12 | val nOfStudents = listStudents_isOk() 13 | 14 | val aStudentURI = registerStudent_withEmail_isAccepted("student1@ema.il") 15 | val aStudent = getStudent_isOk_withVersion(aStudentURI, 0L) 16 | 17 | val anotherStudentURI = registerStudent_withEmail_isAccepted("student2@ema.il") 18 | val anotherStudent = getStudent_isOk_withVersion(anotherStudentURI, 0L) 19 | 20 | listStudents_isOk_withNofStudents(nOfStudents + 2) 21 | 22 | 23 | val nOfClasses = listClasses_isOk() 24 | 25 | val classURI = scheduleNewClass_withSize_isAccepted(10) 26 | var expectedClassVersion = 0L 27 | getClass_isOK_withVersion(classURI, expectedClassVersion) 28 | 29 | listClasses_isOk_withNofClasses(nOfClasses + 1) 30 | 31 | enrollStudent_isAccepted(classURI, aStudent.studentId, expectedClassVersion) 32 | expectedClassVersion++ 33 | 34 | getClass_isOk_withVersion_andWithStudents(classURI, expectedClassVersion, aStudent.studentId) 35 | 36 | enrollStudent_isAccepted(classURI, anotherStudent.studentId, expectedClassVersion) 37 | expectedClassVersion++ 38 | 39 | getClass_isOk_withVersion_andWithStudents(classURI, expectedClassVersion, aStudent.studentId, anotherStudent.studentId) 40 | 41 | unenrollStudent_isAccepted(classURI,aStudent.studentId, expectedClassVersion) 42 | expectedClassVersion++ 43 | 44 | getClass_isOk_withVersion_andWithStudents(classURI, expectedClassVersion, anotherStudent.studentId) 45 | 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/end2end/UnenrollNotEnrolledStudentRuleViolationE2ETest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.end2end 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.springframework.beans.factory.annotation.Autowired 5 | import org.springframework.boot.test.web.client.TestRestTemplate 6 | 7 | internal class UnenrollNotEnrolledStudentRuleViolationE2ETest(@Autowired template : TestRestTemplate) : BaseE2EJourneyTest(template) { 8 | 9 | @Test 10 | fun `UNAHPPY | Register Student + Schedule Class + Enroll a Student + Unenroll a different Student and get rejected`() { 11 | val aStudentURI = registerStudent_withEmail_isAccepted("student1@ema.il") 12 | val aStudent = getStudent_isOk_withVersion(aStudentURI, 0L) 13 | 14 | val classURI = scheduleNewClass_withSize_isAccepted(2) 15 | var expectedClassVersion = 0L 16 | 17 | enrollStudent_isAccepted(classURI, aStudent.studentId, expectedClassVersion) 18 | expectedClassVersion++ 19 | 20 | unenrollStudent_isRejectedWith422(classURI, "ANOTHER-STUDENT-ID", expectedClassVersion) 21 | } 22 | } -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/eventstore/InMemoryEventStoreTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.eventstore 2 | 3 | import arrow.core.Option 4 | import arrow.core.Some 5 | import com.nhaarman.mockitokotlin2.mock 6 | import com.nhaarman.mockitokotlin2.verify 7 | import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions 8 | import eventsourcing.EventsAssert.Companion.assertThatEvents 9 | import eventsourcing.ResultAssert.Companion.assertThatResult 10 | import eventsourcing.domain.* 11 | import eventsourcing.domain.EventStoreFailure.ConcurrentChangeDetected 12 | import org.junit.jupiter.api.Test 13 | 14 | val AGGREGATE_TYPE : AggregateType = TrainingClass.TYPE 15 | const val AN_AGGREGATE_ID : ClassID = "aggr012" 16 | const val ANOTHER_AGGREGATE_ID : ClassID = "aggr045" 17 | 18 | 19 | internal class InMemoryEventStoreTest { 20 | 21 | @Test 22 | fun `given an Event Store containing 3 Events of a single Aggregate, when I retrieve the Events of the Aggregate, then it returns all Events, in order, with version 0 to 2`() { 23 | 24 | val sut: EventStore = givenAnInMemoryEventStore ( 25 | { withSavedEvents(AGGREGATE_TYPE, AN_AGGREGATE_ID, listOf( 26 | StudentEnrolled(AN_AGGREGATE_ID, "student-1"), 27 | StudentUnenrolled(AN_AGGREGATE_ID, "student-2", "some reasons"), 28 | StudentEnrolled(AN_AGGREGATE_ID, "student-3") 29 | )) }) 30 | 31 | val extractedEvents = sut.getEventsForAggregate(AGGREGATE_TYPE, AN_AGGREGATE_ID) 32 | 33 | val expectedVersionedEvents = listOf( 34 | StudentEnrolled(AN_AGGREGATE_ID, "student-1", 0), 35 | StudentUnenrolled(AN_AGGREGATE_ID, "student-2", "some reasons", 1), 36 | StudentEnrolled(AN_AGGREGATE_ID, "student-3", 2) 37 | ) 38 | assertThatEvents(extractedEvents) 39 | .isDefined() 40 | .onlyContainsInOrder(expectedVersionedEvents) 41 | } 42 | 43 | @Test 44 | fun `given an Event Store containing 3 events of a single Aggregate, when I store new events specifying version = 2, then it succeeds`() { 45 | val sut : EventStore = givenAnInMemoryEventStore( 46 | { withSavedEvents(AGGREGATE_TYPE, AN_AGGREGATE_ID, listOf( 47 | StudentEnrolled(AN_AGGREGATE_ID, "student-1"), 48 | StudentUnenrolled(AN_AGGREGATE_ID, "student-2","some reasons"), 49 | StudentEnrolled(AN_AGGREGATE_ID, "student-3") 50 | )) } ) 51 | 52 | val moreEvents = listOf( 53 | StudentEnrolled(AN_AGGREGATE_ID, "student-4"), 54 | StudentEnrolled(AN_AGGREGATE_ID, "student-5") 55 | ) 56 | 57 | val aggregateVersion = 2L 58 | val result = sut.saveEvents(AGGREGATE_TYPE, AN_AGGREGATE_ID, moreEvents, Some(aggregateVersion)) 59 | 60 | assertThatResult(result).isSuccess() 61 | } 62 | 63 | 64 | @Test 65 | fun `given an Event Store containing 3 events of a single Aggregate, when I store new events specifying a version != 2, than it fail with a ConcurrentChangeDetected`() { 66 | val sut : EventStore = givenAnInMemoryEventStore( 67 | { withSavedEvents(AGGREGATE_TYPE, AN_AGGREGATE_ID, listOf( 68 | StudentEnrolled(AN_AGGREGATE_ID, "student-1"), 69 | StudentUnenrolled(AN_AGGREGATE_ID, "student-2", "some reasons"), 70 | StudentEnrolled(AN_AGGREGATE_ID, "student-3") 71 | )) } ) 72 | 73 | val moreEvents = listOf( 74 | StudentEnrolled(AN_AGGREGATE_ID, "student-4"), 75 | StudentEnrolled(AN_AGGREGATE_ID, "student-5") 76 | ) 77 | 78 | val aggregateVersion = 42L 79 | val result: Result> = sut.saveEvents(AGGREGATE_TYPE, AN_AGGREGATE_ID, moreEvents, Some(aggregateVersion)) 80 | 81 | assertThatResult(result) 82 | .isFailure() 83 | .failureIsA() 84 | } 85 | 86 | 87 | 88 | @Test 89 | fun `given an Event Store containing events of 2 Aggregates, when I retrieve events of one aggregate, then it retrieves Events of the specified Aggregate`() { 90 | val sut : EventStore = givenAnInMemoryEventStore ( 91 | { withSavedEvents(AGGREGATE_TYPE, AN_AGGREGATE_ID, listOf( 92 | StudentEnrolled(AN_AGGREGATE_ID, "student-1"), 93 | StudentUnenrolled(AN_AGGREGATE_ID, "student-2", "some reasons"), 94 | StudentEnrolled(AN_AGGREGATE_ID, "student-3") 95 | )) }, 96 | { withSavedEvents(AGGREGATE_TYPE, ANOTHER_AGGREGATE_ID, listOf( 97 | StudentEnrolled(ANOTHER_AGGREGATE_ID, "student-4"), 98 | StudentEnrolled(ANOTHER_AGGREGATE_ID, "student-5") 99 | )) }) 100 | 101 | 102 | val extractedEvents: Option> = sut.getEventsForAggregate(AGGREGATE_TYPE, AN_AGGREGATE_ID) 103 | 104 | val expectedVersionedEvents = listOf( 105 | StudentEnrolled(AN_AGGREGATE_ID, "student-1", 0), 106 | StudentUnenrolled(AN_AGGREGATE_ID, "student-2", "some reasons",1), 107 | StudentEnrolled(AN_AGGREGATE_ID, "student-3", 2) 108 | ) 109 | 110 | assertThatEvents(extractedEvents) 111 | .isDefined() 112 | .onlyContainsInOrder(expectedVersionedEvents) 113 | } 114 | 115 | @Test 116 | fun `given an Event Store containing Events of a single Aggregate, when I retrieve Events for a different Aggregate, then it returns no events`() { 117 | val sut : EventStore = givenAnInMemoryEventStore( 118 | { withSavedEvents(AGGREGATE_TYPE, AN_AGGREGATE_ID, listOf( 119 | StudentEnrolled(AN_AGGREGATE_ID, "student-1"), 120 | StudentUnenrolled(AN_AGGREGATE_ID, "student-2", "some reasons"), 121 | StudentEnrolled(AN_AGGREGATE_ID, "student-3") 122 | )) } ) 123 | 124 | val nonExistingAggregateID = "this-id-does-not-exist" 125 | val events = sut.getEventsForAggregate(AGGREGATE_TYPE, nonExistingAggregateID) 126 | assertThatEvents(events).isNone() 127 | } 128 | 129 | @Test 130 | fun `given an empty Event Store, when I store 3 new Events of the same Aggregate, then it publishes all new Events in order with versions 0,1 and 2`() { 131 | val publisher = mock>() 132 | val sut : EventStore = givenAnInMemoryEventStore(eventPublisher = publisher) 133 | 134 | val newEvents = listOf( 135 | StudentEnrolled(AN_AGGREGATE_ID, "student-1"), 136 | StudentUnenrolled(AN_AGGREGATE_ID, "student-2", "some reasons"), 137 | StudentEnrolled(AN_AGGREGATE_ID, "student-3")) 138 | sut.saveEvents(AGGREGATE_TYPE, AN_AGGREGATE_ID, newEvents) 139 | 140 | val expectedVersionedEvents = listOf( 141 | StudentEnrolled(AN_AGGREGATE_ID, "student-1", 0), 142 | StudentUnenrolled(AN_AGGREGATE_ID, "student-2", "some reasons", 1), 143 | StudentEnrolled(AN_AGGREGATE_ID, "student-3", 2) 144 | ) 145 | for(expectedEvent in expectedVersionedEvents) 146 | verify(publisher).publish(expectedEvent) 147 | verifyNoMoreInteractions(publisher) 148 | } 149 | } 150 | 151 | private fun givenAnInMemoryEventStore(vararg inits: EventStore.() -> Unit, eventPublisher : EventPublisher = mock() ) : EventStore { 152 | val es = InMemoryEventStore(eventPublisher) 153 | for( init in inits ) 154 | es.init() 155 | return es 156 | } 157 | 158 | private fun EventStore.withSavedEvents(aggregateType: AggregateType, aggregateId: AggregateID, events: Iterable) { 159 | saveEvents(aggregateType, aggregateId, events) 160 | } 161 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/messagebus/AsyncInMemoryBusTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.messagebus 2 | 3 | import com.nhaarman.mockitokotlin2.any 4 | import com.nhaarman.mockitokotlin2.mock 5 | import com.nhaarman.mockitokotlin2.times 6 | import com.nhaarman.mockitokotlin2.verify 7 | import eventsourcing.domain.Event 8 | import eventsourcing.domain.Handles 9 | import kotlinx.coroutines.test.runBlockingTest 10 | import org.junit.jupiter.api.Test 11 | 12 | /** 13 | * This test uses kotlinx.coroutines.test.runBlockingTest to test the asynchronous behaviours in AsyncInMemoryBus 14 | */ 15 | internal class AsyncInMemoryBusTest { 16 | @Test 17 | fun `given a message bus with registered handlers, when I publish multiple events, then all handlers get notified for each event`() = runBlockingTest { 18 | val sut = AsyncInMemoryBus(this) 19 | val handlers = listOf( mock>(), mock>() ) 20 | for(h in handlers) sut.register(h) 21 | 22 | for(i in 1..10L) 23 | sut.publish(object : Event(i) { 24 | override fun copyWithVersion(version: Long): Event = this 25 | }) 26 | 27 | for (h in handlers) 28 | verify(h, times(10)).handle(any()) 29 | 30 | sut.shutdown() 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/messagebus/InMemoryBusTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.messagebus 2 | 3 | import com.nhaarman.mockitokotlin2.eq 4 | import com.nhaarman.mockitokotlin2.mock 5 | import com.nhaarman.mockitokotlin2.verify 6 | import eventsourcing.domain.Event 7 | import eventsourcing.domain.Handles 8 | import org.junit.jupiter.api.Test 9 | 10 | internal class InMemoryBusTest { 11 | @Test 12 | fun `given a message bus with registered handlers, when I publish an event, then all handlers get notified`() { 13 | val sut = InMemoryBus() 14 | val handlers = listOf( mock>(), mock>() ) 15 | for(h in handlers) sut.register(h) 16 | 17 | val event = DummyEvent 18 | sut.publish(event) 19 | 20 | for(h in handlers) 21 | verify(h).handle(eq(event)) 22 | } 23 | } 24 | 25 | private object DummyEvent : Event(0L) { 26 | override fun copyWithVersion(version: Long): Event = this 27 | } -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/readmodels/InMemoryDocumentStoreTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels 2 | 3 | import arrow.core.None 4 | import arrow.core.Option 5 | import org.assertj.core.api.Assertions.assertThat 6 | import org.junit.jupiter.api.Test 7 | 8 | internal class InMemoryDocumentStoreTest { 9 | @Test 10 | fun `given an empty store, when I save a new document, then I may retrieve it back`() { 11 | val sut = givenAnEmptyDocumentStore() 12 | 13 | val id = "01" 14 | val doc = Dummy(id, 0) 15 | sut.save(id, doc) 16 | 17 | val retrieve: Option = sut.get(id) 18 | assertThat(retrieve.orNull()).isEqualTo(doc) 19 | } 20 | 21 | @Test 22 | fun `given a store containing one document, when I update the document, then I may retrieve the new version of it`() { 23 | val id = "01" 24 | val doc = Dummy(id, 0) 25 | val sut = givenADocumentStoreContaining(doc) 26 | 27 | val new = Dummy(id, 1) 28 | sut.save(id, new) 29 | 30 | val retrieve : Option = sut.get(id) 31 | assertThat(retrieve.orNull()).isEqualTo(new) 32 | } 33 | 34 | @Test 35 | fun `given a store, when I retrieve a document not in the store, then I get null`(){ 36 | val sut = givenAnEmptyDocumentStore() 37 | 38 | val res : Option = sut.get("-non-existing-key-") 39 | assertThat(res).isEqualTo(None) 40 | } 41 | } 42 | 43 | private data class Dummy(val id: String, val version: Int) 44 | 45 | private fun InMemoryDocumentStoreTest.givenAnEmptyDocumentStore() : InMemoryDocumentStore = InMemoryDocumentStore() 46 | 47 | private fun InMemoryDocumentStoreTest.givenADocumentStoreContaining(vararg documents: Dummy) : InMemoryDocumentStore { 48 | val datastore = InMemoryDocumentStore() 49 | for(e in documents) datastore.save(e.id, e) 50 | return datastore 51 | } 52 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/readmodels/studentdetails/StudentDetailsProjectionTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels.studentdetails 2 | 3 | import com.nhaarman.mockitokotlin2.eq 4 | import com.nhaarman.mockitokotlin2.mock 5 | import com.nhaarman.mockitokotlin2.verify 6 | import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions 7 | import eventsourcing.domain.NewStudentRegistered 8 | import org.assertj.core.api.Assertions 9 | import org.junit.jupiter.api.Test 10 | import com.nhaarman.mockitokotlin2.check 11 | import eventsourcing.readmodels.DocumentStore 12 | 13 | internal class StudentDetailsProjectionTest { 14 | 15 | @Test 16 | fun `given a StudentDetails projection, when it handles a NewStudentRegistered event, then it saves the new StudentDetails in the datastore`(){ 17 | val (sut, store) = givenProjectionAndStore() 18 | 19 | val event = NewStudentRegistered("STUDENT001", "test@ema.il", "Full Name", 42L) 20 | sut.handle(event) 21 | 22 | verify(store).save(eq(event.studentId), check { 23 | Assertions.assertThat(it.studentId).isEqualTo(event.studentId) 24 | Assertions.assertThat(it.email).isEqualTo(event.email) 25 | Assertions.assertThat(it.fullName).isEqualTo(event.fullName) 26 | Assertions.assertThat(it.version).isEqualTo(event.version) 27 | }) 28 | verifyNoMoreInteractions(store) 29 | } 30 | } 31 | 32 | private fun givenProjectionAndStore(): Pair> { 33 | val datastore = mock>() 34 | return Pair(StudentDetailsProjection(datastore), datastore) 35 | } -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/readmodels/studentdetails/StudentDetailsReadModelTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels.studentdetails 2 | 3 | import arrow.core.None 4 | import arrow.core.Option 5 | import arrow.core.Some 6 | import com.nhaarman.mockitokotlin2.* 7 | import eventsourcing.readmodels.DocumentStore 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.junit.jupiter.api.Test 10 | 11 | internal class StudentDetailsReadModelTest { 12 | 13 | @Test 14 | fun `given a Student Details read model, when I get an existing studentId, then it returns the student`() { 15 | val (sut, store) = givenReadModelAndStore() 16 | val aStudent = StudentDetails("STUDENT001", "test@ema.il", "Full Name", 2L) 17 | whenever(store.get(any())).thenReturn( Some(aStudent) ) 18 | 19 | val result : Option = sut.getStudentById("STUDENT001") 20 | assertThat(result.orNull()).isEqualTo(aStudent) 21 | 22 | verify(store).get(eq("STUDENT001")) 23 | verifyNoMoreInteractions(store) 24 | } 25 | 26 | @Test 27 | fun `given a Student Details read model, when I get a non existing studentId, then it returns null`(){ 28 | val (sut, store) = givenReadModelAndStore() 29 | whenever(store.get(any())).thenReturn( None ) 30 | 31 | val result : Option = sut.getStudentById("NOT-EXISTS") 32 | assertThat(result).isEqualTo(None) 33 | } 34 | 35 | 36 | private fun givenReadModelAndStore(): Pair> { 37 | val datastore = mock>() 38 | return Pair(StudentDetailsReadModel(datastore), datastore) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/readmodels/studentlist/StudentListProjectionTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels.studentlist 2 | 3 | import com.nhaarman.mockitokotlin2.* 4 | import eventsourcing.domain.NewStudentRegistered 5 | import eventsourcing.readmodels.SingleDocumentStore 6 | import org.assertj.core.api.Assertions.assertThat 7 | import org.junit.jupiter.api.Test 8 | 9 | internal class StudentListProjectionTest { 10 | 11 | @Test 12 | fun `given a Student List projection, when it handles a NewStudentRegistered, then it get the old the stored list and replace it with a new list including the new Student and sorted alphabetically`(){ 13 | val (sut, store) = givenProjectionAndStore() 14 | val oldList = listOf(Student("STUDENT001", "Zorro")) 15 | whenever(store.get()).thenReturn(oldList) 16 | 17 | val event = NewStudentRegistered("STUDENT002", "test@ema.il", "Ajeje Brazov", 42L) 18 | sut.handle(event) 19 | 20 | verify(store).get() 21 | verify(store).save( check{ 22 | assertThat(it).hasSize(2) 23 | assertThat(it.first()).isEqualTo(Student("STUDENT002", "Ajeje Brazov")) 24 | assertThat(it.last()).isEqualTo(Student("STUDENT001", "Zorro")) 25 | }) 26 | verifyNoMoreInteractions(store) 27 | } 28 | 29 | 30 | private fun givenProjectionAndStore(): Pair> { 31 | val datastore = mock>() 32 | return Pair(StudentListProjection(datastore), datastore) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/readmodels/studentlist/StudentListReadModelTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels.studentlist 2 | 3 | import com.nhaarman.mockitokotlin2.mock 4 | import com.nhaarman.mockitokotlin2.verify 5 | import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions 6 | import com.nhaarman.mockitokotlin2.whenever 7 | import eventsourcing.readmodels.SingleDocumentStore 8 | import org.assertj.core.api.Assertions.assertThat 9 | import org.junit.jupiter.api.Test 10 | 11 | internal class StudentListReadModelTest { 12 | @Test 13 | fun `given a Student List read model containing no Student, when I retrieve all Students, then I get an empty list`(){ 14 | val (sut, store) = givenReadModelAndStore() 15 | whenever(store.get()).thenReturn(emptyList()) 16 | 17 | val result : Iterable = sut.allStudents() 18 | assertThat(result).isEmpty() 19 | 20 | verify(store).get() 21 | verifyNoMoreInteractions(store) 22 | } 23 | 24 | @Test 25 | fun `given a Student List read model containing some Students, when I retrieve all Students, then I get a list with all Students`() { 26 | val (sut, store) = givenReadModelAndStore() 27 | whenever(store.get()).thenReturn( listOf( 28 | Student("STUDENT001", "Student One"), 29 | Student("STUDENT002", "Student Two"))) 30 | 31 | val result : Iterable = sut.allStudents() 32 | assertThat(result).hasSize(2) 33 | assertThat(result.first()).isEqualTo(Student("STUDENT001", "Student One")) 34 | assertThat(result.last()).isEqualTo(Student("STUDENT002", "Student Two")) 35 | } 36 | 37 | 38 | private fun givenReadModelAndStore(): Pair> { 39 | val datastore = mock>() 40 | return Pair(StudentListReadModel(datastore), datastore) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/readmodels/trainingclasses/TrainingClassProjectionTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels.trainingclasses 2 | 3 | import arrow.core.Some 4 | import com.nhaarman.mockitokotlin2.* 5 | import eventsourcing.domain.NewClassScheduled 6 | import eventsourcing.domain.NewStudentRegistered 7 | import eventsourcing.domain.StudentEnrolled 8 | import eventsourcing.domain.StudentUnenrolled 9 | import eventsourcing.readmodels.DocumentStore 10 | import eventsourcing.readmodels.SingleDocumentStore 11 | import org.assertj.core.api.Assertions.assertThat 12 | import org.junit.jupiter.api.BeforeEach 13 | import org.junit.jupiter.api.Test 14 | import java.time.LocalDate 15 | 16 | internal class TrainingClassProjectionTest { 17 | lateinit var trainingClassDetailsStore: DocumentStore 18 | lateinit var trainingClassListStore: SingleDocumentStore 19 | lateinit var studentsContactsStore: DocumentStore 20 | 21 | @BeforeEach 22 | fun before() { 23 | trainingClassDetailsStore = mock>() 24 | trainingClassListStore = mock>() 25 | studentsContactsStore = mock>() 26 | } 27 | 28 | 29 | @Test 30 | fun `given a Training Class projection containing no Class, when it handles a NewClassScheduled, then it adds the new class to the list and Details stores`() { 31 | val sut = givenTrainingClassProjection() 32 | whenever(trainingClassListStore.get()).thenReturn(emptyList()) 33 | 34 | val event = NewClassScheduled("CLASS001", "Class title", LocalDate.now(), 10, 0L) 35 | sut.handle(event) 36 | 37 | verify(trainingClassListStore).get() 38 | verify(trainingClassListStore).save(check { 39 | assertThat(it).hasSize(1) 40 | assertThat(it[0].classId).isEqualTo(event.classId) 41 | assertThat(it[0].title).isEqualTo(event.title) 42 | assertThat(it[0].date).isEqualTo(event.date) 43 | }) 44 | 45 | verify(trainingClassDetailsStore).save(eq(event.classId), check { 46 | assertThat(it.classId).isEqualTo(event.classId) 47 | assertThat(it.title).isEqualTo(event.title) 48 | assertThat(it.date).isEqualTo(event.date) 49 | assertThat(it.availableSpots).isEqualTo(event.classSize) 50 | assertThat(it.totalSize).isEqualTo(event.classSize) 51 | assertThat(it.students).isEmpty() 52 | assertThat(it.version).isEqualTo(event.version) 53 | }) 54 | 55 | verifyNoMoreInteractions(trainingClassListStore) 56 | verifyNoMoreInteractions(trainingClassDetailsStore) 57 | verifyNoMoreInteractions(studentsContactsStore) 58 | } 59 | 60 | @Test 61 | fun `given a Training Class projection, when it handles a StudentEnrolled event, then it retrieves the Class Details and saves it back with the new student, available spots number decreased and version updated`() { 62 | val sut = givenTrainingClassProjection() 63 | 64 | val classId = "CLASS001" 65 | val oldTrainingClassDetails = TrainingClassDetails( 66 | classId = classId, 67 | title = "Class Title", 68 | date = LocalDate.now(), 69 | totalSize = 10, 70 | availableSpots = 10, 71 | students = emptyList(), 72 | version = 0L) 73 | val studentId = "STUDENT042" 74 | val studentContacts = StudentContacts(studentId, "test@ema.il") 75 | val enrolledStudent = EnrolledStudent(studentId, "email", studentContacts.email) 76 | 77 | whenever(trainingClassDetailsStore.get(any())).thenReturn(Some(oldTrainingClassDetails)) 78 | whenever(studentsContactsStore.get(any())).thenReturn(Some(studentContacts)) 79 | 80 | val event = StudentEnrolled(classId, studentId, 0L) 81 | sut.handle(event) 82 | 83 | verify(studentsContactsStore).get(studentId) 84 | verify(trainingClassDetailsStore).get(eq(event.classId)) 85 | verify(trainingClassDetailsStore).save(eq(event.classId), check { 86 | assertThat(it.classId).isEqualTo(event.classId) 87 | assertThat(it.availableSpots).isEqualTo(oldTrainingClassDetails.availableSpots - 1) 88 | assertThat(it.totalSize).isEqualTo(oldTrainingClassDetails.totalSize) 89 | assertThat(it.students).hasSize(1) 90 | assertThat(it.students).contains(enrolledStudent) 91 | assertThat(it.version).isEqualTo(event.version) 92 | }) 93 | 94 | verifyNoMoreInteractions(trainingClassListStore) 95 | verifyNoMoreInteractions(trainingClassDetailsStore) 96 | verifyNoMoreInteractions(studentsContactsStore) 97 | } 98 | 99 | @Test 100 | fun `given a TrainingClass projection, when it handles a StudentUnenrolled events, it retrieves the Class Details and saves back without the student, avail spots +1 and new version`() { 101 | val sut = givenTrainingClassProjection() 102 | 103 | val studentId = "STUDENT042" 104 | val studentContacts = StudentContacts(studentId, "test@ema.il") 105 | val enrolledStudent = EnrolledStudent(studentId, "email", studentContacts.email) 106 | val classId = "CLASS001" 107 | val oldTrainingClassDetails = TrainingClassDetails( 108 | classId = classId, 109 | title = "Class Title", 110 | date = LocalDate.now(), 111 | totalSize = 10, 112 | availableSpots = 0, 113 | students = listOf(enrolledStudent), 114 | version = 1L) 115 | 116 | whenever(trainingClassDetailsStore.get(any())).thenReturn(Some(oldTrainingClassDetails)) 117 | whenever(studentsContactsStore.get(any())).thenReturn(Some(studentContacts)) 118 | 119 | val event = StudentUnenrolled(classId, studentId, "Some good reasons", 2L) 120 | sut.handle(event) 121 | 122 | verify(studentsContactsStore).get(studentId) 123 | verify(trainingClassDetailsStore).get(eq(event.classId)) 124 | verify(trainingClassDetailsStore).save(eq(event.classId), check { 125 | assertThat(it.classId).isEqualTo(event.classId) 126 | assertThat(it.availableSpots).isEqualTo(oldTrainingClassDetails.availableSpots + 1) 127 | assertThat(it.totalSize).isEqualTo(oldTrainingClassDetails.totalSize) 128 | assertThat(it.students).doesNotContain(enrolledStudent) 129 | assertThat(it.version).isEqualTo(event.version) 130 | }) 131 | 132 | verifyNoMoreInteractions(trainingClassListStore) 133 | verifyNoMoreInteractions(trainingClassDetailsStore) 134 | verifyNoMoreInteractions(studentsContactsStore) 135 | } 136 | 137 | @Test 138 | fun `given a TrainingClass projection, when it handles a NewStudentRegistered event, then it adds the new Student's Contact`() { 139 | val sut = givenTrainingClassProjection() 140 | 141 | val event = NewStudentRegistered( 142 | studentId = "STUDENT001", 143 | email = "test@ema.il", 144 | fullName = "Full Name", 145 | version = 0L) 146 | sut.handle(event) 147 | 148 | verify(studentsContactsStore).save( eq(event.studentId), check { 149 | assertThat(it.studentId).isEqualTo(event.studentId) 150 | assertThat(it.email).isEqualTo(event.email) 151 | }) 152 | 153 | verifyNoMoreInteractions(trainingClassListStore) 154 | verifyNoMoreInteractions(trainingClassDetailsStore) 155 | verifyNoMoreInteractions(studentsContactsStore) 156 | } 157 | 158 | private fun givenTrainingClassProjection() = 159 | TrainingClassProjection(trainingClassDetailsStore, trainingClassListStore, studentsContactsStore) 160 | } 161 | 162 | 163 | 164 | 165 | -------------------------------------------------------------------------------- /src/test/kotlin/eventsourcing/readmodels/trainingclasses/TrainingClassReadModelTest.kt: -------------------------------------------------------------------------------- 1 | package eventsourcing.readmodels.trainingclasses 2 | 3 | import arrow.core.None 4 | import arrow.core.Option 5 | import arrow.core.Some 6 | import com.nhaarman.mockitokotlin2.* 7 | import eventsourcing.readmodels.DocumentStore 8 | import eventsourcing.readmodels.SingleDocumentStore 9 | import org.assertj.core.api.Assertions.assertThat 10 | import org.junit.jupiter.api.BeforeEach 11 | import org.junit.jupiter.api.Test 12 | import java.time.LocalDate 13 | 14 | internal class TrainingClassReadModelTest { 15 | lateinit var trainingClassDetailsStore: DocumentStore 16 | lateinit var trainingClassListStore: SingleDocumentStore 17 | 18 | @BeforeEach 19 | fun before() { 20 | trainingClassDetailsStore = mock>() 21 | trainingClassListStore = mock>() 22 | } 23 | 24 | @Test 25 | fun `given a Training Class read model, when I get an existing Training Class Details by Id, then it returns the class`() { 26 | val sut = givenTrainingClassReadModel() 27 | 28 | whenever(trainingClassDetailsStore.get(any())).thenReturn(Some(aTrainingClassDetails)) 29 | 30 | val result : Option = sut.getTrainingClassDetailsById(aTrainingClassDetails.classId) 31 | 32 | assertThat(result.orNull()).isEqualTo(aTrainingClassDetails) 33 | 34 | verify(trainingClassDetailsStore).get(eq(aTrainingClassDetails.classId)) 35 | 36 | verifyNoMoreInteractions(trainingClassDetailsStore) 37 | verifyNoMoreInteractions(trainingClassListStore) 38 | } 39 | 40 | @Test 41 | fun `given a Training Class read model, when I get a non existing Training Class Details, then it returns an empty result`() { 42 | val sut = givenTrainingClassReadModel() 43 | 44 | whenever(trainingClassDetailsStore.get(any())).thenReturn(None) 45 | 46 | val result : Option = sut.getTrainingClassDetailsById("NON-EXISTING-CLASS") 47 | 48 | assertThat(result).isEqualTo(None) 49 | 50 | verify(trainingClassDetailsStore).get(eq("NON-EXISTING-CLASS")) 51 | 52 | verifyNoMoreInteractions(trainingClassDetailsStore) 53 | verifyNoMoreInteractions(trainingClassListStore) 54 | } 55 | 56 | @Test 57 | fun `given a Training Class read model containing no Class, when I retrieve all Classes, then I get an empty list`() { 58 | val sut = givenTrainingClassReadModel() 59 | whenever(trainingClassListStore.get()).thenReturn(emptyList()) 60 | 61 | val result : TrainingClassList = sut.allClasses() 62 | assertThat(result).isEmpty() 63 | 64 | verify(trainingClassListStore).get() 65 | 66 | verifyNoMoreInteractions(trainingClassDetailsStore) 67 | verifyNoMoreInteractions(trainingClassListStore) 68 | } 69 | 70 | @Test 71 | fun `given a Training Class read model containing some Classes, when I retrieve all Classes, then I get a list with all Classes`() { 72 | val sut = givenTrainingClassReadModel() 73 | 74 | val aClass = TrainingClass("CLASS001", "First Class", LocalDate.now()) 75 | val anotherClass = TrainingClass("CLASS002", "Second Class", LocalDate.now()) 76 | whenever(trainingClassListStore.get()).thenReturn(listOf(aClass, anotherClass)) 77 | 78 | 79 | val result = sut.allClasses() 80 | assertThat(result).hasSize(2) 81 | assertThat(result.first()).isEqualTo(aClass) 82 | assertThat(result.last()).isEqualTo(anotherClass) 83 | } 84 | 85 | private fun givenTrainingClassReadModel() = 86 | TrainingClassReadModel(trainingClassDetailsStore, trainingClassListStore) 87 | } 88 | 89 | val aTrainingClassDetails = TrainingClassDetails( 90 | classId = "CLASS001", 91 | title = "Class Title", 92 | date = LocalDate.now(), 93 | totalSize = 10, 94 | availableSpots = 0, 95 | students = listOf(EnrolledStudent("STUDENT042", "email", "test@ema.il")), 96 | version = 1L) 97 | -------------------------------------------------------------------------------- /src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline 2 | --------------------------------------------------------------------------------