├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main ├── kotlin │ └── de │ │ └── bringmeister │ │ └── connect │ │ └── product │ │ ├── Application.kt │ │ ├── application │ │ ├── cdn │ │ │ ├── ImageCdnService.kt │ │ │ └── UpdateCdnCommand.kt │ │ ├── mediadata │ │ │ ├── MediaDataRegistry.kt │ │ │ ├── MediaDataService.kt │ │ │ └── RegisterForMediaDataUpdatesCommand.kt │ │ ├── product │ │ │ └── ProductService.kt │ │ ├── search │ │ │ ├── SearchIndexService.kt │ │ │ └── UpdateSearchIndexCommand.kt │ │ └── shop │ │ │ ├── ShopService.kt │ │ │ └── UpdateShopCommand.kt │ │ ├── domain │ │ ├── Command.kt │ │ ├── CommandBus.kt │ │ ├── CommandListener.kt │ │ ├── DomainEntity.kt │ │ ├── Event.kt │ │ ├── EventBus.kt │ │ ├── EventListener.kt │ │ └── product │ │ │ ├── CreateNewProductCommand.kt │ │ │ ├── MasterDataUpdatedEvent.kt │ │ │ ├── MediaDataUpdatedEvent.kt │ │ │ ├── Product.kt │ │ │ ├── ProductCreatedEvent.kt │ │ │ ├── ProductInformation.kt │ │ │ ├── ProductNumber.kt │ │ │ ├── ProductRepository.kt │ │ │ ├── UpdateMasterDataCommand.kt │ │ │ └── UpdateMediaDataCommand.kt │ │ ├── infrastructure │ │ ├── spring │ │ │ ├── SpringCommandBus.kt │ │ │ └── SpringEventBus.kt │ │ └── stubs │ │ │ ├── StubbedMediaDataRegistry.kt │ │ │ └── StubbedProductRepository.kt │ │ └── ports │ │ ├── messages │ │ ├── MasterDataUpdateAvailableEventListener.kt │ │ ├── MasterDataUpdatedEventListener.kt │ │ ├── MediaDataUpdateAvailableEventListener.kt │ │ ├── MediaDataUpdatedEventListener.kt │ │ └── ProductCreatedEventListener.kt │ │ └── rest │ │ ├── DemoController.kt │ │ ├── MasterDataUpdateAvailableEvent.kt │ │ └── MediaDataUpdateAvailableEvent.kt └── resources │ ├── application.yml │ └── log4j2.xml └── test └── kotlin └── de └── bringmeister └── connect └── product ├── AcceptanceTest.kt ├── SpringContextTest.kt ├── application ├── cdn │ └── ImageCdnServiceTest.kt ├── search │ └── SearchIndexServiceTest.kt └── shop │ └── ShopServiceTest.kt ├── domain ├── DomainEntityTest.kt └── product │ ├── ProductNumberTest.kt │ └── ProductTest.kt └── infrastructure └── spring ├── SpringCommandBusTest.kt └── SpringEventBusTest.kt /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,java,gradle,intellij 3 | 4 | .idea/ 5 | *.iml 6 | 7 | # CMake 8 | cmake-build-debug/ 9 | 10 | # Mongo Explorer plugin: 11 | .idea/**/mongoSettings.xml 12 | 13 | ## File-based project format: 14 | *.iws 15 | 16 | ## Plugin-specific files: 17 | 18 | # IntelliJ 19 | /out/ 20 | 21 | # mpeltonen/sbt-idea plugin 22 | .idea_modules/ 23 | 24 | # JIRA plugin 25 | atlassian-ide-plugin.xml 26 | 27 | # Cursive Clojure plugin 28 | .idea/replstate.xml 29 | 30 | # Ruby plugin and RubyMine 31 | /.rakeTasks 32 | 33 | # Crashlytics plugin (for Android Studio and IntelliJ) 34 | com_crashlytics_export_strings.xml 35 | crashlytics.properties 36 | crashlytics-build.properties 37 | fabric.properties 38 | 39 | ### Intellij Patch ### 40 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 41 | 42 | # *.iml 43 | # modules.xml 44 | # .idea/misc.xml 45 | # *.ipr 46 | 47 | # Sonarlint plugin 48 | .idea/sonarlint 49 | 50 | ### Java ### 51 | # Compiled class file 52 | *.class 53 | 54 | # Log file 55 | *.log 56 | 57 | # BlueJ files 58 | *.ctxt 59 | 60 | # Mobile Tools for Java (J2ME) 61 | .mtj.tmp/ 62 | 63 | # Package Files # 64 | *.jar 65 | *.war 66 | *.ear 67 | *.zip 68 | *.tar.gz 69 | *.rar 70 | 71 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 72 | hs_err_pid* 73 | 74 | ### OSX ### 75 | *.DS_Store 76 | .AppleDouble 77 | .LSOverride 78 | 79 | # Icon must end with two \r 80 | Icon 81 | 82 | # Thumbnails 83 | ._* 84 | 85 | # Files that might appear in the root of a volume 86 | .DocumentRevisions-V100 87 | .fseventsd 88 | .Spotlight-V100 89 | .TemporaryItems 90 | .Trashes 91 | .VolumeIcon.icns 92 | .com.apple.timemachine.donotpresent 93 | 94 | # Directories potentially created on remote AFP share 95 | .AppleDB 96 | .AppleDesktop 97 | Network Trash Folder 98 | Temporary Items 99 | .apdisk 100 | 101 | ### Gradle ### 102 | .gradle 103 | **/build/ 104 | 105 | # Ignore Gradle GUI config 106 | gradle-app.setting 107 | 108 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 109 | !gradle-wrapper.jar 110 | 111 | # Cache of project 112 | .gradletasknamecache 113 | 114 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 115 | # gradle/wrapper/gradle-wrapper.properties 116 | 117 | # End of https://www.gitignore.io/api/osx,java,gradle,intellij 118 | 119 | .classpath 120 | .project 121 | .settings 122 | bin -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | jdk: 3 | - oraclejdk8 4 | before_install: 5 | - chmod +x gradlew 6 | - chmod +x gradle/wrapper/gradle-wrapper.jar 7 | script: 8 | - ./gradlew check -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Bringmeister GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | DDD with Kotlin 2 | =============== 3 | 4 | [![Build Status](https://img.shields.io/travis/bringmeister/ddd-with-kotlin/master.svg)](https://travis-ci.org/bringmeister/ddd-with-kotlin) 5 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/bringmeister/ddd-with-kotlin/master/LICENSE) 6 | 7 | This is a simple demo project to show a Domain Driven Design with Kotlin. 8 | 9 | Note that there is a similar version of this demo which is using event sourcing at [event-sourcing-with-kotlin](https://github.com/bringmeister/event-sourcing-with-kotlin). 10 | 11 | ## Run it 12 | 13 | ``` 14 | ./gradlew bootRun 15 | ``` 16 | 17 | Then simulate an incoming event: 18 | 19 | ``` 20 | POST to http://localhost:8080/master_data_update 21 | POST to http://localhost:8080/media_data_update 22 | ``` 23 | 24 | ## Example Use Case 25 | 26 | The subject of this demo is the `Product Service`. 27 | The `Product Service` is located between five other components. 28 | The `Master Data Service` will push new products to the `Product Service`. 29 | This is the beginning of our business process. 30 | The `Product Service` will register each new product at the `Media Data Service`. 31 | After a product is registered, the `Product Service` will receive updates for this product from the `Media Data Service`. 32 | After an update was received, the `Product Service` will: 33 | - Update the `CDN` if media data has changed 34 | - Update the `Shop` and `search index` if master data has changed 35 | 36 | ``` 37 | +-------------+ 38 | | Master Data | 39 | | Service | 40 | +-------------+ 41 | | << DEMO >> 42 | +------------------► +---------+ 43 | 1: update product | Product | 44 | | Service |---------+---------+ 45 | +------► +---------+ | | 46 | 3: | | 2: | | 5: 47 | push updates | | register new | | update if master 48 | | | product | | data has changed 49 | +------------+ | | | 50 | | Media Data | ◄----------+ | +----------+ 51 | | Service | | | | 52 | +------------+ 4: update if media | | | 53 | data has changed | | | 54 | ▼ ▼ ▼ 55 | +-----+ +------+ +--------+ 56 | | CDN | | Shop | | Search | 57 | +-----+ +------+ +--------+ 58 | ``` 59 | 60 | ## What to see 61 | 62 | - A domain entity called `Product.kt` which encapsulates business logic and throws domain events. 63 | - A process flow with events ("something has happened") and commands ("now do something"). 64 | - Value objects such as `ProductNumber.kt` or `ProductInformation.kt`. 65 | - A ports-and-adapters package layout. 66 | - An anti-corruption layer for external events - they will be transformed to internal commands. 67 | 68 | ## Resources 69 | 70 | - https://martinfowler.com/tags/domain%20driven%20design.html 71 | * A collection of articles by Martin Fowler. 72 | Each article enlightens a different aspect of DDD. 73 | - https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/microservice-ddd-cqrs-patterns/ddd-oriented-microservice 74 | * First article of series by Microsoft on how to use DDD for microservices. 75 | Although the series is using C#, the examples are easy to understand. -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | 3 | repositories { 4 | mavenCentral() 5 | } 6 | 7 | dependencies { 8 | classpath "org.springframework.boot:spring-boot-gradle-plugin:2.0.3.RELEASE" 9 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.2.51" 10 | classpath "org.jetbrains.kotlin:kotlin-allopen:1.2.51" 11 | } 12 | } 13 | 14 | repositories { 15 | mavenCentral() 16 | } 17 | 18 | apply plugin: 'kotlin' 19 | apply plugin: 'kotlin-spring' 20 | apply plugin: 'idea' 21 | apply plugin: 'org.springframework.boot' 22 | apply plugin: 'io.spring.dependency-management' 23 | 24 | group = 'de.bringmeister' 25 | version = '0.1.0' 26 | sourceCompatibility = 1.8 27 | 28 | compileKotlin { 29 | kotlinOptions.jvmTarget = "1.8" 30 | } 31 | compileTestKotlin { 32 | kotlinOptions.jvmTarget = "1.8" 33 | } 34 | 35 | configurations.all { 36 | resolutionStrategy { 37 | failOnVersionConflict() 38 | } 39 | } 40 | 41 | dependencies { 42 | 43 | compile "org.springframework.boot:spring-boot-starter-web" 44 | compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.2.51" 45 | compile "org.jetbrains.kotlin:kotlin-reflect:1.2.51" 46 | compile "org.apache.commons:commons-lang3:3.7" 47 | 48 | testCompile "org.springframework.boot:spring-boot-starter-test" 49 | } 50 | 51 | sourceSets { 52 | main.java.srcDirs += 'src/main/kotlin' 53 | } 54 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bringmeister/ddd-with-kotlin/e1765b0c3cc871f63aea99677f33c90221952319/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Jan 30 11:56:14 CET 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.2.1-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'ddd-kotlin' 2 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/Application.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product 2 | 3 | import org.springframework.boot.SpringApplication 4 | import org.springframework.boot.autoconfigure.SpringBootApplication 5 | import org.springframework.scheduling.annotation.EnableAsync 6 | 7 | @SpringBootApplication 8 | @EnableAsync 9 | class Application 10 | 11 | fun main(args: Array) { 12 | SpringApplication.run(Application::class.java, *args) 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/application/cdn/ImageCdnService.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.cdn 2 | 3 | import de.bringmeister.connect.product.domain.CommandListener 4 | import org.slf4j.Logger 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class ImageCdnService() { 10 | 11 | private val log: Logger = LoggerFactory.getLogger(this.javaClass) 12 | 13 | @CommandListener 14 | fun handle(command: UpdateCdnCommand) { 15 | 16 | // Here would be the place for some business logic 17 | // interacting with the external image CDN. 18 | 19 | log.info("Image CDN has been updated. [productNumber={}]", command.productNumber) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/application/cdn/UpdateCdnCommand.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.cdn 2 | 3 | import de.bringmeister.connect.product.domain.Command 4 | import de.bringmeister.connect.product.domain.product.ProductNumber 5 | 6 | data class UpdateCdnCommand( 7 | val productNumber: ProductNumber 8 | ) : Command 9 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/application/mediadata/MediaDataRegistry.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.mediadata 2 | 3 | interface MediaDataRegistry { 4 | fun handle(command: RegisterForMediaDataUpdatesCommand) 5 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/application/mediadata/MediaDataService.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.mediadata 2 | 3 | import de.bringmeister.connect.product.domain.CommandListener 4 | import org.springframework.stereotype.Service 5 | 6 | @Service 7 | class MediaDataService(private val mediaDataRegistry: MediaDataRegistry) { 8 | 9 | @CommandListener 10 | fun handle(command: RegisterForMediaDataUpdatesCommand) { 11 | mediaDataRegistry.handle(command) 12 | } 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/application/mediadata/RegisterForMediaDataUpdatesCommand.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.mediadata 2 | 3 | import de.bringmeister.connect.product.domain.Command 4 | import de.bringmeister.connect.product.domain.product.ProductNumber 5 | 6 | data class RegisterForMediaDataUpdatesCommand( 7 | val productNumber: ProductNumber 8 | ) : Command 9 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/application/product/ProductService.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.product 2 | 3 | import de.bringmeister.connect.product.domain.CommandListener 4 | import de.bringmeister.connect.product.domain.EventBus 5 | import de.bringmeister.connect.product.domain.product.CreateNewProductCommand 6 | import de.bringmeister.connect.product.domain.product.Product 7 | import de.bringmeister.connect.product.domain.product.ProductRepository 8 | import de.bringmeister.connect.product.domain.product.UpdateMasterDataCommand 9 | import de.bringmeister.connect.product.domain.product.UpdateMediaDataCommand 10 | import org.slf4j.Logger 11 | import org.slf4j.LoggerFactory 12 | import org.springframework.stereotype.Service 13 | 14 | @Service 15 | class ProductService( 16 | private val productRepository: ProductRepository, 17 | private val eventBus: EventBus 18 | ) { 19 | 20 | private val log: Logger = LoggerFactory.getLogger(this.javaClass) 21 | 22 | @CommandListener 23 | fun handle(command: CreateNewProductCommand) { 24 | val product = Product(command) 25 | productRepository.save(product) 26 | eventBus.sendAll(product.occurredEvents()) 27 | } 28 | 29 | @CommandListener 30 | fun handle(command: UpdateMasterDataCommand) { 31 | val product = productRepository.find(command.productNumber) 32 | product.handle(command) 33 | productRepository.save(product) 34 | eventBus.sendAll(product.occurredEvents()) 35 | } 36 | 37 | @CommandListener 38 | fun handle(command: UpdateMediaDataCommand) { 39 | if (productRepository.exists(command.productNumber)) { 40 | val product = productRepository.find(command.productNumber) 41 | product.handle(command) 42 | productRepository.save(product) 43 | eventBus.sendAll(product.occurredEvents()) 44 | } else { 45 | log.info("Media data ignored as product doesn't exist. [productNumber={}]", command.productNumber) 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/application/search/SearchIndexService.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.search 2 | 3 | import de.bringmeister.connect.product.domain.CommandListener 4 | import org.slf4j.Logger 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class SearchIndexService() { 10 | 11 | private val log: Logger = LoggerFactory.getLogger(this.javaClass) 12 | 13 | @CommandListener 14 | fun handle(command: UpdateSearchIndexCommand) { 15 | 16 | // Here would be the place for some business logic 17 | // interacting with the external search index. 18 | 19 | log.info("Search index has been updated. [productNumber={}]", command.productNumber) 20 | } 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/application/search/UpdateSearchIndexCommand.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.search 2 | 3 | import de.bringmeister.connect.product.domain.Command 4 | import de.bringmeister.connect.product.domain.product.ProductNumber 5 | 6 | data class UpdateSearchIndexCommand( 7 | val productNumber: ProductNumber 8 | ) : Command 9 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/application/shop/ShopService.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.shop 2 | 3 | import de.bringmeister.connect.product.domain.CommandListener 4 | import org.slf4j.Logger 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class ShopService() { 10 | 11 | private val log: Logger = LoggerFactory.getLogger(this.javaClass) 12 | 13 | @CommandListener 14 | fun handle(command: UpdateShopCommand) { 15 | 16 | // Here would be the place for some business logic 17 | // interacting with the external shop system. 18 | 19 | log.info("Shop has been updated. [productNumber={}]", command.productNumber) 20 | } 21 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/application/shop/UpdateShopCommand.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.shop 2 | 3 | import de.bringmeister.connect.product.domain.Command 4 | import de.bringmeister.connect.product.domain.product.ProductNumber 5 | 6 | data class UpdateShopCommand( 7 | val productNumber: ProductNumber 8 | ) : Command 9 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/Command.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain 2 | 3 | interface Command 4 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/CommandBus.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain 2 | 3 | interface CommandBus { 4 | fun send(command: Command) 5 | 6 | fun sendAll(commands: List) { 7 | commands.forEach(this::send) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/CommandListener.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain 2 | 3 | import org.springframework.context.event.EventListener 4 | import org.springframework.scheduling.annotation.Async 5 | 6 | @Target(AnnotationTarget.FUNCTION) 7 | @Retention(AnnotationRetention.RUNTIME) 8 | @EventListener 9 | @Async 10 | annotation class CommandListener -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/DomainEntity.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain 2 | 3 | import org.apache.commons.lang3.builder.EqualsBuilder 4 | import org.apache.commons.lang3.builder.HashCodeBuilder 5 | import org.slf4j.Logger 6 | import org.slf4j.LoggerFactory 7 | 8 | /** 9 | * The abstract skeleton of a domain entity. A domain entity is an object 10 | * of the Domain Driven Design. It encapsulates data and behaviour. This 11 | * means it not only holds data, but also contains the business methods in 12 | * order to manipulate it. After data has been manipulated, a domain event 13 | * will be raised. Other contexts can listen and react on this event. This 14 | * abstract class provides convenient methods to handle those events. 15 | * 16 | * Every domain entity has an unique ID. This means that the identity of a 17 | * domain entity is based on its ID rather than on the current data. To 18 | * reflect this concept in the code, we've overwritten equals() and hashCode(). 19 | */ 20 | abstract class DomainEntity(val id: T) { 21 | 22 | private val log: Logger = LoggerFactory.getLogger(this.javaClass) 23 | private val occurredEvents: MutableList = mutableListOf() 24 | 25 | fun occurredEvents(): List { 26 | val events = this.occurredEvents.toMutableList() 27 | this.occurredEvents.clear() 28 | log.trace("Return occurred domain events. [numberOfEvents=${events.size}]") 29 | return events 30 | } 31 | 32 | protected fun raise(event: Event) { 33 | occurredEvents.add(event) 34 | log.debug("Raised new domain event. [type=${event::class.simpleName}]") 35 | } 36 | 37 | override fun equals(other: Any?): Boolean { 38 | if (other!!.javaClass != this.javaClass) { 39 | return false 40 | } 41 | return EqualsBuilder() 42 | .append(this.id, (other as DomainEntity<*>).id) // Only on the ID! 43 | .isEquals 44 | } 45 | 46 | override fun hashCode(): Int { 47 | return HashCodeBuilder() 48 | .append(id) // Only on the ID! 49 | .toHashCode() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/Event.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain 2 | 3 | interface Event 4 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/EventBus.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain 2 | 3 | interface EventBus { 4 | fun send(event: Event) 5 | 6 | fun sendAll(events: List) { 7 | events.forEach(this::send) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/EventListener.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain 2 | 3 | import org.springframework.context.event.EventListener 4 | import org.springframework.scheduling.annotation.Async 5 | 6 | @Target(AnnotationTarget.FUNCTION) 7 | @Retention(AnnotationRetention.RUNTIME) 8 | @EventListener 9 | @Async 10 | annotation class EventListener 11 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/product/CreateNewProductCommand.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain.product 2 | 3 | import de.bringmeister.connect.product.domain.Command 4 | 5 | data class CreateNewProductCommand( 6 | val productNumber: ProductNumber, 7 | val name: String, 8 | val description: String 9 | ) : Command 10 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/product/MasterDataUpdatedEvent.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain.product 2 | 3 | import de.bringmeister.connect.product.domain.Event 4 | 5 | data class MasterDataUpdatedEvent( 6 | val productNumber: ProductNumber 7 | ) : Event 8 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/product/MediaDataUpdatedEvent.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain.product 2 | 3 | import de.bringmeister.connect.product.domain.Event 4 | 5 | data class MediaDataUpdatedEvent( 6 | val productNumber: ProductNumber 7 | ) : Event 8 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/product/Product.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain.product 2 | 3 | import de.bringmeister.connect.product.domain.DomainEntity 4 | import org.slf4j.Logger 5 | import org.slf4j.LoggerFactory 6 | import org.springframework.util.Assert 7 | 8 | /** 9 | * The product domain entity. 10 | * 11 | * This entity encapsulates all information which belongs to a product. 12 | * It also provides business methods to work on this information. Data 13 | * cannot be changed from outside - there are no setters. 14 | * 15 | * Whenever data has been changed a domain event will thrown. This event 16 | * informs any listener that something has changed in the context of a 17 | * product. In a real life example, those events would be published over 18 | * a message broker such as Kafka, ActiveMQ or AWS Kinesis. 19 | */ 20 | class Product(command: CreateNewProductCommand) : DomainEntity(command.productNumber) { 21 | 22 | private val log: Logger = LoggerFactory.getLogger(this.javaClass) 23 | 24 | var productInformation: ProductInformation 25 | private set 26 | 27 | var imageUrl: String? = null // we have none until we get the first media data update 28 | private set 29 | 30 | init { 31 | 32 | Assert.hasText(command.name, "Product name must not be empty!") 33 | Assert.hasText(command.description, "Product description must not be empty!") 34 | 35 | productInformation = ProductInformation( 36 | name = command.name, 37 | description = command.description 38 | ) 39 | 40 | raise(ProductCreatedEvent(productNumber = id)) 41 | log.info("New product created. [productNumber={}]", id) 42 | } 43 | 44 | fun handle(command: UpdateMasterDataCommand) { 45 | 46 | Assert.hasText(command.name, "Product name must not be empty!") 47 | Assert.hasText(command.description, "Product description must not be empty!") 48 | 49 | this.productInformation = ProductInformation( 50 | name = command.name, 51 | description = command.description 52 | ) 53 | 54 | raise(MasterDataUpdatedEvent(productNumber = id)) 55 | log.info("Product master data updated. [productNumber={}]", id) 56 | } 57 | 58 | fun handle(command: UpdateMediaDataCommand) { 59 | 60 | Assert.hasText(command.imageUrl, "Image URL must not be empty!") 61 | 62 | this.imageUrl = command.imageUrl 63 | 64 | raise(MediaDataUpdatedEvent(productNumber = id)) 65 | log.info("Product media data updated. [productNumber={}]", id) 66 | } 67 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/product/ProductCreatedEvent.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain.product 2 | 3 | import de.bringmeister.connect.product.domain.Event 4 | 5 | data class ProductCreatedEvent( 6 | val productNumber: ProductNumber 7 | ) : Event 8 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/product/ProductInformation.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain.product 2 | 3 | data class ProductInformation( 4 | val name: String, 5 | val description: String 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/product/ProductNumber.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain.product 2 | 3 | import org.springframework.util.Assert 4 | 5 | data class ProductNumber(val productNumber: String) { 6 | 7 | private val productNumberFormat = Regex("P-[0-9]{6}") 8 | 9 | init { 10 | Assert.state( 11 | productNumber.matches(productNumberFormat), 12 | "Product number has an invalid format: ${productNumber}" 13 | ) 14 | } 15 | 16 | fun stringValue(): String { 17 | return productNumber 18 | } 19 | 20 | // We want a simpler toString format so that we can 21 | // use this object in log statements more easily. 22 | override fun toString(): String { 23 | return productNumber 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/product/ProductRepository.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain.product 2 | 3 | interface ProductRepository { 4 | fun find(productNumber: ProductNumber): Product 5 | fun exists(productNumber: ProductNumber): Boolean 6 | fun save(product: Product) 7 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/product/UpdateMasterDataCommand.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain.product 2 | 3 | import de.bringmeister.connect.product.domain.Command 4 | 5 | data class UpdateMasterDataCommand( 6 | val productNumber: ProductNumber, 7 | val name: String, 8 | val description: String 9 | ) : Command 10 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/domain/product/UpdateMediaDataCommand.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain.product 2 | 3 | import de.bringmeister.connect.product.domain.Command 4 | 5 | data class UpdateMediaDataCommand( 6 | val productNumber: ProductNumber, 7 | val imageUrl: String 8 | ) : Command 9 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/infrastructure/spring/SpringCommandBus.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.infrastructure.spring 2 | 3 | import de.bringmeister.connect.product.domain.Command 4 | import de.bringmeister.connect.product.domain.CommandBus 5 | import org.springframework.context.ApplicationEventPublisher 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class SpringCommandBus(private val publisher: ApplicationEventPublisher) : CommandBus { 10 | 11 | override fun send(command: Command) { 12 | publisher.publishEvent(command) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/infrastructure/spring/SpringEventBus.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.infrastructure.spring 2 | 3 | import de.bringmeister.connect.product.domain.Event 4 | import de.bringmeister.connect.product.domain.EventBus 5 | import org.springframework.context.ApplicationEventPublisher 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class SpringEventBus(private val publisher: ApplicationEventPublisher) : EventBus { 10 | 11 | override fun send(event: Event) { 12 | publisher.publishEvent(event) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/infrastructure/stubs/StubbedMediaDataRegistry.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.infrastructure.stubs 2 | 3 | import de.bringmeister.connect.product.application.mediadata.MediaDataRegistry 4 | import de.bringmeister.connect.product.application.mediadata.RegisterForMediaDataUpdatesCommand 5 | import de.bringmeister.connect.product.domain.EventBus 6 | import de.bringmeister.connect.product.ports.rest.MediaDataUpdateAvailableEvent 7 | import org.slf4j.Logger 8 | import org.slf4j.LoggerFactory 9 | import org.springframework.stereotype.Component 10 | 11 | @Component 12 | class StubbedMediaDataRegistry(private val eventBus: EventBus) : MediaDataRegistry { 13 | 14 | private val log: Logger = LoggerFactory.getLogger(this.javaClass) 15 | 16 | override fun handle(command: RegisterForMediaDataUpdatesCommand) { 17 | 18 | log.info("Registered for media data updates. [productNumber={}]", command.productNumber) 19 | 20 | // This event simulates the response of another external system. After 21 | // a product has been registered for updates at the media data service, 22 | // this service will eventually send an update to us. 23 | 24 | eventBus.send( 25 | MediaDataUpdateAvailableEvent( 26 | productNumber = command.productNumber, 27 | imageUrl = "www.my-domain.com/my-new-image.jpg" 28 | ) 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/infrastructure/stubs/StubbedProductRepository.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.infrastructure.stubs 2 | 3 | import de.bringmeister.connect.product.domain.product.Product 4 | import de.bringmeister.connect.product.domain.product.ProductNumber 5 | import de.bringmeister.connect.product.domain.product.ProductRepository 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class StubbedProductRepository : ProductRepository { 10 | 11 | private val products = mutableMapOf() 12 | 13 | override fun find(productNumber: ProductNumber): Product { 14 | return products[productNumber.productNumber] 15 | ?: throw RuntimeException("Product not found: " + productNumber.productNumber) 16 | } 17 | 18 | override fun exists(productNumber: ProductNumber): Boolean { 19 | return products.containsKey(productNumber.productNumber) 20 | } 21 | 22 | override fun save(product: Product) { 23 | products[product.id.productNumber] = product 24 | } 25 | 26 | fun clear() { 27 | products.clear() 28 | } 29 | } -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/ports/messages/MasterDataUpdateAvailableEventListener.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.ports.messages 2 | 3 | import de.bringmeister.connect.product.domain.CommandBus 4 | import de.bringmeister.connect.product.domain.EventListener 5 | import de.bringmeister.connect.product.domain.product.CreateNewProductCommand 6 | import de.bringmeister.connect.product.domain.product.ProductRepository 7 | import de.bringmeister.connect.product.domain.product.UpdateMasterDataCommand 8 | import de.bringmeister.connect.product.ports.rest.MasterDataUpdateAvailableEvent 9 | import org.springframework.stereotype.Component 10 | 11 | @Component 12 | class MasterDataUpdateAvailableEventListener( 13 | private val commandBus: CommandBus, 14 | private val productRepository: ProductRepository 15 | ) { 16 | 17 | @EventListener 18 | fun handle(event: MasterDataUpdateAvailableEvent) { 19 | 20 | val productExists = productRepository.exists(event.productNumber) 21 | 22 | if (productExists) { 23 | 24 | commandBus.send( 25 | UpdateMasterDataCommand( 26 | productNumber = event.productNumber, 27 | name = event.name, 28 | description = event.description 29 | ) 30 | ) 31 | } else { 32 | 33 | commandBus.send( 34 | CreateNewProductCommand( 35 | productNumber = event.productNumber, 36 | name = event.name, 37 | description = event.description 38 | ) 39 | ) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/ports/messages/MasterDataUpdatedEventListener.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.ports.messages 2 | 3 | import de.bringmeister.connect.product.application.search.UpdateSearchIndexCommand 4 | import de.bringmeister.connect.product.application.shop.UpdateShopCommand 5 | import de.bringmeister.connect.product.domain.CommandBus 6 | import de.bringmeister.connect.product.domain.EventListener 7 | import de.bringmeister.connect.product.domain.product.MasterDataUpdatedEvent 8 | import org.springframework.stereotype.Component 9 | 10 | @Component 11 | class MasterDataUpdatedEventListener(private val commandBus: CommandBus) { 12 | 13 | @EventListener 14 | fun handle(event: MasterDataUpdatedEvent) { 15 | 16 | commandBus.send( 17 | UpdateShopCommand( 18 | productNumber = event.productNumber 19 | ) 20 | ) 21 | 22 | commandBus.send( 23 | UpdateSearchIndexCommand( 24 | productNumber = event.productNumber 25 | ) 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/ports/messages/MediaDataUpdateAvailableEventListener.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.ports.messages 2 | 3 | import de.bringmeister.connect.product.domain.CommandBus 4 | import de.bringmeister.connect.product.domain.EventListener 5 | import de.bringmeister.connect.product.domain.product.UpdateMediaDataCommand 6 | import de.bringmeister.connect.product.ports.rest.MediaDataUpdateAvailableEvent 7 | import org.springframework.stereotype.Component 8 | 9 | @Component 10 | class MediaDataUpdateAvailableEventListener(private val commandBus: CommandBus) { 11 | 12 | @EventListener 13 | fun handle(event: MediaDataUpdateAvailableEvent) { 14 | 15 | commandBus.send( 16 | UpdateMediaDataCommand( 17 | productNumber = event.productNumber, 18 | imageUrl = event.imageUrl 19 | ) 20 | ) 21 | } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/ports/messages/MediaDataUpdatedEventListener.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.ports.messages 2 | 3 | import de.bringmeister.connect.product.application.cdn.UpdateCdnCommand 4 | import de.bringmeister.connect.product.domain.CommandBus 5 | import de.bringmeister.connect.product.domain.EventListener 6 | import de.bringmeister.connect.product.domain.product.MediaDataUpdatedEvent 7 | import org.springframework.stereotype.Component 8 | 9 | @Component 10 | class MediaDataUpdatedEventListener(private val commandBus: CommandBus) { 11 | 12 | @EventListener 13 | fun handle(event: MediaDataUpdatedEvent) { 14 | 15 | val updateCdnCommand = UpdateCdnCommand(event.productNumber) 16 | commandBus.send(updateCdnCommand) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/ports/messages/ProductCreatedEventListener.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.ports.messages 2 | 3 | import de.bringmeister.connect.product.application.mediadata.RegisterForMediaDataUpdatesCommand 4 | import de.bringmeister.connect.product.application.search.UpdateSearchIndexCommand 5 | import de.bringmeister.connect.product.application.shop.UpdateShopCommand 6 | import de.bringmeister.connect.product.domain.CommandBus 7 | import de.bringmeister.connect.product.domain.EventListener 8 | import de.bringmeister.connect.product.domain.product.ProductCreatedEvent 9 | import org.springframework.stereotype.Component 10 | 11 | @Component 12 | class ProductCreatedEventListener(private val commandBus: CommandBus) { 13 | 14 | @EventListener 15 | fun handle(event: ProductCreatedEvent) { 16 | 17 | commandBus.send( 18 | RegisterForMediaDataUpdatesCommand( 19 | productNumber = event.productNumber 20 | ) 21 | ) 22 | 23 | commandBus.send( 24 | UpdateShopCommand( 25 | productNumber = event.productNumber 26 | ) 27 | ) 28 | 29 | commandBus.send( 30 | UpdateSearchIndexCommand( 31 | productNumber = event.productNumber 32 | ) 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/ports/rest/DemoController.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.ports.rest 2 | 3 | import de.bringmeister.connect.product.domain.EventBus 4 | import de.bringmeister.connect.product.domain.product.ProductNumber 5 | import org.springframework.web.bind.annotation.PostMapping 6 | import org.springframework.web.bind.annotation.RestController 7 | 8 | /** 9 | * A REST controller to simulate the two starting points of the demo application: 10 | * 11 | * - The "MasterDataUpdateAvailableEvent" is thrown by the "Master Data Service". 12 | * - The "MediaDataUpdateAvailableEvent" is thrown by the "Media Data Service". 13 | * 14 | * See the "README.md" for an overview of the business process! 15 | */ 16 | @RestController 17 | class DemoController(private val eventBus: EventBus) { 18 | 19 | @PostMapping("/master_data_update") 20 | fun masterDataUpdate() { 21 | 22 | // Simulate an incoming event from another external system. 23 | // In our example, this event would be thrown by the external 24 | // "Master Data Service". 25 | 26 | eventBus.send( 27 | MasterDataUpdateAvailableEvent( 28 | productNumber = ProductNumber("P-000001"), 29 | name = "Coca Cola", 30 | description = "A bottle of tasty Coca Cola" 31 | ) 32 | ) 33 | } 34 | 35 | @PostMapping("/media_data_update") 36 | fun mediaDataUpdate() { 37 | 38 | // Simulate an incoming event from another external system. 39 | // In our example, this event would be thrown by the external 40 | // "Media Data Service". 41 | 42 | eventBus.send( 43 | MediaDataUpdateAvailableEvent( 44 | productNumber = ProductNumber("P-000001"), 45 | imageUrl = "www.my-domain.com/my-image.jpg" 46 | ) 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/ports/rest/MasterDataUpdateAvailableEvent.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.ports.rest 2 | 3 | import de.bringmeister.connect.product.domain.Event 4 | import de.bringmeister.connect.product.domain.product.ProductNumber 5 | 6 | data class MasterDataUpdateAvailableEvent( 7 | val productNumber: ProductNumber, 8 | val name: String, 9 | val description: String 10 | ) : Event 11 | -------------------------------------------------------------------------------- /src/main/kotlin/de/bringmeister/connect/product/ports/rest/MediaDataUpdateAvailableEvent.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.ports.rest 2 | 3 | import de.bringmeister.connect.product.domain.Event 4 | import de.bringmeister.connect.product.domain.product.ProductNumber 5 | 6 | data class MediaDataUpdateAvailableEvent( 7 | val productNumber: ProductNumber, 8 | val imageUrl: String 9 | ) : Event 10 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: "ddd-kotlin" -------------------------------------------------------------------------------- /src/main/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/test/kotlin/de/bringmeister/connect/product/AcceptanceTest.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product 2 | 3 | import de.bringmeister.connect.product.AcceptanceTest.RecordingHandler 4 | import de.bringmeister.connect.product.AcceptanceTest.RecordingHandlerAssert.Companion.assertThat 5 | import de.bringmeister.connect.product.application.cdn.UpdateCdnCommand 6 | import de.bringmeister.connect.product.application.mediadata.RegisterForMediaDataUpdatesCommand 7 | import de.bringmeister.connect.product.application.search.UpdateSearchIndexCommand 8 | import de.bringmeister.connect.product.application.shop.UpdateShopCommand 9 | import de.bringmeister.connect.product.domain.Command 10 | import de.bringmeister.connect.product.domain.CommandListener 11 | import de.bringmeister.connect.product.domain.Event 12 | import de.bringmeister.connect.product.domain.EventBus 13 | import de.bringmeister.connect.product.domain.EventListener 14 | import de.bringmeister.connect.product.domain.product.CreateNewProductCommand 15 | import de.bringmeister.connect.product.domain.product.MasterDataUpdatedEvent 16 | import de.bringmeister.connect.product.domain.product.MediaDataUpdatedEvent 17 | import de.bringmeister.connect.product.domain.product.Product 18 | import de.bringmeister.connect.product.domain.product.ProductCreatedEvent 19 | import de.bringmeister.connect.product.domain.product.ProductNumber 20 | import de.bringmeister.connect.product.domain.product.UpdateMasterDataCommand 21 | import de.bringmeister.connect.product.domain.product.UpdateMediaDataCommand 22 | import de.bringmeister.connect.product.infrastructure.stubs.StubbedProductRepository 23 | import de.bringmeister.connect.product.ports.rest.MasterDataUpdateAvailableEvent 24 | import de.bringmeister.connect.product.ports.rest.MediaDataUpdateAvailableEvent 25 | import org.assertj.core.api.AbstractAssert 26 | import org.junit.Before 27 | import org.junit.Test 28 | import org.junit.runner.RunWith 29 | import org.springframework.beans.factory.annotation.Autowired 30 | import org.springframework.boot.test.context.SpringBootTest 31 | import org.springframework.context.annotation.Import 32 | import org.springframework.stereotype.Service 33 | import org.springframework.test.context.junit4.SpringRunner 34 | import java.util.concurrent.CountDownLatch 35 | import java.util.concurrent.TimeUnit.SECONDS 36 | 37 | @RunWith(SpringRunner::class) 38 | @SpringBootTest 39 | @Import(RecordingHandler::class) 40 | class AcceptanceTest { 41 | 42 | private val productNumber = ProductNumber("P-000001") 43 | private val name = "Coca-Cola" 44 | private val description = "Tasty Coca-Cola!" 45 | private val url = "www.my-domain.com/my-new-image.jpg" 46 | 47 | @Autowired 48 | private lateinit var eventBus: EventBus 49 | 50 | @Autowired 51 | private lateinit var recordingHandler: RecordingHandler 52 | 53 | @Autowired 54 | private lateinit var productRepository: StubbedProductRepository 55 | 56 | companion object { 57 | var latch = CountDownLatch(1) 58 | } 59 | 60 | @Before 61 | fun setUp() { 62 | productRepository.clear() 63 | recordingHandler.clear() 64 | } 65 | 66 | @Test 67 | fun `should create new product when master data is updated for the first time`() { 68 | 69 | val input = MasterDataUpdateAvailableEvent(productNumber, name, description) 70 | 71 | val expectedMessages = setOf( 72 | input, 73 | CreateNewProductCommand(productNumber, name, description), 74 | ProductCreatedEvent(productNumber), 75 | RegisterForMediaDataUpdatesCommand(productNumber), 76 | UpdateShopCommand(productNumber), 77 | UpdateSearchIndexCommand(productNumber), 78 | MediaDataUpdateAvailableEvent(productNumber, url), 79 | UpdateMediaDataCommand(productNumber, url), 80 | MediaDataUpdatedEvent(productNumber), 81 | UpdateCdnCommand(productNumber) 82 | ) 83 | 84 | eventBus.send(input) 85 | 86 | assertThat(recordingHandler).received(expectedMessages) 87 | } 88 | 89 | @Test 90 | fun `should update an existing product when master data is updated`() { 91 | 92 | prepareAnExistingProduct() 93 | 94 | val input = MasterDataUpdateAvailableEvent(productNumber, name, description) 95 | 96 | val expectedMessages = setOf( 97 | input, 98 | UpdateMasterDataCommand(productNumber, name, description), 99 | MasterDataUpdatedEvent(productNumber), 100 | UpdateShopCommand(productNumber), 101 | UpdateSearchIndexCommand(productNumber) 102 | ) 103 | 104 | eventBus.send(input) 105 | 106 | assertThat(recordingHandler).received(expectedMessages) 107 | } 108 | 109 | @Test 110 | fun `should ignore media data updates for unknown products`() { 111 | 112 | val input = MediaDataUpdateAvailableEvent(productNumber, url) 113 | 114 | val expectedMessages = setOf( 115 | input, 116 | UpdateMediaDataCommand(productNumber, url) 117 | ) 118 | 119 | eventBus.send(input) 120 | 121 | assertThat(recordingHandler).received(expectedMessages) 122 | } 123 | 124 | @Test 125 | fun `should apply media data updates for existing products`() { 126 | 127 | prepareAnExistingProduct() 128 | 129 | val input = MediaDataUpdateAvailableEvent(productNumber, url) 130 | 131 | val expectedMessages = setOf( 132 | input, 133 | UpdateMediaDataCommand(productNumber, url), 134 | MediaDataUpdatedEvent(productNumber), 135 | UpdateCdnCommand(productNumber) 136 | ) 137 | 138 | eventBus.send(input) 139 | 140 | assertThat(recordingHandler).received(expectedMessages) 141 | } 142 | 143 | private fun prepareAnExistingProduct() { 144 | val command = CreateNewProductCommand(productNumber, name, description) 145 | val product = Product(command) 146 | product.occurredEvents() // get the events once to clear the list 147 | productRepository.save(product) 148 | } 149 | 150 | @Service 151 | class RecordingHandler { 152 | 153 | val messages = mutableSetOf() 154 | 155 | @EventListener 156 | fun handle(event: Event) { 157 | messages.add(event) 158 | latch.countDown() 159 | } 160 | 161 | @CommandListener 162 | fun handle(command: Command) { 163 | messages.add(command) 164 | latch.countDown() 165 | } 166 | 167 | fun clear() { 168 | messages.clear() 169 | } 170 | } 171 | 172 | class RecordingHandlerAssert(recordingHandler: RecordingHandler) : 173 | AbstractAssert(recordingHandler, RecordingHandlerAssert::class.java) { 174 | 175 | companion object { 176 | fun assertThat(actual: RecordingHandler): RecordingHandlerAssert { 177 | return RecordingHandlerAssert(actual) 178 | } 179 | } 180 | 181 | fun received(expectedEvents: Set): RecordingHandlerAssert { 182 | 183 | latch = CountDownLatch(expectedEvents.size) 184 | latch.await(10, SECONDS) 185 | 186 | if (!actual.messages.containsAll(expectedEvents)) { 187 | failWithMessage("Expected messages to be <%s> but was <%s>", expectedEvents, actual.messages) 188 | } 189 | 190 | return this 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/test/kotlin/de/bringmeister/connect/product/SpringContextTest.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product 2 | 3 | import org.junit.Test 4 | import org.junit.runner.RunWith 5 | import org.springframework.boot.test.context.SpringBootTest 6 | import org.springframework.test.context.junit4.SpringRunner 7 | 8 | /** 9 | * This test will run the complete Spring Boot application. It 10 | * will make sure that all beans and configurations are found 11 | * and that the application will start. 12 | */ 13 | @RunWith(SpringRunner::class) 14 | @SpringBootTest 15 | class SpringContextTest { 16 | 17 | @Test 18 | fun `should start app`() { 19 | // empty as only the start up is tested 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/kotlin/de/bringmeister/connect/product/application/cdn/ImageCdnServiceTest.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.cdn 2 | 3 | import ch.qos.logback.classic.Logger 4 | import ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME 5 | import ch.qos.logback.classic.spi.ILoggingEvent 6 | import ch.qos.logback.core.Appender 7 | import de.bringmeister.connect.product.domain.product.ProductNumber 8 | import org.junit.After 9 | import org.junit.Assert.assertEquals 10 | import org.junit.Before 11 | import org.junit.Test 12 | import org.junit.runner.RunWith 13 | import org.mockito.ArgumentCaptor 14 | import org.mockito.Captor 15 | import org.mockito.Mock 16 | import org.mockito.Mockito.verify 17 | import org.mockito.junit.MockitoJUnitRunner 18 | import org.slf4j.LoggerFactory.getLogger 19 | 20 | @RunWith(MockitoJUnitRunner::class) 21 | class ImageCdnServiceTest { 22 | 23 | @Mock 24 | private lateinit var appender: Appender 25 | 26 | @Captor 27 | private lateinit var captor: ArgumentCaptor 28 | 29 | @Before 30 | fun setUp() { 31 | val logger = getLogger(ROOT_LOGGER_NAME) as Logger 32 | logger.addAppender(appender) 33 | } 34 | 35 | @After 36 | fun tearDown() { 37 | val logger = getLogger(ROOT_LOGGER_NAME) as Logger 38 | logger.detachAppender(appender) 39 | } 40 | 41 | @Test 42 | fun `should update Image CDN`() { 43 | 44 | val command = UpdateCdnCommand(ProductNumber("P-000001")) 45 | val service = ImageCdnService() 46 | service.handle(command) 47 | 48 | verify(appender).doAppend(captor.capture()) 49 | val loggingEvent = captor.value 50 | assertEquals(loggingEvent.formattedMessage, "Image CDN has been updated. [productNumber=P-000001]"); 51 | } 52 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/bringmeister/connect/product/application/search/SearchIndexServiceTest.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.search 2 | 3 | import ch.qos.logback.classic.Logger 4 | import ch.qos.logback.classic.spi.ILoggingEvent 5 | import ch.qos.logback.core.Appender 6 | import de.bringmeister.connect.product.domain.product.ProductNumber 7 | import org.junit.After 8 | import org.junit.Assert 9 | import org.junit.Before 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import org.mockito.ArgumentCaptor 13 | import org.mockito.Captor 14 | import org.mockito.Mock 15 | import org.mockito.Mockito 16 | import org.mockito.junit.MockitoJUnitRunner 17 | import org.slf4j.LoggerFactory 18 | 19 | @RunWith(MockitoJUnitRunner::class) 20 | class SearchIndexServiceTest { 21 | 22 | @Mock 23 | private lateinit var appender: Appender 24 | 25 | @Captor 26 | private lateinit var captor: ArgumentCaptor 27 | 28 | @Before 29 | fun setUp() { 30 | val logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) as Logger 31 | logger.addAppender(appender) 32 | } 33 | 34 | @After 35 | fun tearDown() { 36 | val logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) as Logger 37 | logger.detachAppender(appender) 38 | } 39 | 40 | @Test 41 | fun `should update Image CDN`() { 42 | 43 | val command = UpdateSearchIndexCommand(ProductNumber("P-000001")) 44 | val service = SearchIndexService() 45 | service.handle(command) 46 | 47 | Mockito.verify(appender).doAppend(captor.capture()) 48 | val loggingEvent = captor.value 49 | Assert.assertEquals(loggingEvent.formattedMessage, "Search index has been updated. [productNumber=P-000001]"); 50 | } 51 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/bringmeister/connect/product/application/shop/ShopServiceTest.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.application.shop 2 | 3 | import ch.qos.logback.classic.Logger 4 | import ch.qos.logback.classic.spi.ILoggingEvent 5 | import ch.qos.logback.core.Appender 6 | import de.bringmeister.connect.product.domain.product.ProductNumber 7 | import org.junit.After 8 | import org.junit.Assert 9 | import org.junit.Before 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import org.mockito.ArgumentCaptor 13 | import org.mockito.Captor 14 | import org.mockito.Mock 15 | import org.mockito.Mockito 16 | import org.mockito.junit.MockitoJUnitRunner 17 | import org.slf4j.LoggerFactory 18 | 19 | @RunWith(MockitoJUnitRunner::class) 20 | class ShopServiceTest { 21 | 22 | @Mock 23 | private lateinit var appender: Appender 24 | 25 | @Captor 26 | private lateinit var captor: ArgumentCaptor 27 | 28 | @Before 29 | fun setUp() { 30 | val logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) as Logger 31 | logger.addAppender(appender) 32 | } 33 | 34 | @After 35 | fun tearDown() { 36 | val logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) as Logger 37 | logger.detachAppender(appender) 38 | } 39 | 40 | @Test 41 | fun `should update Image CDN`() { 42 | 43 | val command = UpdateShopCommand(ProductNumber("P-000001")) 44 | val service = ShopService() 45 | service.handle(command) 46 | 47 | Mockito.verify(appender).doAppend(captor.capture()) 48 | val loggingEvent = captor.value 49 | Assert.assertEquals(loggingEvent.formattedMessage, "Shop has been updated. [productNumber=P-000001]"); 50 | } 51 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/bringmeister/connect/product/domain/DomainEntityTest.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.junit.Test 5 | 6 | class DomainEntityTest { 7 | 8 | @Test 9 | fun `should equal domain entity of same type with same ID`() { 10 | val entity1 = MyDomainEntityA("Jon") 11 | val entity2 = MyDomainEntityA("Jon") 12 | 13 | assertThat(entity1).isEqualTo(entity2) 14 | } 15 | 16 | @Test 17 | fun `should not equal domain entity of different type`() { 18 | val entityA = MyDomainEntityA("Jon") 19 | val entityB = MyDomainEntityB("Jon") 20 | 21 | assertThat(entityA).isNotEqualTo(entityB) 22 | } 23 | 24 | @Test 25 | fun `should return events`() { 26 | 27 | val entity = MyDomainEntityA("Jon") 28 | entity.doSomething() 29 | 30 | val events = entity.occurredEvents() 31 | assertThat(events).hasSize(1) 32 | } 33 | 34 | @Test 35 | fun `should return events only once`() { 36 | 37 | val entity = MyDomainEntityA("Jon") 38 | entity.doSomething() 39 | 40 | val events1 = entity.occurredEvents() 41 | val events2 = entity.occurredEvents() 42 | 43 | assertThat(events1).hasSize(1) // Events are returned once... 44 | assertThat(events2).hasSize(0) // ...but not twice! 45 | } 46 | 47 | private class MyDomainEvent : Event 48 | 49 | private class MyDomainEntityA(name: String) : DomainEntity(name) { 50 | 51 | fun doSomething() { 52 | raise(MyDomainEvent()) 53 | } 54 | } 55 | 56 | private class MyDomainEntityB(name: String) : DomainEntity(name) 57 | } 58 | -------------------------------------------------------------------------------- /src/test/kotlin/de/bringmeister/connect/product/domain/product/ProductNumberTest.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain.product 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.assertj.core.api.Assertions.assertThatThrownBy 5 | import org.junit.Test 6 | 7 | class ProductNumberTest { 8 | 9 | @Test 10 | fun `should create new product number`() { 11 | val productNumber = ProductNumber("P-000001") 12 | assertThat(productNumber.stringValue()).isEqualTo("P-000001") 13 | } 14 | 15 | @Test 16 | fun `should be equal to product number with same value`() { 17 | val productNumber1 = ProductNumber("P-000001") 18 | val productNumber2 = ProductNumber("P-000001") 19 | assertThat(productNumber1).isEqualTo(productNumber2) 20 | } 21 | 22 | @Test 23 | fun `should not be equal to product number with different value`() { 24 | val productNumber1 = ProductNumber("P-000001") 25 | val productNumber2 = ProductNumber("P-000002") 26 | assertThat(productNumber1).isNotEqualTo(productNumber2) 27 | } 28 | 29 | @Test 30 | fun `should use simple toString format`() { 31 | val productNumber = ProductNumber("P-000001") 32 | assertThat(productNumber.toString()).isEqualTo("P-000001") 33 | } 34 | 35 | @Test 36 | fun `should throw exception on invalid format`() { 37 | assertThatThrownBy { ProductNumber("X-1") } 38 | .isInstanceOf(IllegalStateException::class.java) 39 | .hasNoCause() 40 | .hasMessageContaining("Product number has an invalid format: X-1") 41 | } 42 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/bringmeister/connect/product/domain/product/ProductTest.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.domain.product 2 | 3 | import org.assertj.core.api.Assertions.assertThat 4 | import org.assertj.core.api.Assertions.assertThatThrownBy 5 | import org.junit.Test 6 | 7 | class ProductTest { 8 | 9 | @Test 10 | fun `should create new product`() { 11 | 12 | val command = aCreateNewProductCommand() 13 | val product = Product(command) 14 | 15 | assertThat(product.id.stringValue()).isEqualTo("P-000001") 16 | assertThat(product.productInformation.name).isEqualTo("Coca Cola") 17 | assertThat(product.productInformation.description).isEqualTo("This is a bottle of tasty Coca Cola!") 18 | assertThat(product.imageUrl).isNull() 19 | } 20 | 21 | @Test 22 | fun `should throw event when product is created`() { 23 | 24 | val command = aCreateNewProductCommand() 25 | val product = Product(command) 26 | 27 | val events = product.occurredEvents() 28 | assertThat(events).hasSize(1) 29 | assertThat(events[0]).isEqualTo(ProductCreatedEvent(productNumber = ProductNumber("P-000001"))) 30 | } 31 | 32 | @Test 33 | fun `should clear events after returning them`() { 34 | 35 | val command = aCreateNewProductCommand() 36 | val product = Product(command) 37 | 38 | assertThat(product.occurredEvents()).hasSize(1) 39 | assertThat(product.occurredEvents()).hasSize(0) // list is empty now! 40 | } 41 | 42 | @Test 43 | fun `should throw exception on empty name`() { 44 | assertThatThrownBy { 45 | Product( 46 | CreateNewProductCommand( 47 | productNumber = ProductNumber("P-000001"), 48 | name = "", // empty! 49 | description = "This is a bottle of tasty Coca Cola!" 50 | ) 51 | ) 52 | } 53 | .isInstanceOf(IllegalArgumentException::class.java) 54 | .hasNoCause() 55 | .hasMessageContaining("Product name must not be empty!") 56 | } 57 | 58 | @Test 59 | fun `should throw exception on empty description`() { 60 | assertThatThrownBy { 61 | Product( 62 | CreateNewProductCommand( 63 | productNumber = ProductNumber("P-000001"), 64 | name = "Coca Cola", 65 | description = "" // empty! 66 | ) 67 | ) 68 | } 69 | .isInstanceOf(IllegalArgumentException::class.java) 70 | .hasNoCause() 71 | .hasMessageContaining("Product description must not be empty!") 72 | } 73 | 74 | @Test 75 | fun `should equal product with same ID`() { 76 | 77 | // We've got two products with different values but with 78 | // the same ID - so they represent the same product! 79 | 80 | val product1 = Product( 81 | CreateNewProductCommand( 82 | productNumber = ProductNumber("P-000001"), 83 | name = "Coca Cola", 84 | description = "This is a bottle of tasty Coca Cola!" 85 | ) 86 | ) 87 | 88 | val product2 = Product( 89 | CreateNewProductCommand( 90 | productNumber = ProductNumber("P-000001"), 91 | name = "Coca Cola", 92 | description = "This is a bottle of tasty Coca Cola!" 93 | ) 94 | ) 95 | 96 | assertThat(product1).isEqualTo(product2) 97 | } 98 | 99 | private fun aCreateNewProductCommand(): CreateNewProductCommand { 100 | return CreateNewProductCommand( 101 | productNumber = ProductNumber("P-000001"), 102 | name = "Coca Cola", 103 | description = "This is a bottle of tasty Coca Cola!" 104 | ) 105 | } 106 | } -------------------------------------------------------------------------------- /src/test/kotlin/de/bringmeister/connect/product/infrastructure/spring/SpringCommandBusTest.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.infrastructure.spring 2 | 3 | import de.bringmeister.connect.product.domain.Command 4 | import de.bringmeister.connect.product.domain.CommandBus 5 | import de.bringmeister.connect.product.domain.CommandListener 6 | import de.bringmeister.connect.product.infrastructure.spring.SpringCommandBusTest.MyCommandHandler 7 | import org.assertj.core.api.Assertions.assertThat 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | import org.springframework.beans.factory.annotation.Autowired 11 | import org.springframework.stereotype.Service 12 | import org.springframework.test.context.ContextConfiguration 13 | import org.springframework.test.context.junit4.SpringRunner 14 | import java.util.concurrent.CountDownLatch 15 | import java.util.concurrent.TimeUnit.SECONDS 16 | 17 | @RunWith(SpringRunner::class) 18 | @ContextConfiguration(classes = [SpringCommandBus::class, MyCommandHandler::class]) 19 | class SpringCommandBusTest { 20 | 21 | @Autowired 22 | private lateinit var commandBus: CommandBus 23 | 24 | @Autowired 25 | private lateinit var myCommandHandler: MyCommandHandler 26 | 27 | companion object { 28 | val latch = CountDownLatch(1) 29 | } 30 | 31 | @Test 32 | fun `should send and receive command`() { 33 | commandBus.send(MyCommand("I'm a command!")) 34 | latch.await(5, SECONDS) 35 | assertThat(myCommandHandler.invoked).isTrue() 36 | } 37 | 38 | @Service 39 | class MyCommandHandler { 40 | 41 | var invoked = false 42 | 43 | @CommandListener 44 | fun handle(myCommand: MyCommand) { 45 | invoked = true 46 | assertThat(myCommand.data).isEqualTo("I'm a command!") 47 | latch.countDown() 48 | } 49 | } 50 | 51 | data class MyCommand(val data: String) : Command 52 | } 53 | -------------------------------------------------------------------------------- /src/test/kotlin/de/bringmeister/connect/product/infrastructure/spring/SpringEventBusTest.kt: -------------------------------------------------------------------------------- 1 | package de.bringmeister.connect.product.infrastructure.spring 2 | 3 | import de.bringmeister.connect.product.domain.Event 4 | import de.bringmeister.connect.product.domain.EventBus 5 | import de.bringmeister.connect.product.domain.EventListener 6 | import de.bringmeister.connect.product.infrastructure.spring.SpringEventBusTest.MyEventHandler 7 | import org.assertj.core.api.Assertions.assertThat 8 | import org.junit.Test 9 | import org.junit.runner.RunWith 10 | import org.springframework.beans.factory.annotation.Autowired 11 | import org.springframework.stereotype.Service 12 | import org.springframework.test.context.ContextConfiguration 13 | import org.springframework.test.context.junit4.SpringRunner 14 | import java.util.concurrent.CountDownLatch 15 | import java.util.concurrent.TimeUnit.SECONDS 16 | 17 | @RunWith(SpringRunner::class) 18 | @ContextConfiguration(classes = [SpringEventBus::class, MyEventHandler::class]) 19 | class SpringEventBusTest { 20 | 21 | @Autowired 22 | private lateinit var eventBus: EventBus 23 | 24 | @Autowired 25 | private lateinit var myEventHandler: MyEventHandler 26 | 27 | companion object { 28 | val latch = CountDownLatch(1) 29 | } 30 | 31 | @Test 32 | fun `should send and receive command`() { 33 | eventBus.send(MyEvent("I'm an event!")) 34 | latch.await(5, SECONDS) 35 | assertThat(myEventHandler.invoked).isTrue() 36 | } 37 | 38 | @Service 39 | class MyEventHandler { 40 | 41 | var invoked = false 42 | 43 | @EventListener 44 | fun handle(myEvent: MyEvent) { 45 | invoked = true 46 | assertThat(myEvent.data).isEqualTo("I'm an event!") 47 | latch.countDown() 48 | } 49 | } 50 | 51 | data class MyEvent(val data: String) : Event 52 | } 53 | --------------------------------------------------------------------------------