关于架构:聊聊buckpal对于Hexagonal-Architecture的实践

2次阅读

共计 8146 个字符,预计需要花费 21 分钟才能阅读完成。

本文次要赏析一下 buckpal 对于 Hexagonal Architecture 的实际

我的项目构造

├── adapter
│   ├── in
│   │   └── web
│   │       └── SendMoneyController.java
│   └── out
│       └── persistence
│           ├── AccountJpaEntity.java
│           ├── AccountMapper.java
│           ├── AccountPersistenceAdapter.java
│           ├── ActivityJpaEntity.java
│           ├── ActivityRepository.java
│           └── SpringDataAccountRepository.java
├── application
│   ├── port
│   │   ├── in
│   │   │   ├── GetAccountBalanceQuery.java
│   │   │   ├── SendMoneyCommand.java
│   │   │   └── SendMoneyUseCase.java
│   │   └── out
│   │       ├── AccountLock.java
│   │       ├── LoadAccountPort.java
│   │       └── UpdateAccountStatePort.java
│   └── service
│       ├── GetAccountBalanceService.java
│       ├── MoneyTransferProperties.java
│       ├── NoOpAccountLock.java
│       ├── SendMoneyService.java
│       └── ThresholdExceededException.java
└── domain
    ├── Account.java
    ├── Activity.java
    ├── ActivityWindow.java
    └── Money.java

这里分为 adapter、application、domain 三层;其中 application 层定义了 port 包,该包定义了 in、out 两种类型的接口;adapter 层也分 in、out 两类,别离实现 application/port 层的接口;application 的 service 则实现了 port 的接口

application/port

in

public interface GetAccountBalanceQuery {Money getAccountBalance(AccountId accountId);

}

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

public interface SendMoneyUseCase {boolean sendMoney(SendMoneyCommand command);

}

application/port/in 定义了 GetAccountBalanceQuery、SendMoneyUseCase 接口

out

public interface AccountLock {void lockAccount(Account.AccountId accountId);

    void releaseAccount(Account.AccountId accountId);

}

public interface LoadAccountPort {Account loadAccount(AccountId accountId, LocalDateTime baselineDate);
}

public interface UpdateAccountStatePort {void updateActivities(Account account);

}

application/port/out 定义了 AccountLock、LoadAccountPort、UpdateAccountStatePort 接口

application/service

@RequiredArgsConstructor
class GetAccountBalanceService implements GetAccountBalanceQuery {

    private final LoadAccountPort loadAccountPort;

    @Override
    public Money getAccountBalance(AccountId accountId) {return loadAccountPort.loadAccount(accountId, LocalDateTime.now())
                .calculateBalance();}
}

@Component
class NoOpAccountLock implements AccountLock {

    @Override
    public void lockAccount(AccountId accountId) {// do nothing}

    @Override
    public void releaseAccount(AccountId accountId) {// do nothing}

}

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

}

application/service 的 GetAccountBalanceService 实现了 application.port.in.GetAccountBalanceQuery 接口;NoOpAccountLock 实现了 application.port.out.AccountLock 接口;SendMoneyService 实现了 application.port.in.SendMoneyUseCase 接口

domain

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

}

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

Account 类定义了 calculateBalance、withdraw、deposit 办法;ActivityWindow 类定义了 calculateBalance 办法

小结

buckpal 工程 adapter、application、domain 三层;其中 application 层定义了 port 包,该包定义了 in、out 两种类型的接口;adapter 层也分 in、out 两类,别离实现 application/port 层的接口;application 的 service 则实现了 port 的接口。其中 domain 层不依赖任何层;application 层的 port 定义了接口,而后 service 层实现接口和援用接口;adapter 层则实现了 application 的 port 层的接口。

doc

  • buckpal
正文完
 0