package buckpal.domain;
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Account {
@Getter
private final AccountId id;
@Getter
private final Money baselineBalance;
@Getter
private final ActivityWindow activityWindow;
// 생성자 생략
public Money calculateBalance() {
return Money.add(
this.baselineBalance,
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 Money.add(
this.calculateBalance(),
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;
}
}
실제 계좌의 현재 스냅샷을 제공
계좌에 대한 모든 입금과 출금은 Activity 엔티티에 포착된다
한 계좌에 대한 모든 활동을 항상 메모리에 한꺼번에 올리는 것은 현명한 방법이 아니기에, Account 엔티티는 ActivityWindow 값 객체에서 포착한 지난 며칠 혹은 몇 주간의 범위에 해당하는 활동만 보유한다
ActivityWindow의 baselineBalance 속성은 현재 ActivityWindow가 보유한 첫 번째 활동 전의 잔고를 표현한다.
계좌의 현재 잔고 : (ActivityWindow의 baselineBalance) + (ActivityWindow의 모든 활동들의 합)
유스케이스
유스케이스 코드는 도메인 로직만 다루는 것이 좋기에, 입력 유효성 검증은 다른 곳에서 처리한다
유스케이스는 도메인 엔티티와 함께 비즈니스 규칙을 검증할 책임을 공유한다
넓은 서비스 문제를 피하기 위해, 각 유스케이스별로 서비스를 분리하여 구현한다
유스케이스의 동작 과정
1. 입력을 받는다
2. 비즈니스 규칙을 검증한다
3. 모델 상태를 조작한다
4. 출력을 반환한다
송금하기 유스케이스 구현 서비스
package buckpal.application.service;
@RequiredArgsConstructor
@Transactional
public class SendMoneyService implements SendMoneyUseCase {
private final LoadAccountPort loadAccountPort;
private final AccountLock accountLock;
private final UpdateAccountStatePort updateAccountStatePort;
@Override
public boolean sendMoney(SendMoneyCommand command) {
// 비즈니스 규칙 검증
// 모델 상태 변경
// 출력 값 반환
}
}
SendMoneyService는 인커밍 포트 인터페이스SendMoneyUseCase를 구현한다.
SendMoneyService는 계좌를 불러오기 위해 아웃고잉 포트 인터페이스LoadAccountPort를 호출하고, 계좌 상태를 업데이트하기 위해 아웃고잉 포트 인터페이스UpdateAccountStatePort를 호출한다.
입력 모델을 통한 유효성 검증
인커밍 어댑터에서 입력 유효성을 검증하는 경우
유스케이스를 호출하는 모든 어댑터에서 유효성 검증 로직을 모두 구현해야 한다 (코드 중복)
각 어댑터가 유스케이스를 호출하기 전에 유효성 검증을 정상적으로 수행했음을 장담할 수 없다 (신뢰성)
따라서, 입력 유효성 검증은 애플리케이션 계층에서 수행해야 한다
각 유스케이스는 입력 모델을 통해 값을 전달받고, 입력 모델의 생성자에서 유효성 검증을 수행함으로써 입력값의 유효성을 보장할 수 있다.
입력 모델에 있는 유효성 검증 코드는 사실상 유스케이스 주변에 오류를 방지할 수 있는 계층을 형성한다
송금하기 유스케이스의 입력 모델
package buckpal.application.port.in;
@Getter
public class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {
private final AccountId sourceAccountId;
private final AccountId targetAccountId;
private final Money money;
public SendMoneyCommand(AccountId sourceAccountId, AccountId targetAccountId, Money money) {
this.sourceAccountId = sourceAccountId;
this.targetAccountId = targetAccountId;
this.money = money;
requireNonNull(sourceAccountId);
requireNonNull(targetAccountId);
requireNonNull(money);
requireGreaterThan(money, 0);
}
}
SendMoneyCommand는SendMoneyUseCase의 입력 모델이다
송금을 위해서는 출금 계좌와 입금 계좌의 ID, 송금 금액이 필요하다
유효성 검증 조건 : 모든 파라미터가 null이 아니어야 하고 송금액은 0보다 커야 한다.
각 필드에 final을 지정함으로써, 생성된 이후 잘못된 상태로 변경되지 않도록 보장할 수 있다
SendMoneyCommand는 유스케이스 API의 일부이기 때문에 인커밍 포트 패키지에 위치한다.
유효성 검증 로직을 유스케이스와 분리된 별도의 입력 모델에서 구현함으로써, 유스케이스 코드는 오염되지 않고 비즈니스 로직에 집중할 수 있다
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();
}
}
package shared;
public abstract class SelfValidating<T> {
private Validator validator;
public SelfValidating() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
protected void validateSelf() {
Set<ConstraintViolation<T>> violations = validator.validate((T) this);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}
자바의 Bean Validation API를 이용하여 필요한 유효성 규칙들을 필드의 애너테이션으로 표현할 수 있다.
SelfValidating추상 클래스가 제공하는validateSelf()메서드는 필드에 지정된 Bean Validation 애너테이션을 검증하고, 유효성 검증 규칙을 위반한 경우 예외를 던진다.
생성자의 힘
긴 파라미터 리스트를 받아야 하는 경우 생성자 대신 빌더 패턴을 활용할 수 있다
빌더를 호출하는 코드에 새로운 필드를 추가하지 않는 실수가 있어도 컴파일러는 이를 경고해주지 못한다
하지만 빌더 대신 생성자를 직접 사용했다면 컴파일 에러를 따라 나머지 코드에 변경사항을 반영할 수 있었을 것이다.
또한, 긴 파라미터 리스트라도 충분히 깔끔하게 포매팅할 수 있고, 몇몇 IDE가 파라미터명 힌트를 제공해준다
이와 같은 이유로, 책에서는 빌더 대신 생성자를 직접 사용하는 것을 권장한다
유스케이스마다 다른 입력 모델
서로 다른 유스케이스에서 동일한 입력 모델을 사용하는 경우 다음과 같은 문제가 발생할 수 있다
특정 유스케이스에서 입력받지 못하는 필드에 대해서는 null을 허용해야 한다
유스케이스별로 유효성 검증 로직이 다르기 때문에, 이를 입력 모델에서 구현하지 못하고 유스케이스에서 구현함으로써 비즈니스 코드가 유효성 검증과 관련된 관심사로 오염된다
각 유스케이스별로 전용의 입력 모델을 사용하면, 유스케이스를 훨씬 명확하게 만들고 다른 유스케이스와의 결합도 제거함으로써 불필요한 부수효과를 방지할 수 있다.
유스케이스마다 다른 출력 모델
입력과 비슷하게 출력도 가능하면 각 유스케이스에 맞게 구체적일수록 좋다
출력은 호출자에게 꼭 필요한 데이터만 들고 있어야 한다
출력 모델을 공유하는 것은 지양해야 한다
유스케이스들 간에 출력 모델을 공유하게 되면 유스케이스들도 강하게 결합된다
한 유스케이스에서 출력 모델에 새로운 필드가 필요해지면 이 값과 관련이 없는 다른 유스케이스에서도 해당 필드를 처리해야 한다
공유 모델은 장기적으로 봤을 때 갖가지 이유로 점점 커지게 돼 있다
단일 책임 원칙을 적용하고 모델을 분리해서 유지하는 것은 유스케이스의 결합을 제거하는데 도움이 된다
도메인 엔티티를 출력 모델로 사용하는 것도 지양해야 한다
도메인 엔티티를 변경할 이유가 필요 이상으로 늘어난다
비즈니스 규칙 검증
vs 입력 유효성 검증
입력 유효성 검증은 유스케이스 로직의 일부가 아닌 반면, 비즈니스 규칙 검증은 유스케이스 로직의 일부이다
입력 유효성 검증은 @NotNull 애너테이션을 붙이는 것처럼 선언적으로 구현할 수 있지만, 비즈니스 규칙을 검증하는 일은 맥락이 필요하다.
입력 유효성 검증 : 구문상(syntatical)의 유효성을 검증하는 일
비즈니스 규칙 검증 : 의미적인(semantical) 유효성을 검증하는 일
구분법 : 비즈니스 규칙을 검증하는 것은 도메인 모델의 현재 상태에 접근해야 하는 반면, 입력 유효성 검증은 그렇지 않다.
이러한 구분은 논쟁의 여지가 있지만, 현재 모델 상태에 접근하는 여부만으로 코드 상의 위치를 결정할 수 있기 때문에 처음 규칙을 구현할 때 뿐만 아니라 향후 유지보수에도 도움이 된다.
비즈니스 규칙 검증 구현
비즈니스 규칙 검증을 구현하는 가장 좋은 방법은 비즈니스 규칙을 도메인 엔티티 안에 넣는 것이다.
package buckpal.domain;
public class Account {
// 생략
public boolean withdraw(Money money, AccountId targetAccountId) {
// 비즈니스 규칙 검증 : 출금 계좌는 초과인출되어서는 안된다
if (!mayWithdraw(money)) {
return false;
}
// 생략
}
}
이처럼 비즈니스 로직 바로 옆에 규칙을 위치시킨다면 위치를 정하는 것도 쉽고 추론하기도 쉽다
package buckpal.application.service;
public class SendMoneyService implements SendMoneyUseCase {
// 생략
@Override
public boolean sendMoney(SendMoneyCommand command) {
requireAccountExists(command.getSourceAccountId());
requireAccountExists(command.getTargetAccountId());
// 생략
}
// 생략
}
유스케이스 코드에서 도메인 엔티티를 사용하기 전에 비즈니스 규칙을 검증해도 된다
풍부한 도메인 모델 vs 빈약한 도메인 모델
풍부한 도메인 모델
DDD 철학을 따르는 모델
애플리케이션 코어에 있는 엔티티에서 가능한 한 많은 도메인 로직이 구현됨
유스케이스는 도메인 모델의 진입점으로 사용자의 의도만을 표현하고, 이 의도를 실제 작업을 수행하는 체계화된 도메인 엔티티 메서드 호출로 변환한다.
많은 비즈니스 규칙이 유스케이스 구현체 대신 엔티티에 위치하게 된다
빈약한 도메인 모델
빈약한 도메인 모델에서는 엔티티 자체가 굉장히 얇다
일반적으로 엔티티는 상태를 표현하는 필드와 이 값을 읽고 변경하기 위한 getter, setter 메서드만 포함하고 어떠한 도메인 로직도 가지고 있지 않다
도메인 로직은 엔티티가 아닌 유스케이스 클래스에 구현되어 있다
비즈니스 규칙을 검증하고, 엔티티의 상태를 바꾸고, 아웃고잉 포트에 엔티티를 전달할 책임 역시 유스케이스 클래스에 있다
이러한 스타일은 각자의 필요에 맞춰 자유롭게 택해서 사용하면 된다
쿼리 vs 유스케이스
UI에 계좌의 잔액을 표시해야 하는 읽기 전용 작업을 유스케이스로 구현하는 것은 조금 이상하다
애플리케이션 코어의 관점에서 해당 작업은 간단한 데이터 쿼리다. 따라서, 프로젝트 맥락에서 유스케이스로 간주되지 않는다면 실제 유스케이스와 구분하기 위해 쿼리로 구현할 수 있다
이 책의 아키텍처 스타일에서 이를 구현하는 방법은 쿼리를 위한 인커밍 전용 포트를 만들고 이를 쿼리 서비스에 구현하는 것이다
package buckpal.application.service;
@RequiredArgsConstructor
class GetAccountBalanceService implements GetAccountBalanceQuery {
private final LoadAccountPort loadAccountPort;
@Override
public Money getAccountBalance(AccountId accountId) {
return loadAccountPort.loadAccount(accountId, LocalDateTime.now())
.calculateBalance();
}
}
이처럼 읽기 전용 쿼리는 쓰기가 가능한 유스케이스(또는 커맨드)와 코드 상에서 명확하게 구분된다
이러한 방식은 CQS(Command-Query Separation)나 CQRS(Command Query Responsibility Segregation) 같은 개념과 잘 맞는다
정리
입출력 모델을 독립적으로 모델링한다면 원치 않는 부수효과를 피할 수 있다.
유스케이스별로 모델을 만들면 유스케이스를 명확하게 이해할 수 있고, 장기적으로 유지보수하기도 더 쉽다.
또한, 여러 명의 개발자가 다른 사람이 작업 중인 유스케이스를 건드리지 않은 채로 여러 개의 유스케이스를 동시에 작업할 수 있다.
꼼꼼한 입력 유효성 검증, 유스케이스별 입출력 모델은 지속 가능한 코드를 만드는 데 큰 도움이 된다.