예제 코드

This commit is contained in:
wikibook
2021-11-03 17:09:30 +09:00
commit fe9edeacfb
54 changed files with 2372 additions and 0 deletions

36
.github/workflows/ci.yml vendored Normal file
View 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
View 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
View File

@@ -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

47
build.gradle Normal file
View 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

Binary file not shown.

View 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
View 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
View 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

View File

@@ -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);
}
}

View File

@@ -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()));
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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());
}
}

View File

@@ -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));
}
}
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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> {
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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
}
}

View File

@@ -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());
}
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View 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 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 "";
}

View File

@@ -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);
}
}
}

View 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 "";
}

View 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 "";
}

View File

@@ -0,0 +1,2 @@
buckpal:
transferThreshold: 10000

View File

@@ -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() {
}
}

View File

@@ -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.."));
}
}

View 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);
}
}

View File

@@ -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))));
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View 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());
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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);