유스케이스 구현하기

도메인 모델 구현하기

한 계좌에서 다른 계좌로 송감하는 유스케이스를 구현한다.

Account 엔티티 모델링

입금과 출금을 할 수 있는 계좌를 의미

package com.book.cleanarchitecture.buckpal.account.domain.vo;

public class AccountId {

    private final Long value;

    public AccountId(Long value) {
        this.value = value;
    }

    public Long getValue() {
        return value;
    }
}
package com.book.cleanarchitecture.buckpal.account.domain;

import com.book.cleanarchitecture.buckpal.account.domain.vo.AccountId;
import com.book.cleanarchitecture.buckpal.account.domain.vo.Money;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

public class Account {

    private final AccountId id;

    private final Money baselineBalance;

    private final ActivityWindow activityWindow;

    private Account(AccountId id, Money baselineBalance, ActivityWindow activityWindow) {
        this.id = id;
        this.baselineBalance = baselineBalance;
        this.activityWindow = activityWindow;
    }

    public static Account withId(
            AccountId accountId,
            Money baselineBalance,
            ActivityWindow activityWindow) {
        return new Account(accountId, baselineBalance, activityWindow);
    }

    public Money calculateBalance() {
        return this.baselineBalance.plus(this.activityWindow.calculateBalance(this.id));
    }

    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 this.calculateBalance().plus(money.negate()).isPositiveOrZero();
    }

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

    public Optional<AccountId> getId() {
        return Optional.ofNullable(this.id);
    }

    public List<Activity> getActivities() {
        return activityWindow.getActivities();
    }
}
  • 모든 활동들을 메모리에 올리면 현명하지 않기 때문에 ActivityWindow라는 VO를 가지고 있게 된다.

  • baselineBalance는 첫 번째 활동 바로 전의 잔고를 표현한다

  • 현재 총 잔고는 기준 잔고(baselineBalnce)에 활동창의 모든 활동들의 잔고를 합한 값이 된다.

  • withdraw() 메서드는 출금 / deposit() 메서드는 입금을 의미한다.

ActivityWindow 모델링

계좌에 대한 모든 입금과 출금은 Activity 엔티티에 포착되며, 모든 활동들을 메모리에 올리면 현명하지 않기 때문에 ActivityWindow라는 VO를 가지고 있게 된다.

package com.book.cleanarchitecture.buckpal.account.domain.vo;

public class ActivityId {

    private final Long value;

    public ActivityId(Long value) {
        this.value = value;
    }

    public Long getValue() {
        return value;
    }
}
package com.book.cleanarchitecture.buckpal.account.domain;

import com.book.cleanarchitecture.buckpal.account.domain.vo.AccountId;
import com.book.cleanarchitecture.buckpal.account.domain.vo.Money;

import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

public class ActivityWindow {

    private final List<Activity> activities;

    public ActivityWindow(List<Activity> activities) {
        this.activities = activities;
    }

    public ActivityWindow(Activity... activities) {
        this.activities = Arrays.asList(activities);
    }

    public LocalDateTime getStartTimestamp() {
        return activities.stream()
                .min(Comparator.comparing(Activity::getTimestamp))
                .orElseThrow(IllegalStateException::new)
                .getTimestamp();
    }

    public LocalDateTime getEndTimestamp() {
        return activities.stream()
                .max(Comparator.comparing(Activity::getTimestamp))
                .orElseThrow(IllegalStateException::new)
                .getTimestamp();
    }

    public Money calculateBalance(AccountId accountId) {
        Money depositBalance = activities.stream()
                .filter(activity -> activity.getTargetAccountId().equals(accountId))
                .map(Activity::getMoney)
                .reduce(Money.ZERO, Money::plus);

        Money withdrawalBalance = activities.stream()
                .filter(activity -> activity.getSourceAccountId().equals(accountId))
                .map(Activity::getMoney)
                .reduce(Money.ZERO, Money::plus);

        return depositBalance.plus(withdrawalBalance.negate());
    }

    public void addActivity(Activity activity) {
        this.activities.add(activity);
    }

    public List<Activity> getActivities() {
        return Collections.unmodifiableList(this.activities);
    }
}

Money 모델링

돈을 의미한다.

package com.book.cleanarchitecture.buckpal.account.domain.vo;

import java.math.BigInteger;
import java.util.Objects;

public class Money {

    public static final Money ZERO = Money.of(0L);

    private final BigInteger amount;

    public Money(BigInteger amount) {
        this.amount = amount;
    }

    public static Money of(long value) {
        return new Money(BigInteger.valueOf(value));
    }

    public boolean isPositiveOrZero() {
        return this.amount.compareTo(BigInteger.ZERO) >= 0;
    }

    public boolean isGreaterThan(Money money) {
        return this.amount.compareTo(money.amount) >= 1;
    }

    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 Money negate() {
        return new Money(this.amount.negate());
    }

    public BigInteger getAmount() {
        return amount;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Money money = (Money) o;
        return amount.equals(money.amount);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount);
    }
}

유스케이스 둘러보기

단계

  1. 입력을 받는다.

  2. 비즈니스 규칙을 검증한다.

  3. 모델 상태를 조작한다.

  4. 출력을 반환한다.

비즈니스 규칙 검증이란?

유스케이스는 비즈니스 규칙을 검증할 책임이 있고, 도메인인 엔티티와 이 책임을 공유한다. 하나의 서비스가 하나의 유스케이스를 구현하고, 도메인 모델을 변경하고, 변경된 상태를 저장하기 위해 아웃고잉 포트를 호출한다.

package com.book.cleanarchitecture.buckpal.application.service;

import com.book.cleanarchitecture.buckpal.application.port.in.SendMoneyCommand;
import org.springframework.transaction.annotation.Transactional;

@Transactional
public class SendMoneyService implements SendMoneyUseCase {
    private final LoadAccountPort loadAccountPort;
    private final AccountLock accountLock;
    private final UpdateAccountStatePort updateAccountStatePort;

    public SendMoneyService(LoadAccountPort loadAccountPort, AccountLock accountLock, UpdateAccountStatePort updateAccountStatePort) {
        this.loadAccountPort = loadAccountPort;
        this.accountLock = accountLock;
        this.updateAccountStatePort = updateAccountStatePort;
    }

    @Override
    public boolean sendMoney(SendMoneyCommand command) {
        // Todo 비즈니스 규칙 검증
        // Todo 모델 상태 조작
        // Todo 출력 값 반환
    }
}

입력 유효성 검증

애플리케이션 계층에서 유효성을 검증해야 하는 이유는 그렇게 하지 않을 경우 애플리케이션 코어의 바깥쪽으로부터 유효하지 않은 입력값을 받게 되고, 모델의 상태를 해칠수 있기 때문이다. 따라서 입력 모델(input model)을 이 문제를 다루도록 한다.

package com.book.cleanarchitecture.buckpal.shared;

import javax.validation.*;
import java.util.Set;

public abstract class SelfValidating<T> {

    private final Validator validator;

    protected SelfValidating() {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        this.validator = factory.getValidator();
    }

    protected void validateSelf() {
        Set<ConstraintViolation<T>> violations = validator.validate((T) this);
        if (!violations.isEmpty()) {
            throw new ConstraintViolationException(violations);
        }
    }
}
package com.book.cleanarchitecture.buckpal.account.application.port.in;

import com.book.cleanarchitecture.buckpal.account.domain.vo.AccountId;
import com.book.cleanarchitecture.buckpal.account.domain.vo.Money;
import com.book.cleanarchitecture.buckpal.shared.SelfValidating;

import javax.validation.constraints.NotNull;

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 AccountId getSourceAccountId() {
        return sourceAccountId;
    }

    public AccountId getTargetAccountId() {
        return targetAccountId;
    }

    public Money getMoney() {
        return money;
    }
}
  • final을 이용해 불변 필드로 만들고, 이후에 잘못된 상태로 변경할 수 없다는 사실을 보장하자.

  • Java Bean Validation API를 이용해 작업하기 위해 SelfValidating이라는 클래스를 만들었다.

  • 입력 모델에 있는 유효성 검증 코드를 통해 유스케이스 구현체 주위에 사실상 오류 방지 계층(anti corruption layer)을 만ㄷ르었다.

💡 오류 방지 계층이란? 하나의 바운디드 컨텍스트를 다른 바운디드 컨텍스트와 격리시키는 계층

유스케이스마다 다른 입력 모델

각기 다른 유스케이스에 동일한 입력 모델을 사용하고 싶다는 생각이 든다면?

불변 커맨드 객체의 필드에 대해서 null을 유효한 상태로 받아들이는 것은 그 자체로 코드 냄새다. 등록 유스케이스와 업데이트 유스케이스는 서로 다른 유효성 검증 로직이 필요하다. 아마도 유스케이스에 커스텀 유효성 검증 로직을 넣어야 할 테고, 이는 신성한 비즈니스 코드를 입력 유효성 검증과 관련된 관심사로 오염시킨다. 각 유스케이스 전용 입력 모델은 유스케이스를 훨씬 명확하게 만들고 다른 유스케이스와의 결합도 제거해서 불필요한 부수효과가 발생하지 않게 한다.

비즈니스 규칙 검증하기

입력 유효성을 검증하는 것은 구문상의(syntactical) 유효성을 검증하는 것이라고도 할 수 있다. 반면 비즈니스 규칙은 유스케이스의 맥락 속에서 의미적인(semantical) 유효성을 검증하는 일이라고 할 수 있다.

비즈니스 규칙을 도메인 엔티티 안에 넣는 것이 가장 좋다.규칙을 지켜야 하는 비즈니스 로직 바로 옆에 규칙이 위치하기 때문에 위치를 정하는 것도 쉽고 추론하기도 쉽다. 도메인 엔티티에서 비즈니스 규칙을 검증하기가 여의치 않다면 유스케이스 코드에서 도메인 엔티티를 사용하기 전에 해도 된다.

package com.book.cleanarchitecture.buckpal.doamin;

import com.book.cleanarchitecture.buckpal.doamin.vo.AccountId;

import java.time.LocalDateTime;

public class Account {

    private final AccountId id;
    private final Money baselineBalance;
    private final ActivityWindow activityWindow;

    public Account(AccountId id, Money baselineBalance, ActivityWindow activityWindow) {
        this.id = id;
        this.baselineBalance = baselineBalance;
        this.activityWindow = activityWindow;
    }

    public Money calculateBalance() {
        return this.baselineBalance.plus(this.activityWindow.calculateBalance(this.id));
    }

    public boolean withdraw(Money money, AccountId targetAccountId) {
        if(!mayWithdraw(money)) {
            return false;
        }

                // ...
    }
}
package com.book.cleanarchitecture.buckpal.application.service;

import com.book.cleanarchitecture.buckpal.application.port.in.SendMoneyCommand;
import org.springframework.transaction.annotation.Transactional;

@Transactional
public class SendMoneyService implements SendMoneyUseCase {
    private final LoadAccountPort loadAccountPort;
    private final AccountLock accountLock;
    private final UpdateAccountStatePort updateAccountStatePort;

    public SendMoneyService(LoadAccountPort loadAccountPort, AccountLock accountLock, UpdateAccountStatePort updateAccountStatePort) {
        this.loadAccountPort = loadAccountPort;
        this.accountLock = accountLock;
        this.updateAccountStatePort = updateAccountStatePort;
    }

    @Override
    public boolean sendMoney(SendMoneyCommand command) {
        requireAccountExists(command.getSourceAccountId());
        requireAccountExists(command.getTargetAccountId());
    }
}

풍부한 도메인 모델 vs 빈약한 도메인 모델

풍부한 도메인 모델이란?

애플리케이션의 코어에 있는 엔티티에서 가능한 한 많은 도메인 로직이 구현된다. 엔티티들은 상태를 변경하는 메서드를 제공하고, 비즈니스 규칙에 맞는 유효한 변경만 허용한다.

빈약한 도메인 모델이란?

일반적으로 상태를 표현하는 필드와 getter, setter만 포함하고 어느 도메인 로직도 가지고 있지 않다. 도메인 로직이 유스케이스 클래스에 구현되어 있다는 것이고, 엔티티를 전달할 책임 역시 유스케이스 클래스에 있다.

둘 중에 무엇이 좋은가?

각자의 필요에 맞는 스타일을 자유롭게 선택하면 되지만, 나는 풍부한 도메인 모델이 더 좋다고 생각한다

유스케이스마다 다른 출력 모델

만약 의심스럽다면 가능한 한 적게 반환하자.

유스케이스들 간에 같은 출력 모델을 공유하게 되면 유스케이스들도 강하게 결합된다. 한 유스케이스에서 출력 모델에 새로운 필드가 필요해지면 이 값과 관련이 없는 다른 유스케이스에서도 이 필드를 처리해야 한다. 공유 모델은 장기적으로 봤을 때 갖가지 이유로 점점 커지게 돼 있다. 단일 책임 원칙을 적용하고 모델을 분리해서 유지하는 것은 유스케이스의 결합을 제거하는 데 도움이 된다.

읽기 전용 유스케이스는 어떨까?

쿼리를 위한 인커밍 전용 포트를 만들고 이를 쿼리 서비스에 구현하는 것이다.

package com.book.cleanarchitecture.buckpal.account.application.service;

import com.book.cleanarchitecture.buckpal.account.application.port.in.GetAccountBalanceQuery;
import com.book.cleanarchitecture.buckpal.account.application.port.out.LoadAccountPort;
import com.book.cleanarchitecture.buckpal.account.domain.vo.AccountId;
import com.book.cleanarchitecture.buckpal.account.domain.vo.Money;

import java.time.LocalDateTime;

public class GetAccountBalanceService implements GetAccountBalanceQuery {
    private final LoadAccountPort loadAccountPort;

    public GetAccountBalanceService(LoadAccountPort loadAccountPort) {
        this.loadAccountPort = loadAccountPort;
    }

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

이 처럼 읽기 전용 쿼리는 쓰기가 가능한 유스케이스(또는 커맨드)와 코드 상에서 명확하게 구분된다. 이런 방식은 CQS(Command-Query Separation)나 CQRS(Command-Query Responsibility Segregation)같은 개념과 아주 잘 맞는다.

💡 CQRS란 데이터를 조회하는 작업과 데이터를 업데이트하는 작업의 책임을 분리하는 패턴으로서 명명 쿼리 책임 분리로 직영된다.

Last updated