commit fe9edeacfbc1a0c2474afa457968206a87f3abcd Author: wikibook Date: Wed Nov 3 17:09:30 2021 +0900 예제 코드 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..280e4c0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..204c952 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fcd8fa --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Example Implementation of a Hexagonal Architecture + +[![CI](https://github.com/thombergs/buckpal/actions/workflows/ci.yml/badge.svg)](https://github.com/thombergs/buckpal/actions/workflows/ci.yml) + +[![Get Your Hands Dirty On Clean Architecture](https://reflectoring.io/assets/img/get-your-hands-dirty-260x336.png)](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 diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..d0022b6 --- /dev/null +++ b/build.gradle @@ -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() +} + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..87b738c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2a56324 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..af6708f --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..6d57edc --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/src/main/java/io/reflectoring/buckpal/BuckPalApplication.java b/src/main/java/io/reflectoring/buckpal/BuckPalApplication.java new file mode 100644 index 0000000..3cd06ed --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/BuckPalApplication.java @@ -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); + } + +} diff --git a/src/main/java/io/reflectoring/buckpal/BuckPalConfiguration.java b/src/main/java/io/reflectoring/buckpal/BuckPalConfiguration.java new file mode 100644 index 0000000..d66954b --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/BuckPalConfiguration.java @@ -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())); + } + +} diff --git a/src/main/java/io/reflectoring/buckpal/BuckPalConfigurationProperties.java b/src/main/java/io/reflectoring/buckpal/BuckPalConfigurationProperties.java new file mode 100644 index 0000000..6da5b42 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/BuckPalConfigurationProperties.java @@ -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; + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/adapter/in/web/SendMoneyController.java b/src/main/java/io/reflectoring/buckpal/account/adapter/in/web/SendMoneyController.java new file mode 100644 index 0000000..ec87b81 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/adapter/in/web/SendMoneyController.java @@ -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); + } + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/AccountJpaEntity.java b/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/AccountJpaEntity.java new file mode 100644 index 0000000..9f901c0 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/AccountJpaEntity.java @@ -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; + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/AccountMapper.java b/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/AccountMapper.java new file mode 100644 index 0000000..7e5928c --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/AccountMapper.java @@ -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 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 activities) { + List 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()); + } + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/AccountPersistenceAdapter.java b/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/AccountPersistenceAdapter.java new file mode 100644 index 0000000..bd56752 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/AccountPersistenceAdapter.java @@ -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 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)); + } + } + } + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/ActivityJpaEntity.java b/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/ActivityJpaEntity.java new file mode 100644 index 0000000..b2c9e90 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/ActivityJpaEntity.java @@ -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; + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/ActivityRepository.java b/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/ActivityRepository.java new file mode 100644 index 0000000..22f08a4 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/ActivityRepository.java @@ -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 { + + @Query("select a from ActivityJpaEntity a " + + "where a.ownerAccountId = :ownerAccountId " + + "and a.timestamp >= :since") + List 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); + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/SpringDataAccountRepository.java b/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/SpringDataAccountRepository.java new file mode 100644 index 0000000..5932689 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/adapter/out/persistence/SpringDataAccountRepository.java @@ -0,0 +1,6 @@ +package io.reflectoring.buckpal.account.adapter.out.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface SpringDataAccountRepository extends JpaRepository { +} diff --git a/src/main/java/io/reflectoring/buckpal/account/application/port/in/GetAccountBalanceQuery.java b/src/main/java/io/reflectoring/buckpal/account/application/port/in/GetAccountBalanceQuery.java new file mode 100644 index 0000000..d989c26 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/application/port/in/GetAccountBalanceQuery.java @@ -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); + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/application/port/in/SendMoneyCommand.java b/src/main/java/io/reflectoring/buckpal/account/application/port/in/SendMoneyCommand.java new file mode 100644 index 0000000..97d6898 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/application/port/in/SendMoneyCommand.java @@ -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 { + + @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(); + } +} diff --git a/src/main/java/io/reflectoring/buckpal/account/application/port/in/SendMoneyUseCase.java b/src/main/java/io/reflectoring/buckpal/account/application/port/in/SendMoneyUseCase.java new file mode 100644 index 0000000..4155f3b --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/application/port/in/SendMoneyUseCase.java @@ -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); + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/application/port/out/AccountLock.java b/src/main/java/io/reflectoring/buckpal/account/application/port/out/AccountLock.java new file mode 100644 index 0000000..26df9d7 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/application/port/out/AccountLock.java @@ -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); + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/application/port/out/LoadAccountPort.java b/src/main/java/io/reflectoring/buckpal/account/application/port/out/LoadAccountPort.java new file mode 100644 index 0000000..0383183 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/application/port/out/LoadAccountPort.java @@ -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); +} diff --git a/src/main/java/io/reflectoring/buckpal/account/application/port/out/UpdateAccountStatePort.java b/src/main/java/io/reflectoring/buckpal/account/application/port/out/UpdateAccountStatePort.java new file mode 100644 index 0000000..5a1704d --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/application/port/out/UpdateAccountStatePort.java @@ -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); + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/application/service/GetAccountBalanceService.java b/src/main/java/io/reflectoring/buckpal/account/application/service/GetAccountBalanceService.java new file mode 100644 index 0000000..ab73dae --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/application/service/GetAccountBalanceService.java @@ -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(); + } +} diff --git a/src/main/java/io/reflectoring/buckpal/account/application/service/MoneyTransferProperties.java b/src/main/java/io/reflectoring/buckpal/account/application/service/MoneyTransferProperties.java new file mode 100644 index 0000000..08965cc --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/application/service/MoneyTransferProperties.java @@ -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); + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/application/service/NoOpAccountLock.java b/src/main/java/io/reflectoring/buckpal/account/application/service/NoOpAccountLock.java new file mode 100644 index 0000000..8c873b8 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/application/service/NoOpAccountLock.java @@ -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 + } + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/application/service/SendMoneyService.java b/src/main/java/io/reflectoring/buckpal/account/application/service/SendMoneyService.java new file mode 100644 index 0000000..3b13505 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/application/service/SendMoneyService.java @@ -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()); + } + } + +} + + + + diff --git a/src/main/java/io/reflectoring/buckpal/account/application/service/ThresholdExceededException.java b/src/main/java/io/reflectoring/buckpal/account/application/service/ThresholdExceededException.java new file mode 100644 index 0000000..449db54 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/application/service/ThresholdExceededException.java @@ -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)); + } + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/domain/Account.java b/src/main/java/io/reflectoring/buckpal/account/domain/Account.java new file mode 100644 index 0000000..142c318 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/domain/Account.java @@ -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 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; + } + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/domain/Activity.java b/src/main/java/io/reflectoring/buckpal/account/domain/Activity.java new file mode 100644 index 0000000..479c1df --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/domain/Activity.java @@ -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; + } + +} diff --git a/src/main/java/io/reflectoring/buckpal/account/domain/ActivityWindow.java b/src/main/java/io/reflectoring/buckpal/account/domain/ActivityWindow.java new file mode 100644 index 0000000..af04ebd --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/domain/ActivityWindow.java @@ -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 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 activities) { + this.activities = activities; + } + + public ActivityWindow(@NonNull Activity... activities) { + this.activities = new ArrayList<>(Arrays.asList(activities)); + } + + public List getActivities() { + return Collections.unmodifiableList(this.activities); + } + + public void addActivity(Activity activity) { + this.activities.add(activity); + } +} diff --git a/src/main/java/io/reflectoring/buckpal/account/domain/Money.java b/src/main/java/io/reflectoring/buckpal/account/domain/Money.java new file mode 100644 index 0000000..e34445c --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/account/domain/Money.java @@ -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()); + } + +} diff --git a/src/main/java/io/reflectoring/buckpal/common/PersistenceAdapter.java b/src/main/java/io/reflectoring/buckpal/common/PersistenceAdapter.java new file mode 100644 index 0000000..d76fdec --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/common/PersistenceAdapter.java @@ -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 ""; + +} diff --git a/src/main/java/io/reflectoring/buckpal/common/SelfValidating.java b/src/main/java/io/reflectoring/buckpal/common/SelfValidating.java new file mode 100644 index 0000000..5dada92 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/common/SelfValidating.java @@ -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 { + + 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> violations = validator.validate((T) this); + if (!violations.isEmpty()) { + throw new ConstraintViolationException(violations); + } + } +} diff --git a/src/main/java/io/reflectoring/buckpal/common/UseCase.java b/src/main/java/io/reflectoring/buckpal/common/UseCase.java new file mode 100644 index 0000000..eca4e05 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/common/UseCase.java @@ -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 ""; + +} diff --git a/src/main/java/io/reflectoring/buckpal/common/WebAdapter.java b/src/main/java/io/reflectoring/buckpal/common/WebAdapter.java new file mode 100644 index 0000000..db25ec4 --- /dev/null +++ b/src/main/java/io/reflectoring/buckpal/common/WebAdapter.java @@ -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 ""; + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..bb4bc27 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,2 @@ +buckpal: + transferThreshold: 10000 \ No newline at end of file diff --git a/src/test/java/io/reflectoring/buckpal/BuckPalApplicationTests.java b/src/test/java/io/reflectoring/buckpal/BuckPalApplicationTests.java new file mode 100644 index 0000000..9943fe0 --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/BuckPalApplicationTests.java @@ -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() { + } + +} diff --git a/src/test/java/io/reflectoring/buckpal/DependencyRuleTests.java b/src/test/java/io/reflectoring/buckpal/DependencyRuleTests.java new file mode 100644 index 0000000..75b240c --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/DependencyRuleTests.java @@ -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..")); + } + +} diff --git a/src/test/java/io/reflectoring/buckpal/SendMoneySystemTest.java b/src/test/java/io/reflectoring/buckpal/SendMoneySystemTest.java new file mode 100644 index 0000000..1583d37 --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/SendMoneySystemTest.java @@ -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 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); + } + +} diff --git a/src/test/java/io/reflectoring/buckpal/account/adapter/in/web/SendMoneyControllerTest.java b/src/test/java/io/reflectoring/buckpal/account/adapter/in/web/SendMoneyControllerTest.java new file mode 100644 index 0000000..5e7da26 --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/account/adapter/in/web/SendMoneyControllerTest.java @@ -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)))); + } + +} diff --git a/src/test/java/io/reflectoring/buckpal/account/adapter/out/persistence/AccountPersistenceAdapterTest.java b/src/test/java/io/reflectoring/buckpal/account/adapter/out/persistence/AccountPersistenceAdapterTest.java new file mode 100644 index 0000000..511ea1d --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/account/adapter/out/persistence/AccountPersistenceAdapterTest.java @@ -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); + } + +} \ No newline at end of file diff --git a/src/test/java/io/reflectoring/buckpal/account/application/domain/AccountTest.java b/src/test/java/io/reflectoring/buckpal/account/application/domain/AccountTest.java new file mode 100644 index 0000000..91dba21 --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/account/application/domain/AccountTest.java @@ -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)); + } + +} \ No newline at end of file diff --git a/src/test/java/io/reflectoring/buckpal/account/application/domain/ActivityWindowTest.java b/src/test/java/io/reflectoring/buckpal/account/application/domain/ActivityWindowTest.java new file mode 100644 index 0000000..6575ff6 --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/account/application/domain/ActivityWindowTest.java @@ -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); + } + +} \ No newline at end of file diff --git a/src/test/java/io/reflectoring/buckpal/account/application/service/SendMoneyServiceTest.java b/src/test/java/io/reflectoring/buckpal/account/application/service/SendMoneyServiceTest.java new file mode 100644 index 0000000..1cb61c8 --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/account/application/service/SendMoneyServiceTest.java @@ -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 accountCaptor = ArgumentCaptor.forClass(Account.class); + then(updateAccountStatePort).should(times(accountIds.length)) + .updateActivities(accountCaptor.capture()); + + List 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)); + } + +} diff --git a/src/test/java/io/reflectoring/buckpal/archunit/Adapters.java b/src/test/java/io/reflectoring/buckpal/archunit/Adapters.java new file mode 100644 index 0000000..4bff0e7 --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/archunit/Adapters.java @@ -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 incomingAdapterPackages = new ArrayList<>(); + private List 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 allAdapterPackages() { + List 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 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()); + } +} diff --git a/src/test/java/io/reflectoring/buckpal/archunit/ApplicationLayer.java b/src/test/java/io/reflectoring/buckpal/archunit/ApplicationLayer.java new file mode 100644 index 0000000..7c4613a --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/archunit/ApplicationLayer.java @@ -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 incomingPortsPackages = new ArrayList<>(); + private List outgoingPortsPackages = new ArrayList<>(); + private List 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 allPackages() { + List allPackages = new ArrayList<>(); + allPackages.addAll(incomingPortsPackages); + allPackages.addAll(outgoingPortsPackages); + allPackages.addAll(servicePackages); + return allPackages; + } + + void doesNotContainEmptyPackages() { + denyEmptyPackages(allPackages()); + } +} diff --git a/src/test/java/io/reflectoring/buckpal/archunit/ArchitectureElement.java b/src/test/java/io/reflectoring/buckpal/archunit/ArchitectureElement.java new file mode 100644 index 0000000..103c870 --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/archunit/ArchitectureElement.java @@ -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 fromPackages, List 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 packages) { + for (String packageName : packages) { + denyEmptyPackage(packageName); + } + } +} diff --git a/src/test/java/io/reflectoring/buckpal/archunit/HexagonalArchitecture.java b/src/test/java/io/reflectoring/buckpal/archunit/HexagonalArchitecture.java new file mode 100644 index 0000000..ae4402c --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/archunit/HexagonalArchitecture.java @@ -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 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); + } +} diff --git a/src/test/java/io/reflectoring/buckpal/common/AccountTestData.java b/src/test/java/io/reflectoring/buckpal/common/AccountTestData.java new file mode 100644 index 0000000..f6d49cf --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/common/AccountTestData.java @@ -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); + } + + } + + +} diff --git a/src/test/java/io/reflectoring/buckpal/common/ActivityTestData.java b/src/test/java/io/reflectoring/buckpal/common/ActivityTestData.java new file mode 100644 index 0000000..2e7bbdd --- /dev/null +++ b/src/test/java/io/reflectoring/buckpal/common/ActivityTestData.java @@ -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); + } + } +} diff --git a/src/test/resources/io/reflectoring/buckpal/SendMoneySystemTest.sql b/src/test/resources/io/reflectoring/buckpal/SendMoneySystemTest.sql new file mode 100644 index 0000000..7973596 --- /dev/null +++ b/src/test/resources/io/reflectoring/buckpal/SendMoneySystemTest.sql @@ -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); \ No newline at end of file diff --git a/src/test/resources/io/reflectoring/buckpal/account/adapter/out/persistence/AccountPersistenceAdapterTest.sql b/src/test/resources/io/reflectoring/buckpal/account/adapter/out/persistence/AccountPersistenceAdapterTest.sql new file mode 100644 index 0000000..3698724 --- /dev/null +++ b/src/test/resources/io/reflectoring/buckpal/account/adapter/out/persistence/AccountPersistenceAdapterTest.sql @@ -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); \ No newline at end of file