예제 코드
This commit is contained in:
36
.github/workflows/ci.yml
vendored
Normal file
36
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: "Checkout sources"
|
||||||
|
uses: actions/checkout@v1
|
||||||
|
|
||||||
|
- name: "Setup Java"
|
||||||
|
uses: actions/setup-java@v1
|
||||||
|
with:
|
||||||
|
java-version: 14
|
||||||
|
|
||||||
|
- name: "Initialize Gradle dependencies cache"
|
||||||
|
uses: actions/cache@v2
|
||||||
|
with:
|
||||||
|
path: ~/.gradle/caches
|
||||||
|
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/build.gradle') }}
|
||||||
|
|
||||||
|
- name: "Run Gradle build"
|
||||||
|
run: chmod 755 gradlew && ./gradlew build
|
||||||
|
|
||||||
|
- name: "Zip build reports"
|
||||||
|
if: failure()
|
||||||
|
run: zip -r reports.zip build/reports
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v1
|
||||||
|
name: "Upload build reports"
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: reports
|
||||||
|
path: reports.zip
|
||||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
HELP.md
|
||||||
|
.gradle
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
### STS ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
||||||
19
README.md
Normal file
19
README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Example Implementation of a Hexagonal Architecture
|
||||||
|
|
||||||
|
[](https://github.com/thombergs/buckpal/actions/workflows/ci.yml)
|
||||||
|
|
||||||
|
[](https://reflectoring.io/book)
|
||||||
|
|
||||||
|
This is the companion code to my eBook [Get Your Hands Dirty on Clean Architecture](https://leanpub.com/get-your-hands-dirty-on-clean-architecture).
|
||||||
|
|
||||||
|
It implements a domain-centric "Hexagonal" approach of a common web application with Java and Spring Boot.
|
||||||
|
|
||||||
|
## Companion Articles
|
||||||
|
|
||||||
|
* [Hexagonal Architecture with Java and Spring](https://reflectoring.io/spring-hexagonal/)
|
||||||
|
* [Building a Multi-Module Spring Boot Application with Gradle](https://reflectoring.io/spring-boot-gradle-multi-module/)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
* JDK 11
|
||||||
|
* this project uses Lombok, so enable annotation processing in your IDE
|
||||||
47
build.gradle
Normal file
47
build.gradle
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
plugins {
|
||||||
|
id 'org.springframework.boot' version '2.4.3'
|
||||||
|
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
group = 'io.reflectoring.buckpal'
|
||||||
|
version = '0.0.1-SNAPSHOT'
|
||||||
|
|
||||||
|
apply plugin: 'java'
|
||||||
|
apply plugin: 'io.spring.dependency-management'
|
||||||
|
apply plugin: 'java-library'
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
compileJava {
|
||||||
|
sourceCompatibility = 11
|
||||||
|
targetCompatibility = 11
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly 'org.projectlombok:lombok'
|
||||||
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
|
|
||||||
|
implementation ('org.springframework.boot:spring-boot-starter-web')
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
|
|
||||||
|
testImplementation('org.springframework.boot:spring-boot-starter-test') {
|
||||||
|
exclude group: 'junit' // excluding junit 4
|
||||||
|
}
|
||||||
|
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.0.1'
|
||||||
|
testImplementation 'org.mockito:mockito-junit-jupiter:2.23.0'
|
||||||
|
testImplementation 'com.tngtech.archunit:archunit:0.16.0'
|
||||||
|
testImplementation 'org.junit.platform:junit-platform-launcher:1.4.2'
|
||||||
|
testImplementation 'com.h2database:h2'
|
||||||
|
|
||||||
|
runtimeOnly 'com.h2database:h2'
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-bin.zip
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
172
gradlew
vendored
Normal file
172
gradlew
vendored
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m"'
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin, switch paths to Windows format before running java
|
||||||
|
if $cygwin ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
(0) set -- ;;
|
||||||
|
(1) set -- "$args0" ;;
|
||||||
|
(2) set -- "$args0" "$args1" ;;
|
||||||
|
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=$(save "$@")
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
|
|
||||||
|
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||||
|
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
84
gradlew.bat
vendored
Normal file
84
gradlew.bat
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%" == "0" goto init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:init
|
||||||
|
@rem Get command-line arguments, handling Windows variants
|
||||||
|
|
||||||
|
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||||
|
|
||||||
|
:win9xME_args
|
||||||
|
@rem Slurp the command line arguments.
|
||||||
|
set CMD_LINE_ARGS=
|
||||||
|
set _SKIP=2
|
||||||
|
|
||||||
|
:win9xME_args_slurp
|
||||||
|
if "x%~1" == "x" goto execute
|
||||||
|
|
||||||
|
set CMD_LINE_ARGS=%*
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package io.reflectoring.buckpal;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class BuckPalApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(BuckPalApplication.class, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package io.reflectoring.buckpal;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.application.service.MoneyTransferProperties;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(BuckPalConfigurationProperties.class)
|
||||||
|
public class BuckPalConfiguration {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a use-case-specific {@link MoneyTransferProperties} object to the application context. The properties
|
||||||
|
* are read from the Spring-Boot-specific {@link BuckPalConfigurationProperties} object.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public MoneyTransferProperties moneyTransferProperties(BuckPalConfigurationProperties buckPalConfigurationProperties){
|
||||||
|
return new MoneyTransferProperties(Money.of(buckPalConfigurationProperties.getTransferThreshold()));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package io.reflectoring.buckpal;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@ConfigurationProperties(prefix = "buckpal")
|
||||||
|
public class BuckPalConfigurationProperties {
|
||||||
|
|
||||||
|
private long transferThreshold = Long.MAX_VALUE;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package io.reflectoring.buckpal.account.adapter.in.web;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.application.port.in.SendMoneyUseCase;
|
||||||
|
import io.reflectoring.buckpal.account.application.port.in.SendMoneyCommand;
|
||||||
|
import io.reflectoring.buckpal.common.WebAdapter;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@WebAdapter
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
class SendMoneyController {
|
||||||
|
|
||||||
|
private final SendMoneyUseCase sendMoneyUseCase;
|
||||||
|
|
||||||
|
@PostMapping(path = "/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}")
|
||||||
|
void sendMoney(
|
||||||
|
@PathVariable("sourceAccountId") Long sourceAccountId,
|
||||||
|
@PathVariable("targetAccountId") Long targetAccountId,
|
||||||
|
@PathVariable("amount") Long amount) {
|
||||||
|
|
||||||
|
SendMoneyCommand command = new SendMoneyCommand(
|
||||||
|
new AccountId(sourceAccountId),
|
||||||
|
new AccountId(targetAccountId),
|
||||||
|
Money.of(amount));
|
||||||
|
|
||||||
|
sendMoneyUseCase.sendMoney(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||||
|
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.GeneratedValue;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "account")
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
class AccountJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Activity;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Activity.ActivityId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.ActivityWindow;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class AccountMapper {
|
||||||
|
|
||||||
|
Account mapToDomainEntity(
|
||||||
|
AccountJpaEntity account,
|
||||||
|
List<ActivityJpaEntity> activities,
|
||||||
|
Long withdrawalBalance,
|
||||||
|
Long depositBalance) {
|
||||||
|
|
||||||
|
Money baselineBalance = Money.subtract(
|
||||||
|
Money.of(depositBalance),
|
||||||
|
Money.of(withdrawalBalance));
|
||||||
|
|
||||||
|
return Account.withId(
|
||||||
|
new AccountId(account.getId()),
|
||||||
|
baselineBalance,
|
||||||
|
mapToActivityWindow(activities));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityWindow mapToActivityWindow(List<ActivityJpaEntity> activities) {
|
||||||
|
List<Activity> mappedActivities = new ArrayList<>();
|
||||||
|
|
||||||
|
for (ActivityJpaEntity activity : activities) {
|
||||||
|
mappedActivities.add(new Activity(
|
||||||
|
new ActivityId(activity.getId()),
|
||||||
|
new AccountId(activity.getOwnerAccountId()),
|
||||||
|
new AccountId(activity.getSourceAccountId()),
|
||||||
|
new AccountId(activity.getTargetAccountId()),
|
||||||
|
activity.getTimestamp(),
|
||||||
|
Money.of(activity.getAmount())));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ActivityWindow(mappedActivities);
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityJpaEntity mapToJpaEntity(Activity activity) {
|
||||||
|
return new ActivityJpaEntity(
|
||||||
|
activity.getId() == null ? null : activity.getId().getValue(),
|
||||||
|
activity.getTimestamp(),
|
||||||
|
activity.getOwnerAccountId().getValue(),
|
||||||
|
activity.getSourceAccountId().getValue(),
|
||||||
|
activity.getTargetAccountId().getValue(),
|
||||||
|
activity.getMoney().getAmount().longValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||||
|
|
||||||
|
import javax.persistence.EntityNotFoundException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.application.port.out.LoadAccountPort;
|
||||||
|
import io.reflectoring.buckpal.account.application.port.out.UpdateAccountStatePort;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Activity;
|
||||||
|
import io.reflectoring.buckpal.common.PersistenceAdapter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@PersistenceAdapter
|
||||||
|
class AccountPersistenceAdapter implements
|
||||||
|
LoadAccountPort,
|
||||||
|
UpdateAccountStatePort {
|
||||||
|
|
||||||
|
private final SpringDataAccountRepository accountRepository;
|
||||||
|
private final ActivityRepository activityRepository;
|
||||||
|
private final AccountMapper accountMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Account loadAccount(
|
||||||
|
AccountId accountId,
|
||||||
|
LocalDateTime baselineDate) {
|
||||||
|
|
||||||
|
AccountJpaEntity account =
|
||||||
|
accountRepository.findById(accountId.getValue())
|
||||||
|
.orElseThrow(EntityNotFoundException::new);
|
||||||
|
|
||||||
|
List<ActivityJpaEntity> activities =
|
||||||
|
activityRepository.findByOwnerSince(
|
||||||
|
accountId.getValue(),
|
||||||
|
baselineDate);
|
||||||
|
|
||||||
|
Long withdrawalBalance = orZero(activityRepository
|
||||||
|
.getWithdrawalBalanceUntil(
|
||||||
|
accountId.getValue(),
|
||||||
|
baselineDate));
|
||||||
|
|
||||||
|
Long depositBalance = orZero(activityRepository
|
||||||
|
.getDepositBalanceUntil(
|
||||||
|
accountId.getValue(),
|
||||||
|
baselineDate));
|
||||||
|
|
||||||
|
return accountMapper.mapToDomainEntity(
|
||||||
|
account,
|
||||||
|
activities,
|
||||||
|
withdrawalBalance,
|
||||||
|
depositBalance);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long orZero(Long value){
|
||||||
|
return value == null ? 0L : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateActivities(Account account) {
|
||||||
|
for (Activity activity : account.getActivityWindow().getActivities()) {
|
||||||
|
if (activity.getId() == null) {
|
||||||
|
activityRepository.save(accountMapper.mapToJpaEntity(activity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||||
|
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.GeneratedValue;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "activity")
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
class ActivityJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private LocalDateTime timestamp;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private Long ownerAccountId;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private Long sourceAccountId;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private Long targetAccountId;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private Long amount;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
interface ActivityRepository extends JpaRepository<ActivityJpaEntity, Long> {
|
||||||
|
|
||||||
|
@Query("select a from ActivityJpaEntity a " +
|
||||||
|
"where a.ownerAccountId = :ownerAccountId " +
|
||||||
|
"and a.timestamp >= :since")
|
||||||
|
List<ActivityJpaEntity> findByOwnerSince(
|
||||||
|
@Param("ownerAccountId") Long ownerAccountId,
|
||||||
|
@Param("since") LocalDateTime since);
|
||||||
|
|
||||||
|
@Query("select sum(a.amount) from ActivityJpaEntity a " +
|
||||||
|
"where a.targetAccountId = :accountId " +
|
||||||
|
"and a.ownerAccountId = :accountId " +
|
||||||
|
"and a.timestamp < :until")
|
||||||
|
Long getDepositBalanceUntil(
|
||||||
|
@Param("accountId") Long accountId,
|
||||||
|
@Param("until") LocalDateTime until);
|
||||||
|
|
||||||
|
@Query("select sum(a.amount) from ActivityJpaEntity a " +
|
||||||
|
"where a.sourceAccountId = :accountId " +
|
||||||
|
"and a.ownerAccountId = :accountId " +
|
||||||
|
"and a.timestamp < :until")
|
||||||
|
Long getWithdrawalBalanceUntil(
|
||||||
|
@Param("accountId") Long accountId,
|
||||||
|
@Param("until") LocalDateTime until);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
interface SpringDataAccountRepository extends JpaRepository<AccountJpaEntity, Long> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package io.reflectoring.buckpal.account.application.port.in;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
|
||||||
|
public interface GetAccountBalanceQuery {
|
||||||
|
|
||||||
|
Money getAccountBalance(AccountId accountId);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package io.reflectoring.buckpal.account.application.port.in;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
import io.reflectoring.buckpal.common.SelfValidating;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
import javax.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
@Value
|
||||||
|
@EqualsAndHashCode(callSuper = false)
|
||||||
|
public
|
||||||
|
class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private final AccountId sourceAccountId;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private final AccountId targetAccountId;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private final Money money;
|
||||||
|
|
||||||
|
public SendMoneyCommand(
|
||||||
|
AccountId sourceAccountId,
|
||||||
|
AccountId targetAccountId,
|
||||||
|
Money money) {
|
||||||
|
this.sourceAccountId = sourceAccountId;
|
||||||
|
this.targetAccountId = targetAccountId;
|
||||||
|
this.money = money;
|
||||||
|
this.validateSelf();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package io.reflectoring.buckpal.account.application.port.in;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
import io.reflectoring.buckpal.common.SelfValidating;
|
||||||
|
|
||||||
|
public interface SendMoneyUseCase {
|
||||||
|
|
||||||
|
boolean sendMoney(SendMoneyCommand command);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package io.reflectoring.buckpal.account.application.port.out;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account;
|
||||||
|
|
||||||
|
public interface AccountLock {
|
||||||
|
|
||||||
|
void lockAccount(Account.AccountId accountId);
|
||||||
|
|
||||||
|
void releaseAccount(Account.AccountId accountId);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package io.reflectoring.buckpal.account.application.port.out;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
|
||||||
|
public interface LoadAccountPort {
|
||||||
|
|
||||||
|
Account loadAccount(AccountId accountId, LocalDateTime baselineDate);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package io.reflectoring.buckpal.account.application.port.out;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account;
|
||||||
|
|
||||||
|
public interface UpdateAccountStatePort {
|
||||||
|
|
||||||
|
void updateActivities(Account account);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package io.reflectoring.buckpal.account.application.service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.application.port.in.GetAccountBalanceQuery;
|
||||||
|
import io.reflectoring.buckpal.account.application.port.out.LoadAccountPort;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
class GetAccountBalanceService implements GetAccountBalanceQuery {
|
||||||
|
|
||||||
|
private final LoadAccountPort loadAccountPort;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Money getAccountBalance(AccountId accountId) {
|
||||||
|
return loadAccountPort.loadAccount(accountId, LocalDateTime.now())
|
||||||
|
.calculateBalance();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package io.reflectoring.buckpal.account.application.service;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration properties for money transfer use cases.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class MoneyTransferProperties {
|
||||||
|
|
||||||
|
private Money maximumTransferThreshold = Money.of(1_000_000L);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package io.reflectoring.buckpal.account.application.service;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.application.port.out.AccountLock;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class NoOpAccountLock implements AccountLock {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void lockAccount(AccountId accountId) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void releaseAccount(AccountId accountId) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package io.reflectoring.buckpal.account.application.service;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.application.port.in.SendMoneyCommand;
|
||||||
|
import io.reflectoring.buckpal.account.application.port.in.SendMoneyUseCase;
|
||||||
|
import io.reflectoring.buckpal.account.application.port.out.AccountLock;
|
||||||
|
import io.reflectoring.buckpal.account.application.port.out.LoadAccountPort;
|
||||||
|
import io.reflectoring.buckpal.account.application.port.out.UpdateAccountStatePort;
|
||||||
|
import io.reflectoring.buckpal.common.UseCase;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
import javax.transaction.Transactional;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@UseCase
|
||||||
|
@Transactional
|
||||||
|
public class SendMoneyService implements SendMoneyUseCase {
|
||||||
|
|
||||||
|
private final LoadAccountPort loadAccountPort;
|
||||||
|
private final AccountLock accountLock;
|
||||||
|
private final UpdateAccountStatePort updateAccountStatePort;
|
||||||
|
private final MoneyTransferProperties moneyTransferProperties;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean sendMoney(SendMoneyCommand command) {
|
||||||
|
|
||||||
|
checkThreshold(command);
|
||||||
|
|
||||||
|
LocalDateTime baselineDate = LocalDateTime.now().minusDays(10);
|
||||||
|
|
||||||
|
Account sourceAccount = loadAccountPort.loadAccount(
|
||||||
|
command.getSourceAccountId(),
|
||||||
|
baselineDate);
|
||||||
|
|
||||||
|
Account targetAccount = loadAccountPort.loadAccount(
|
||||||
|
command.getTargetAccountId(),
|
||||||
|
baselineDate);
|
||||||
|
|
||||||
|
AccountId sourceAccountId = sourceAccount.getId()
|
||||||
|
.orElseThrow(() -> new IllegalStateException("expected source account ID not to be empty"));
|
||||||
|
AccountId targetAccountId = targetAccount.getId()
|
||||||
|
.orElseThrow(() -> new IllegalStateException("expected target account ID not to be empty"));
|
||||||
|
|
||||||
|
accountLock.lockAccount(sourceAccountId);
|
||||||
|
if (!sourceAccount.withdraw(command.getMoney(), targetAccountId)) {
|
||||||
|
accountLock.releaseAccount(sourceAccountId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
accountLock.lockAccount(targetAccountId);
|
||||||
|
if (!targetAccount.deposit(command.getMoney(), sourceAccountId)) {
|
||||||
|
accountLock.releaseAccount(sourceAccountId);
|
||||||
|
accountLock.releaseAccount(targetAccountId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAccountStatePort.updateActivities(sourceAccount);
|
||||||
|
updateAccountStatePort.updateActivities(targetAccount);
|
||||||
|
|
||||||
|
accountLock.releaseAccount(sourceAccountId);
|
||||||
|
accountLock.releaseAccount(targetAccountId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkThreshold(SendMoneyCommand command) {
|
||||||
|
if(command.getMoney().isGreaterThan(moneyTransferProperties.getMaximumTransferThreshold())){
|
||||||
|
throw new ThresholdExceededException(moneyTransferProperties.getMaximumTransferThreshold(), command.getMoney());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package io.reflectoring.buckpal.account.application.service;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
|
||||||
|
public class ThresholdExceededException extends RuntimeException {
|
||||||
|
|
||||||
|
public ThresholdExceededException(Money threshold, Money actual) {
|
||||||
|
super(String.format("Maximum threshold for transferring money exceeded: tried to transfer %s but threshold is %s!", actual, threshold));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package io.reflectoring.buckpal.account.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account that holds a certain amount of money. An {@link Account} object only
|
||||||
|
* contains a window of the latest account activities. The total balance of the account is
|
||||||
|
* the sum of a baseline balance that was valid before the first activity in the
|
||||||
|
* window and the sum of the activity values.
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||||
|
public class Account {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unique ID of the account.
|
||||||
|
*/
|
||||||
|
@Getter private final AccountId id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The baseline balance of the account. This was the balance of the account before the first
|
||||||
|
* activity in the activityWindow.
|
||||||
|
*/
|
||||||
|
@Getter private final Money baselineBalance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window of latest activities on this account.
|
||||||
|
*/
|
||||||
|
@Getter private final ActivityWindow activityWindow;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an {@link Account} entity without an ID. Use to create a new entity that is not yet
|
||||||
|
* persisted.
|
||||||
|
*/
|
||||||
|
public static Account withoutId(
|
||||||
|
Money baselineBalance,
|
||||||
|
ActivityWindow activityWindow) {
|
||||||
|
return new Account(null, baselineBalance, activityWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an {@link Account} entity with an ID. Use to reconstitute a persisted entity.
|
||||||
|
*/
|
||||||
|
public static Account withId(
|
||||||
|
AccountId accountId,
|
||||||
|
Money baselineBalance,
|
||||||
|
ActivityWindow activityWindow) {
|
||||||
|
return new Account(accountId, baselineBalance, activityWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AccountId> getId(){
|
||||||
|
return Optional.ofNullable(this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the total balance of the account by adding the activity values to the baseline balance.
|
||||||
|
*/
|
||||||
|
public Money calculateBalance() {
|
||||||
|
return Money.add(
|
||||||
|
this.baselineBalance,
|
||||||
|
this.activityWindow.calculateBalance(this.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to withdraw a certain amount of money from this account.
|
||||||
|
* If successful, creates a new activity with a negative value.
|
||||||
|
* @return true if the withdrawal was successful, false if not.
|
||||||
|
*/
|
||||||
|
public boolean withdraw(Money money, AccountId targetAccountId) {
|
||||||
|
|
||||||
|
if (!mayWithdraw(money)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Activity withdrawal = new Activity(
|
||||||
|
this.id,
|
||||||
|
this.id,
|
||||||
|
targetAccountId,
|
||||||
|
LocalDateTime.now(),
|
||||||
|
money);
|
||||||
|
this.activityWindow.addActivity(withdrawal);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean mayWithdraw(Money money) {
|
||||||
|
return Money.add(
|
||||||
|
this.calculateBalance(),
|
||||||
|
money.negate())
|
||||||
|
.isPositiveOrZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to deposit a certain amount of money to this account.
|
||||||
|
* If sucessful, creates a new activity with a positive value.
|
||||||
|
* @return true if the deposit was successful, false if not.
|
||||||
|
*/
|
||||||
|
public boolean deposit(Money money, AccountId sourceAccountId) {
|
||||||
|
Activity deposit = new Activity(
|
||||||
|
this.id,
|
||||||
|
sourceAccountId,
|
||||||
|
this.id,
|
||||||
|
LocalDateTime.now(),
|
||||||
|
money);
|
||||||
|
this.activityWindow.addActivity(deposit);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Value
|
||||||
|
public static class AccountId {
|
||||||
|
private Long value;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package io.reflectoring.buckpal.account.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A money transfer activity between {@link Account}s.
|
||||||
|
*/
|
||||||
|
@Value
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class Activity {
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
private ActivityId id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account that owns this activity.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NonNull
|
||||||
|
private final Account.AccountId ownerAccountId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The debited account.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NonNull
|
||||||
|
private final Account.AccountId sourceAccountId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The credited account.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NonNull
|
||||||
|
private final Account.AccountId targetAccountId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timestamp of the activity.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NonNull
|
||||||
|
private final LocalDateTime timestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The money that was transferred between the accounts.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@NonNull
|
||||||
|
private final Money money;
|
||||||
|
|
||||||
|
public Activity(
|
||||||
|
@NonNull Account.AccountId ownerAccountId,
|
||||||
|
@NonNull Account.AccountId sourceAccountId,
|
||||||
|
@NonNull Account.AccountId targetAccountId,
|
||||||
|
@NonNull LocalDateTime timestamp,
|
||||||
|
@NonNull Money money) {
|
||||||
|
this.id = null;
|
||||||
|
this.ownerAccountId = ownerAccountId;
|
||||||
|
this.sourceAccountId = sourceAccountId;
|
||||||
|
this.targetAccountId = targetAccountId;
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.money = money;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Value
|
||||||
|
public static class ActivityId {
|
||||||
|
private final Long value;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package io.reflectoring.buckpal.account.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import lombok.NonNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A window of account activities.
|
||||||
|
*/
|
||||||
|
public class ActivityWindow {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of account activities within this window.
|
||||||
|
*/
|
||||||
|
private List<Activity> activities;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timestamp of the first activity within this window.
|
||||||
|
*/
|
||||||
|
public LocalDateTime getStartTimestamp() {
|
||||||
|
return activities.stream()
|
||||||
|
.min(Comparator.comparing(Activity::getTimestamp))
|
||||||
|
.orElseThrow(IllegalStateException::new)
|
||||||
|
.getTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timestamp of the last activity within this window.
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
public LocalDateTime getEndTimestamp() {
|
||||||
|
return activities.stream()
|
||||||
|
.max(Comparator.comparing(Activity::getTimestamp))
|
||||||
|
.orElseThrow(IllegalStateException::new)
|
||||||
|
.getTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the balance by summing up the values of all activities within this window.
|
||||||
|
*/
|
||||||
|
public Money calculateBalance(AccountId accountId) {
|
||||||
|
Money depositBalance = activities.stream()
|
||||||
|
.filter(a -> a.getTargetAccountId().equals(accountId))
|
||||||
|
.map(Activity::getMoney)
|
||||||
|
.reduce(Money.ZERO, Money::add);
|
||||||
|
|
||||||
|
Money withdrawalBalance = activities.stream()
|
||||||
|
.filter(a -> a.getSourceAccountId().equals(accountId))
|
||||||
|
.map(Activity::getMoney)
|
||||||
|
.reduce(Money.ZERO, Money::add);
|
||||||
|
|
||||||
|
return Money.add(depositBalance, withdrawalBalance.negate());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivityWindow(@NonNull List<Activity> activities) {
|
||||||
|
this.activities = activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivityWindow(@NonNull Activity... activities) {
|
||||||
|
this.activities = new ArrayList<>(Arrays.asList(activities));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Activity> getActivities() {
|
||||||
|
return Collections.unmodifiableList(this.activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addActivity(Activity activity) {
|
||||||
|
this.activities.add(activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package io.reflectoring.buckpal.account.domain;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
@Value
|
||||||
|
public class Money {
|
||||||
|
|
||||||
|
public static Money ZERO = Money.of(0L);
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private final BigInteger amount;
|
||||||
|
|
||||||
|
public boolean isPositiveOrZero(){
|
||||||
|
return this.amount.compareTo(BigInteger.ZERO) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isNegative(){
|
||||||
|
return this.amount.compareTo(BigInteger.ZERO) < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPositive(){
|
||||||
|
return this.amount.compareTo(BigInteger.ZERO) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isGreaterThanOrEqualTo(Money money){
|
||||||
|
return this.amount.compareTo(money.amount) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isGreaterThan(Money money){
|
||||||
|
return this.amount.compareTo(money.amount) >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Money of(long value) {
|
||||||
|
return new Money(BigInteger.valueOf(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Money add(Money a, Money b) {
|
||||||
|
return new Money(a.amount.add(b.amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Money minus(Money money){
|
||||||
|
return new Money(this.amount.subtract(money.amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Money plus(Money money){
|
||||||
|
return new Money(this.amount.add(money.amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Money subtract(Money a, Money b) {
|
||||||
|
return new Money(a.amount.subtract(b.amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Money negate(){
|
||||||
|
return new Money(this.amount.negate());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package io.reflectoring.buckpal.common;
|
||||||
|
|
||||||
|
import java.lang.annotation.Documented;
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
import org.springframework.core.annotation.AliasFor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Target({ElementType.TYPE})
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Documented
|
||||||
|
@Component
|
||||||
|
public @interface PersistenceAdapter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value may indicate a suggestion for a logical component name,
|
||||||
|
* to be turned into a Spring bean in case of an autodetected component.
|
||||||
|
* @return the suggested component name, if any (or empty String otherwise)
|
||||||
|
*/
|
||||||
|
@AliasFor(annotation = Component.class)
|
||||||
|
String value() default "";
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package io.reflectoring.buckpal.common;
|
||||||
|
|
||||||
|
import javax.validation.ConstraintViolation;
|
||||||
|
import javax.validation.ConstraintViolationException;
|
||||||
|
import javax.validation.Validation;
|
||||||
|
import javax.validation.Validator;
|
||||||
|
import javax.validation.ValidatorFactory;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public abstract class SelfValidating<T> {
|
||||||
|
|
||||||
|
private Validator validator;
|
||||||
|
|
||||||
|
public SelfValidating() {
|
||||||
|
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
|
||||||
|
validator = factory.getValidator();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates all Bean Validations on the attributes of this
|
||||||
|
* instance.
|
||||||
|
*/
|
||||||
|
protected void validateSelf() {
|
||||||
|
Set<ConstraintViolation<T>> violations = validator.validate((T) this);
|
||||||
|
if (!violations.isEmpty()) {
|
||||||
|
throw new ConstraintViolationException(violations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/main/java/io/reflectoring/buckpal/common/UseCase.java
Normal file
26
src/main/java/io/reflectoring/buckpal/common/UseCase.java
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package io.reflectoring.buckpal.common;
|
||||||
|
|
||||||
|
import java.lang.annotation.Documented;
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
import org.springframework.core.annotation.AliasFor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Target({ElementType.TYPE})
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Documented
|
||||||
|
@Component
|
||||||
|
public @interface UseCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value may indicate a suggestion for a logical component name,
|
||||||
|
* to be turned into a Spring bean in case of an autodetected component.
|
||||||
|
* @return the suggested component name, if any (or empty String otherwise)
|
||||||
|
*/
|
||||||
|
@AliasFor(annotation = Component.class)
|
||||||
|
String value() default "";
|
||||||
|
|
||||||
|
}
|
||||||
26
src/main/java/io/reflectoring/buckpal/common/WebAdapter.java
Normal file
26
src/main/java/io/reflectoring/buckpal/common/WebAdapter.java
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package io.reflectoring.buckpal.common;
|
||||||
|
|
||||||
|
import org.springframework.core.annotation.AliasFor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.lang.annotation.Documented;
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
@Target({ElementType.TYPE})
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Documented
|
||||||
|
@Component
|
||||||
|
public @interface WebAdapter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value may indicate a suggestion for a logical component name,
|
||||||
|
* to be turned into a Spring bean in case of an autodetected component.
|
||||||
|
* @return the suggested component name, if any (or empty String otherwise)
|
||||||
|
*/
|
||||||
|
@AliasFor(annotation = Component.class)
|
||||||
|
String value() default "";
|
||||||
|
|
||||||
|
}
|
||||||
2
src/main/resources/application.yml
Normal file
2
src/main/resources/application.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
buckpal:
|
||||||
|
transferThreshold: 10000
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package io.reflectoring.buckpal;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||||
|
|
||||||
|
@ExtendWith(SpringExtension.class)
|
||||||
|
@SpringBootTest
|
||||||
|
class BuckPalApplicationTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contextLoads() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package io.reflectoring.buckpal;
|
||||||
|
|
||||||
|
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||||
|
import io.reflectoring.buckpal.archunit.HexagonalArchitecture;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
|
||||||
|
|
||||||
|
class DependencyRuleTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateRegistrationContextArchitecture() {
|
||||||
|
HexagonalArchitecture.boundedContext("io.reflectoring.buckpal.account")
|
||||||
|
|
||||||
|
.withDomainLayer("domain")
|
||||||
|
|
||||||
|
.withAdaptersLayer("adapter")
|
||||||
|
.incoming("in.web")
|
||||||
|
.outgoing("out.persistence")
|
||||||
|
.and()
|
||||||
|
|
||||||
|
.withApplicationLayer("application")
|
||||||
|
.services("service")
|
||||||
|
.incomingPorts("port.in")
|
||||||
|
.outgoingPorts("port.out")
|
||||||
|
.and()
|
||||||
|
|
||||||
|
.withConfiguration("configuration")
|
||||||
|
.check(new ClassFileImporter()
|
||||||
|
.importPackages("io.reflectoring.buckpal.."));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testPackageDependencies() {
|
||||||
|
noClasses()
|
||||||
|
.that()
|
||||||
|
.resideInAPackage("io.reflectoring.reviewapp.domain..")
|
||||||
|
.should()
|
||||||
|
.dependOnClassesThat()
|
||||||
|
.resideInAnyPackage("io.reflectoring.reviewapp.application..")
|
||||||
|
.check(new ClassFileImporter()
|
||||||
|
.importPackages("io.reflectoring.reviewapp.."));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
104
src/test/java/io/reflectoring/buckpal/SendMoneySystemTest.java
Normal file
104
src/test/java/io/reflectoring/buckpal/SendMoneySystemTest.java
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package io.reflectoring.buckpal;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.application.port.out.LoadAccountPort;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
|
||||||
|
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.test.context.jdbc.Sql;
|
||||||
|
import static org.assertj.core.api.BDDAssertions.*;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
|
||||||
|
class SendMoneySystemTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TestRestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private LoadAccountPort loadAccountPort;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Sql("SendMoneySystemTest.sql")
|
||||||
|
void sendMoney() {
|
||||||
|
|
||||||
|
Money initialSourceBalance = sourceAccount().calculateBalance();
|
||||||
|
Money initialTargetBalance = targetAccount().calculateBalance();
|
||||||
|
|
||||||
|
ResponseEntity response = whenSendMoney(
|
||||||
|
sourceAccountId(),
|
||||||
|
targetAccountId(),
|
||||||
|
transferredAmount());
|
||||||
|
|
||||||
|
then(response.getStatusCode())
|
||||||
|
.isEqualTo(HttpStatus.OK);
|
||||||
|
|
||||||
|
then(sourceAccount().calculateBalance())
|
||||||
|
.isEqualTo(initialSourceBalance.minus(transferredAmount()));
|
||||||
|
|
||||||
|
then(targetAccount().calculateBalance())
|
||||||
|
.isEqualTo(initialTargetBalance.plus(transferredAmount()));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private Account sourceAccount() {
|
||||||
|
return loadAccount(sourceAccountId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Account targetAccount() {
|
||||||
|
return loadAccount(targetAccountId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Account loadAccount(AccountId accountId) {
|
||||||
|
return loadAccountPort.loadAccount(
|
||||||
|
accountId,
|
||||||
|
LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private ResponseEntity whenSendMoney(
|
||||||
|
AccountId sourceAccountId,
|
||||||
|
AccountId targetAccountId,
|
||||||
|
Money amount) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.add("Content-Type", "application/json");
|
||||||
|
HttpEntity<Void> request = new HttpEntity<>(null, headers);
|
||||||
|
|
||||||
|
return restTemplate.exchange(
|
||||||
|
"/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
|
||||||
|
HttpMethod.POST,
|
||||||
|
request,
|
||||||
|
Object.class,
|
||||||
|
sourceAccountId.getValue(),
|
||||||
|
targetAccountId.getValue(),
|
||||||
|
amount.getAmount());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Money transferredAmount() {
|
||||||
|
return Money.of(500L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Money balanceOf(AccountId accountId) {
|
||||||
|
Account account = loadAccountPort.loadAccount(accountId, LocalDateTime.now());
|
||||||
|
return account.calculateBalance();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AccountId sourceAccountId() {
|
||||||
|
return new AccountId(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AccountId targetAccountId() {
|
||||||
|
return new AccountId(2L);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package io.reflectoring.buckpal.account.adapter.in.web;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.application.port.in.SendMoneyUseCase;
|
||||||
|
import io.reflectoring.buckpal.account.application.port.in.SendMoneyCommand;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import static org.mockito.BDDMockito.*;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@WebMvcTest(controllers = SendMoneyController.class)
|
||||||
|
class SendMoneyControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private SendMoneyUseCase sendMoneyUseCase;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSendMoney() throws Exception {
|
||||||
|
|
||||||
|
mockMvc.perform(post("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
|
||||||
|
41L, 42L, 500)
|
||||||
|
.header("Content-Type", "application/json"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
then(sendMoneyUseCase).should()
|
||||||
|
.sendMoney(eq(new SendMoneyCommand(
|
||||||
|
new AccountId(41L),
|
||||||
|
new AccountId(42L),
|
||||||
|
Money.of(500L))));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.ActivityWindow;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.test.context.jdbc.Sql;
|
||||||
|
import static io.reflectoring.buckpal.common.AccountTestData.*;
|
||||||
|
import static io.reflectoring.buckpal.common.ActivityTestData.*;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@Import({AccountPersistenceAdapter.class, AccountMapper.class})
|
||||||
|
class AccountPersistenceAdapterTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AccountPersistenceAdapter adapterUnderTest;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ActivityRepository activityRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Sql("AccountPersistenceAdapterTest.sql")
|
||||||
|
void loadsAccount() {
|
||||||
|
Account account = adapterUnderTest.loadAccount(new AccountId(1L), LocalDateTime.of(2018, 8, 10, 0, 0));
|
||||||
|
|
||||||
|
assertThat(account.getActivityWindow().getActivities()).hasSize(2);
|
||||||
|
assertThat(account.calculateBalance()).isEqualTo(Money.of(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatesActivities() {
|
||||||
|
Account account = defaultAccount()
|
||||||
|
.withBaselineBalance(Money.of(555L))
|
||||||
|
.withActivityWindow(new ActivityWindow(
|
||||||
|
defaultActivity()
|
||||||
|
.withId(null)
|
||||||
|
.withMoney(Money.of(1L)).build()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
adapterUnderTest.updateActivities(account);
|
||||||
|
|
||||||
|
assertThat(activityRepository.count()).isEqualTo(1);
|
||||||
|
|
||||||
|
ActivityJpaEntity savedActivity = activityRepository.findAll().get(0);
|
||||||
|
assertThat(savedActivity.getAmount()).isEqualTo(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package io.reflectoring.buckpal.account.domain;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static io.reflectoring.buckpal.common.AccountTestData.*;
|
||||||
|
import static io.reflectoring.buckpal.common.ActivityTestData.*;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
class AccountTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calculatesBalance() {
|
||||||
|
AccountId accountId = new AccountId(1L);
|
||||||
|
Account account = defaultAccount()
|
||||||
|
.withAccountId(accountId)
|
||||||
|
.withBaselineBalance(Money.of(555L))
|
||||||
|
.withActivityWindow(new ActivityWindow(
|
||||||
|
defaultActivity()
|
||||||
|
.withTargetAccount(accountId)
|
||||||
|
.withMoney(Money.of(999L)).build(),
|
||||||
|
defaultActivity()
|
||||||
|
.withTargetAccount(accountId)
|
||||||
|
.withMoney(Money.of(1L)).build()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Money balance = account.calculateBalance();
|
||||||
|
|
||||||
|
assertThat(balance).isEqualTo(Money.of(1555L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withdrawalSucceeds() {
|
||||||
|
AccountId accountId = new AccountId(1L);
|
||||||
|
Account account = defaultAccount()
|
||||||
|
.withAccountId(accountId)
|
||||||
|
.withBaselineBalance(Money.of(555L))
|
||||||
|
.withActivityWindow(new ActivityWindow(
|
||||||
|
defaultActivity()
|
||||||
|
.withTargetAccount(accountId)
|
||||||
|
.withMoney(Money.of(999L)).build(),
|
||||||
|
defaultActivity()
|
||||||
|
.withTargetAccount(accountId)
|
||||||
|
.withMoney(Money.of(1L)).build()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
boolean success = account.withdraw(Money.of(555L), new AccountId(99L));
|
||||||
|
|
||||||
|
assertThat(success).isTrue();
|
||||||
|
assertThat(account.getActivityWindow().getActivities()).hasSize(3);
|
||||||
|
assertThat(account.calculateBalance()).isEqualTo(Money.of(1000L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withdrawalFailure() {
|
||||||
|
AccountId accountId = new AccountId(1L);
|
||||||
|
Account account = defaultAccount()
|
||||||
|
.withAccountId(accountId)
|
||||||
|
.withBaselineBalance(Money.of(555L))
|
||||||
|
.withActivityWindow(new ActivityWindow(
|
||||||
|
defaultActivity()
|
||||||
|
.withTargetAccount(accountId)
|
||||||
|
.withMoney(Money.of(999L)).build(),
|
||||||
|
defaultActivity()
|
||||||
|
.withTargetAccount(accountId)
|
||||||
|
.withMoney(Money.of(1L)).build()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
boolean success = account.withdraw(Money.of(1556L), new AccountId(99L));
|
||||||
|
|
||||||
|
assertThat(success).isFalse();
|
||||||
|
assertThat(account.getActivityWindow().getActivities()).hasSize(2);
|
||||||
|
assertThat(account.calculateBalance()).isEqualTo(Money.of(1555L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void depositSuccess() {
|
||||||
|
AccountId accountId = new AccountId(1L);
|
||||||
|
Account account = defaultAccount()
|
||||||
|
.withAccountId(accountId)
|
||||||
|
.withBaselineBalance(Money.of(555L))
|
||||||
|
.withActivityWindow(new ActivityWindow(
|
||||||
|
defaultActivity()
|
||||||
|
.withTargetAccount(accountId)
|
||||||
|
.withMoney(Money.of(999L)).build(),
|
||||||
|
defaultActivity()
|
||||||
|
.withTargetAccount(accountId)
|
||||||
|
.withMoney(Money.of(1L)).build()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
boolean success = account.deposit(Money.of(445L), new AccountId(99L));
|
||||||
|
|
||||||
|
assertThat(success).isTrue();
|
||||||
|
assertThat(account.getActivityWindow().getActivities()).hasSize(3);
|
||||||
|
assertThat(account.calculateBalance()).isEqualTo(Money.of(2000L));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package io.reflectoring.buckpal.account.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import org.assertj.core.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static io.reflectoring.buckpal.common.ActivityTestData.*;
|
||||||
|
|
||||||
|
class ActivityWindowTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calculatesStartTimestamp() {
|
||||||
|
ActivityWindow window = new ActivityWindow(
|
||||||
|
defaultActivity().withTimestamp(startDate()).build(),
|
||||||
|
defaultActivity().withTimestamp(inBetweenDate()).build(),
|
||||||
|
defaultActivity().withTimestamp(endDate()).build());
|
||||||
|
|
||||||
|
Assertions.assertThat(window.getStartTimestamp()).isEqualTo(startDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calculatesEndTimestamp() {
|
||||||
|
ActivityWindow window = new ActivityWindow(
|
||||||
|
defaultActivity().withTimestamp(startDate()).build(),
|
||||||
|
defaultActivity().withTimestamp(inBetweenDate()).build(),
|
||||||
|
defaultActivity().withTimestamp(endDate()).build());
|
||||||
|
|
||||||
|
Assertions.assertThat(window.getEndTimestamp()).isEqualTo(endDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void calculatesBalance() {
|
||||||
|
|
||||||
|
AccountId account1 = new AccountId(1L);
|
||||||
|
AccountId account2 = new AccountId(2L);
|
||||||
|
|
||||||
|
ActivityWindow window = new ActivityWindow(
|
||||||
|
defaultActivity()
|
||||||
|
.withSourceAccount(account1)
|
||||||
|
.withTargetAccount(account2)
|
||||||
|
.withMoney(Money.of(999)).build(),
|
||||||
|
defaultActivity()
|
||||||
|
.withSourceAccount(account1)
|
||||||
|
.withTargetAccount(account2)
|
||||||
|
.withMoney(Money.of(1)).build(),
|
||||||
|
defaultActivity()
|
||||||
|
.withSourceAccount(account2)
|
||||||
|
.withTargetAccount(account1)
|
||||||
|
.withMoney(Money.of(500)).build());
|
||||||
|
|
||||||
|
Assertions.assertThat(window.calculateBalance(account1)).isEqualTo(Money.of(-500));
|
||||||
|
Assertions.assertThat(window.calculateBalance(account2)).isEqualTo(Money.of(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDateTime startDate() {
|
||||||
|
return LocalDateTime.of(2019, 8, 3, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDateTime inBetweenDate() {
|
||||||
|
return LocalDateTime.of(2019, 8, 4, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDateTime endDate() {
|
||||||
|
return LocalDateTime.of(2019, 8, 5, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package io.reflectoring.buckpal.account.application.service;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.application.port.in.SendMoneyCommand;
|
||||||
|
import io.reflectoring.buckpal.account.application.port.out.AccountLock;
|
||||||
|
import io.reflectoring.buckpal.account.application.port.out.LoadAccountPort;
|
||||||
|
import io.reflectoring.buckpal.account.application.port.out.UpdateAccountStatePort;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.mockito.BDDMockito.*;
|
||||||
|
|
||||||
|
class SendMoneyServiceTest {
|
||||||
|
|
||||||
|
private final LoadAccountPort loadAccountPort =
|
||||||
|
Mockito.mock(LoadAccountPort.class);
|
||||||
|
|
||||||
|
private final AccountLock accountLock =
|
||||||
|
Mockito.mock(AccountLock.class);
|
||||||
|
|
||||||
|
private final UpdateAccountStatePort updateAccountStatePort =
|
||||||
|
Mockito.mock(UpdateAccountStatePort.class);
|
||||||
|
|
||||||
|
private final SendMoneyService sendMoneyService =
|
||||||
|
new SendMoneyService(loadAccountPort, accountLock, updateAccountStatePort, moneyTransferProperties());
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void givenWithdrawalFails_thenOnlySourceAccountIsLockedAndReleased() {
|
||||||
|
|
||||||
|
AccountId sourceAccountId = new AccountId(41L);
|
||||||
|
Account sourceAccount = givenAnAccountWithId(sourceAccountId);
|
||||||
|
|
||||||
|
AccountId targetAccountId = new AccountId(42L);
|
||||||
|
Account targetAccount = givenAnAccountWithId(targetAccountId);
|
||||||
|
|
||||||
|
givenWithdrawalWillFail(sourceAccount);
|
||||||
|
givenDepositWillSucceed(targetAccount);
|
||||||
|
|
||||||
|
SendMoneyCommand command = new SendMoneyCommand(
|
||||||
|
sourceAccountId,
|
||||||
|
targetAccountId,
|
||||||
|
Money.of(300L));
|
||||||
|
|
||||||
|
boolean success = sendMoneyService.sendMoney(command);
|
||||||
|
|
||||||
|
assertThat(success).isFalse();
|
||||||
|
|
||||||
|
then(accountLock).should().lockAccount(eq(sourceAccountId));
|
||||||
|
then(accountLock).should().releaseAccount(eq(sourceAccountId));
|
||||||
|
then(accountLock).should(times(0)).lockAccount(eq(targetAccountId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transactionSucceeds() {
|
||||||
|
|
||||||
|
Account sourceAccount = givenSourceAccount();
|
||||||
|
Account targetAccount = givenTargetAccount();
|
||||||
|
|
||||||
|
givenWithdrawalWillSucceed(sourceAccount);
|
||||||
|
givenDepositWillSucceed(targetAccount);
|
||||||
|
|
||||||
|
Money money = Money.of(500L);
|
||||||
|
|
||||||
|
SendMoneyCommand command = new SendMoneyCommand(
|
||||||
|
sourceAccount.getId().get(),
|
||||||
|
targetAccount.getId().get(),
|
||||||
|
money);
|
||||||
|
|
||||||
|
boolean success = sendMoneyService.sendMoney(command);
|
||||||
|
|
||||||
|
assertThat(success).isTrue();
|
||||||
|
|
||||||
|
AccountId sourceAccountId = sourceAccount.getId().get();
|
||||||
|
AccountId targetAccountId = targetAccount.getId().get();
|
||||||
|
|
||||||
|
then(accountLock).should().lockAccount(eq(sourceAccountId));
|
||||||
|
then(sourceAccount).should().withdraw(eq(money), eq(targetAccountId));
|
||||||
|
then(accountLock).should().releaseAccount(eq(sourceAccountId));
|
||||||
|
|
||||||
|
then(accountLock).should().lockAccount(eq(targetAccountId));
|
||||||
|
then(targetAccount).should().deposit(eq(money), eq(sourceAccountId));
|
||||||
|
then(accountLock).should().releaseAccount(eq(targetAccountId));
|
||||||
|
|
||||||
|
thenAccountsHaveBeenUpdated(sourceAccountId, targetAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void thenAccountsHaveBeenUpdated(AccountId... accountIds){
|
||||||
|
ArgumentCaptor<Account> accountCaptor = ArgumentCaptor.forClass(Account.class);
|
||||||
|
then(updateAccountStatePort).should(times(accountIds.length))
|
||||||
|
.updateActivities(accountCaptor.capture());
|
||||||
|
|
||||||
|
List<AccountId> updatedAccountIds = accountCaptor.getAllValues()
|
||||||
|
.stream()
|
||||||
|
.map(Account::getId)
|
||||||
|
.map(Optional::get)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
for(AccountId accountId : accountIds){
|
||||||
|
assertThat(updatedAccountIds).contains(accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void givenDepositWillSucceed(Account account) {
|
||||||
|
given(account.deposit(any(Money.class), any(AccountId.class)))
|
||||||
|
.willReturn(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void givenWithdrawalWillFail(Account account) {
|
||||||
|
given(account.withdraw(any(Money.class), any(AccountId.class)))
|
||||||
|
.willReturn(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void givenWithdrawalWillSucceed(Account account) {
|
||||||
|
given(account.withdraw(any(Money.class), any(AccountId.class)))
|
||||||
|
.willReturn(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Account givenTargetAccount(){
|
||||||
|
return givenAnAccountWithId(new AccountId(42L));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Account givenSourceAccount(){
|
||||||
|
return givenAnAccountWithId(new AccountId(41L));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Account givenAnAccountWithId(AccountId id) {
|
||||||
|
Account account = Mockito.mock(Account.class);
|
||||||
|
given(account.getId())
|
||||||
|
.willReturn(Optional.of(id));
|
||||||
|
given(loadAccountPort.loadAccount(eq(account.getId().get()), any(LocalDateTime.class)))
|
||||||
|
.willReturn(account);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MoneyTransferProperties moneyTransferProperties(){
|
||||||
|
return new MoneyTransferProperties(Money.of(Long.MAX_VALUE));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
62
src/test/java/io/reflectoring/buckpal/archunit/Adapters.java
Normal file
62
src/test/java/io/reflectoring/buckpal/archunit/Adapters.java
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package io.reflectoring.buckpal.archunit;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||||
|
|
||||||
|
public class Adapters extends ArchitectureElement {
|
||||||
|
|
||||||
|
private final HexagonalArchitecture parentContext;
|
||||||
|
private List<String> incomingAdapterPackages = new ArrayList<>();
|
||||||
|
private List<String> outgoingAdapterPackages = new ArrayList<>();
|
||||||
|
|
||||||
|
Adapters(HexagonalArchitecture parentContext, String basePackage) {
|
||||||
|
super(basePackage);
|
||||||
|
this.parentContext = parentContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Adapters outgoing(String packageName) {
|
||||||
|
this.incomingAdapterPackages.add(fullQualifiedPackage(packageName));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Adapters incoming(String packageName) {
|
||||||
|
this.outgoingAdapterPackages.add(fullQualifiedPackage(packageName));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> allAdapterPackages() {
|
||||||
|
List<String> allAdapters = new ArrayList<>();
|
||||||
|
allAdapters.addAll(incomingAdapterPackages);
|
||||||
|
allAdapters.addAll(outgoingAdapterPackages);
|
||||||
|
return allAdapters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HexagonalArchitecture and() {
|
||||||
|
return parentContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getBasePackage() {
|
||||||
|
return basePackage;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dontDependOnEachOther(JavaClasses classes) {
|
||||||
|
List<String> allAdapters = allAdapterPackages();
|
||||||
|
for (String adapter1 : allAdapters) {
|
||||||
|
for (String adapter2 : allAdapters) {
|
||||||
|
if (!adapter1.equals(adapter2)) {
|
||||||
|
denyDependency(adapter1, adapter2, classes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void doesNotDependOn(String packageName, JavaClasses classes) {
|
||||||
|
denyDependency(this.basePackage, packageName, classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
void doesNotContainEmptyPackages() {
|
||||||
|
denyEmptyPackages(allAdapterPackages());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package io.reflectoring.buckpal.archunit;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||||
|
|
||||||
|
public class ApplicationLayer extends ArchitectureElement {
|
||||||
|
|
||||||
|
private final HexagonalArchitecture parentContext;
|
||||||
|
private List<String> incomingPortsPackages = new ArrayList<>();
|
||||||
|
private List<String> outgoingPortsPackages = new ArrayList<>();
|
||||||
|
private List<String> servicePackages = new ArrayList<>();
|
||||||
|
|
||||||
|
public ApplicationLayer(String basePackage, HexagonalArchitecture parentContext) {
|
||||||
|
super(basePackage);
|
||||||
|
this.parentContext = parentContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApplicationLayer incomingPorts(String packageName) {
|
||||||
|
this.incomingPortsPackages.add(fullQualifiedPackage(packageName));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApplicationLayer outgoingPorts(String packageName) {
|
||||||
|
this.outgoingPortsPackages.add(fullQualifiedPackage(packageName));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApplicationLayer services(String packageName) {
|
||||||
|
this.servicePackages.add(fullQualifiedPackage(packageName));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HexagonalArchitecture and() {
|
||||||
|
return parentContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void doesNotDependOn(String packageName, JavaClasses classes) {
|
||||||
|
denyDependency(this.basePackage, packageName, classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void incomingAndOutgoingPortsDoNotDependOnEachOther(JavaClasses classes) {
|
||||||
|
denyAnyDependency(this.incomingPortsPackages, this.outgoingPortsPackages, classes);
|
||||||
|
denyAnyDependency(this.outgoingPortsPackages, this.incomingPortsPackages, classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> allPackages() {
|
||||||
|
List<String> allPackages = new ArrayList<>();
|
||||||
|
allPackages.addAll(incomingPortsPackages);
|
||||||
|
allPackages.addAll(outgoingPortsPackages);
|
||||||
|
allPackages.addAll(servicePackages);
|
||||||
|
return allPackages;
|
||||||
|
}
|
||||||
|
|
||||||
|
void doesNotContainEmptyPackages() {
|
||||||
|
denyEmptyPackages(allPackages());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package io.reflectoring.buckpal.archunit;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||||
|
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||||
|
import static com.tngtech.archunit.base.DescribedPredicate.*;
|
||||||
|
import static com.tngtech.archunit.lang.conditions.ArchConditions.*;
|
||||||
|
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
|
||||||
|
|
||||||
|
abstract class ArchitectureElement {
|
||||||
|
|
||||||
|
final String basePackage;
|
||||||
|
|
||||||
|
public ArchitectureElement(String basePackage) {
|
||||||
|
this.basePackage = basePackage;
|
||||||
|
}
|
||||||
|
|
||||||
|
String fullQualifiedPackage(String relativePackage) {
|
||||||
|
return this.basePackage + "." + relativePackage;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void denyDependency(String fromPackageName, String toPackageName, JavaClasses classes) {
|
||||||
|
noClasses()
|
||||||
|
.that()
|
||||||
|
.resideInAPackage("io.reflectoring.reviewapp.domain..")
|
||||||
|
.should()
|
||||||
|
.dependOnClassesThat()
|
||||||
|
.resideInAnyPackage("io.reflectoring.reviewapp.application..")
|
||||||
|
.check(classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void denyAnyDependency(
|
||||||
|
List<String> fromPackages, List<String> toPackages, JavaClasses classes) {
|
||||||
|
for (String fromPackage : fromPackages) {
|
||||||
|
for (String toPackage : toPackages) {
|
||||||
|
noClasses()
|
||||||
|
.that()
|
||||||
|
.resideInAPackage(matchAllClassesInPackage(fromPackage))
|
||||||
|
.should()
|
||||||
|
.dependOnClassesThat()
|
||||||
|
.resideInAnyPackage(matchAllClassesInPackage(toPackage))
|
||||||
|
.check(classes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String matchAllClassesInPackage(String packageName) {
|
||||||
|
return packageName + "..";
|
||||||
|
}
|
||||||
|
|
||||||
|
void denyEmptyPackage(String packageName) {
|
||||||
|
classes()
|
||||||
|
.that()
|
||||||
|
.resideInAPackage(matchAllClassesInPackage(packageName))
|
||||||
|
.should(containNumberOfElements(greaterThanOrEqualTo(1)))
|
||||||
|
.check(classesInPackage(packageName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private JavaClasses classesInPackage(String packageName) {
|
||||||
|
return new ClassFileImporter().importPackages(packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
void denyEmptyPackages(List<String> packages) {
|
||||||
|
for (String packageName : packages) {
|
||||||
|
denyEmptyPackage(packageName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package io.reflectoring.buckpal.archunit;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||||
|
|
||||||
|
public class HexagonalArchitecture extends ArchitectureElement {
|
||||||
|
|
||||||
|
private Adapters adapters;
|
||||||
|
private ApplicationLayer applicationLayer;
|
||||||
|
private String configurationPackage;
|
||||||
|
private List<String> domainPackages = new ArrayList<>();
|
||||||
|
|
||||||
|
public static HexagonalArchitecture boundedContext(String basePackage) {
|
||||||
|
return new HexagonalArchitecture(basePackage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HexagonalArchitecture(String basePackage) {
|
||||||
|
super(basePackage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Adapters withAdaptersLayer(String adaptersPackage) {
|
||||||
|
this.adapters = new Adapters(this, fullQualifiedPackage(adaptersPackage));
|
||||||
|
return this.adapters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HexagonalArchitecture withDomainLayer(String domainPackage) {
|
||||||
|
this.domainPackages.add(fullQualifiedPackage(domainPackage));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApplicationLayer withApplicationLayer(String applicationPackage) {
|
||||||
|
this.applicationLayer = new ApplicationLayer(fullQualifiedPackage(applicationPackage), this);
|
||||||
|
return this.applicationLayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HexagonalArchitecture withConfiguration(String packageName) {
|
||||||
|
this.configurationPackage = fullQualifiedPackage(packageName);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void domainDoesNotDependOnOtherPackages(JavaClasses classes) {
|
||||||
|
denyAnyDependency(
|
||||||
|
this.domainPackages, Collections.singletonList(adapters.basePackage), classes);
|
||||||
|
denyAnyDependency(
|
||||||
|
this.domainPackages, Collections.singletonList(applicationLayer.basePackage), classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void check(JavaClasses classes) {
|
||||||
|
this.adapters.doesNotContainEmptyPackages();
|
||||||
|
this.adapters.dontDependOnEachOther(classes);
|
||||||
|
this.adapters.doesNotDependOn(this.configurationPackage, classes);
|
||||||
|
this.applicationLayer.doesNotContainEmptyPackages();
|
||||||
|
this.applicationLayer.doesNotDependOn(this.adapters.getBasePackage(), classes);
|
||||||
|
this.applicationLayer.doesNotDependOn(this.configurationPackage, classes);
|
||||||
|
this.applicationLayer.incomingAndOutgoingPortsDoNotDependOnEachOther(classes);
|
||||||
|
this.domainDoesNotDependOnOtherPackages(classes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package io.reflectoring.buckpal.common;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.ActivityWindow;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
|
||||||
|
public class AccountTestData {
|
||||||
|
|
||||||
|
public static AccountBuilder defaultAccount() {
|
||||||
|
return new AccountBuilder()
|
||||||
|
.withAccountId(new AccountId(42L))
|
||||||
|
.withBaselineBalance(Money.of(999L))
|
||||||
|
.withActivityWindow(new ActivityWindow(
|
||||||
|
ActivityTestData.defaultActivity().build(),
|
||||||
|
ActivityTestData.defaultActivity().build()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static class AccountBuilder {
|
||||||
|
|
||||||
|
private AccountId accountId;
|
||||||
|
private Money baselineBalance;
|
||||||
|
private ActivityWindow activityWindow;
|
||||||
|
|
||||||
|
public AccountBuilder withAccountId(AccountId accountId) {
|
||||||
|
this.accountId = accountId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AccountBuilder withBaselineBalance(Money baselineBalance) {
|
||||||
|
this.baselineBalance = baselineBalance;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AccountBuilder withActivityWindow(ActivityWindow activityWindow) {
|
||||||
|
this.activityWindow = activityWindow;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Account build() {
|
||||||
|
return Account.withId(this.accountId, this.baselineBalance, this.activityWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package io.reflectoring.buckpal.common;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Activity;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Activity.ActivityId;
|
||||||
|
import io.reflectoring.buckpal.account.domain.Money;
|
||||||
|
|
||||||
|
public class ActivityTestData {
|
||||||
|
|
||||||
|
public static ActivityBuilder defaultActivity(){
|
||||||
|
return new ActivityBuilder()
|
||||||
|
.withOwnerAccount(new AccountId(42L))
|
||||||
|
.withSourceAccount(new AccountId(42L))
|
||||||
|
.withTargetAccount(new AccountId(41L))
|
||||||
|
.withTimestamp(LocalDateTime.now())
|
||||||
|
.withMoney(Money.of(999L));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ActivityBuilder {
|
||||||
|
private ActivityId id;
|
||||||
|
private AccountId ownerAccountId;
|
||||||
|
private AccountId sourceAccountId;
|
||||||
|
private AccountId targetAccountId;
|
||||||
|
private LocalDateTime timestamp;
|
||||||
|
private Money money;
|
||||||
|
|
||||||
|
public ActivityBuilder withId(ActivityId id) {
|
||||||
|
this.id = id;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivityBuilder withOwnerAccount(AccountId accountId) {
|
||||||
|
this.ownerAccountId = accountId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivityBuilder withSourceAccount(AccountId accountId) {
|
||||||
|
this.sourceAccountId = accountId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivityBuilder withTargetAccount(AccountId accountId) {
|
||||||
|
this.targetAccountId = accountId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivityBuilder withTimestamp(LocalDateTime timestamp) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ActivityBuilder withMoney(Money money) {
|
||||||
|
this.money = money;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Activity build() {
|
||||||
|
return new Activity(
|
||||||
|
this.id,
|
||||||
|
this.ownerAccountId,
|
||||||
|
this.sourceAccountId,
|
||||||
|
this.targetAccountId,
|
||||||
|
this.timestamp,
|
||||||
|
this.money);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
insert into account (id) values (1);
|
||||||
|
insert into account (id) values (2);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (1001, '2018-08-08 08:00:00.0', 1, 1, 2, 500);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (1002, '2018-08-08 08:00:00.0', 2, 1, 2, 500);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (1003, '2018-08-09 10:00:00.0', 1, 2, 1, 1000);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (1004, '2018-08-09 10:00:00.0', 2, 2, 1, 1000);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (1005, '2019-08-09 09:00:00.0', 1, 1, 2, 1000);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (1006, '2019-08-09 09:00:00.0', 2, 1, 2, 1000);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (1007, '2019-08-09 10:00:00.0', 1, 2, 1, 1000);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (1008, '2019-08-09 10:00:00.0', 2, 2, 1, 1000);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
insert into account (id) values (1);
|
||||||
|
insert into account (id) values (2);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (1, '2018-08-08 08:00:00.0', 1, 1, 2, 500);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (2, '2018-08-08 08:00:00.0', 2, 1, 2, 500);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (3, '2018-08-09 10:00:00.0', 1, 2, 1, 1000);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (4, '2018-08-09 10:00:00.0', 2, 2, 1, 1000);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (5, '2019-08-09 09:00:00.0', 1, 1, 2, 1000);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (6, '2019-08-09 09:00:00.0', 2, 1, 2, 1000);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (7, '2019-08-09 10:00:00.0', 1, 2, 1, 1000);
|
||||||
|
|
||||||
|
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||||
|
values (8, '2019-08-09 10:00:00.0', 2, 2, 1, 1000);
|
||||||
Reference in New Issue
Block a user