도메인 영역의 코드를 작성하다 보면, 한 애그리거트로 기능을 구현할 수 없을 때가 있다.
대표적인 예가 결제 금액 계산 로직이다.
실제 결제 금액을 계산하기 위해 다음과 같은 내용이 필요하다.
이 상황에서 실제 결제 금액을 계산해야 하는 주체는 어떤 애그리거트일까?
생각해볼 수 있는 방법은 주문 애그리거트가 필요한 데이터를 모두 갖도록 한 뒤 할인 금액 계산 책임을 주문 애그리거트에 할당하는 것이다.
그렇다면 결제 금액 계산 로직은 주문 애그리거트의 책임이 맞을까?
예를 들어, 특별 감사 세일로 전 품목에 대해 2% 추가 할인이 적용된다면?
이 할인 정책은 주문 애그리거트가 갖는 구성요소와 관련이 없음에도 불구하고 주문 애그리거트의 코드를 수정해야 한다.
이렇게 한 애그리거트에 넣기 애매한 도메인 기능을 억지로 특정 애그리거트에 구현하면 안 된다. 억지로 구현하면 애그리거트는 자신의 책임 범위를 넘어서는 기능을 구현하기 때문에 외부에 대한 의존성을 높이고 코드를 복잡하게 만들어 수정이 어려워진다.
애그리거트 범위를 넘어서는 도메인 개념이 애그리거트에 숨어들어 명시적으로 드러나지 않게 된다.
이런 문제를 해소하는 가장 쉬운 방법이 도메인 기능을 별도 서비스로 구현하는 것이다!
도메인 서비스는 도메인 영역에 위치한 도메인 로직을 표현할 때 사용한다.
주로 다음 상황에서 도메인 서비스를 사용한다.
한 애그리거트에 넣기 애매한 도메인 개념을 구현하려면 애그리거트에 억지로 넣지 말고, 도메인 서비스를 이용해서 도메인 개념을 명시적으로 드러내면 좋다.
응용 영역의 서비스가 응용 로직을 다룬다면, 도메인 서비스는 도메인 로직을 다룬다.
도메인 영역의 애그리거트나 밸류와 같은 구성요소와 도메인 서비스의 차이점은 도메인 서비스는 상태 없이 로직만 구현한다는 점이다.
도메인 서비스를 구현하는 데 필요한 상태는 다른 방법으로 전달받는다.
public class DiscountCalculationService {
public Money calculateDiscountAmounts(
List<OrderLine> orderLines,
List<Coupon> coupons,
MemberGrade grade) {
Money couponDiscount = coupons.stream()
.map(coupon -> calculateDiscount(coupon))
.reduce(Money(0), (v1, v2) -> v1.add(v2));
Money memberShipDiscount = calculateDiscount(orderer.getMember().getGrade());
return couponDiscount.add(memberShipDiscount);
}
private Money calculateDiscount(Coupon coupon) {...}
private Money calculateDiscount(MemberGrade grade) {...}
}
할인 계산 서비스를 사용하는 주체는 애그리거트가 될 수도, 응용 서비스가 될 수도 있다.
DiscountCalculationService를 애그리거트의 결제 금액 계산 기능에 전달하면 사용 주체는 애그리거트가 된다.
애그리거트 객체에 도메인 서비스를 전달하는 것은 응용 서비스의 책임이다.
public class OrderService {
private DiscountCalculationService discountCalculationService;
@Transactional
public OrderNo placeOrder(OrderRequest request) {
...
Order orderer = createOrder(orderNo, req);
...
}
private Order createOrder(OrderNo orderNo, OrderRequest req) {
Member member = findMember(req.getOrdererId());
Order order = new Order(orderNo, req.getORderLines(), req.getCoupons(), createOrderer(member), ...);
order.calculateAmounts(this.discountCalculationService, member.getGrade());
return order;
}
...
}
도메인 서비스 객체를 애그리거트에 주입하지 않기
애그리거트의 메서드를 실행할 때 도메인 서비스 객체를 파라미터로 전달한다는 것은 애그리거트가 도메인 서비스에 의존한다는 것을 의미한다. 스프링 DI와 AOP를 공부하다보면 애그리거트가 의존하는 도메인 서비스를 의존 주입으로 처리하고 싶을 수 있다. 하지만 개인적으로 이것은 좋은 방법이 아니라고 생각한다. 도메인 객체는 필드로 구성된 데이터와 메서드를 이용해 개념적으로 하나인 모델을 표현한다. 모델의 데이터를 담는 필드는 모델에서 중요한 구성요소다. 그런데 discountCalculateService 필드는 데이터 자체와 관련이 없으며, 다른 필드와 달리 저장 대상도 아니다. 또한 Order가 제공하는 모든 기능에서 필요로 하는 것도 아니다. 일부 기능을 위해 굳이 도메인 서비스 객체를 애그리거트에 의존 주입할 이유는 없다.
애그리거트 메서드를 실행할 때 반대로 도메인 서비스의 기능을 실행할 때 애그리거트를 전달하기도 한다.
대표적인 예가 계좌 이체 기능이다.
계좌 이체는 두 계좌 애그리거트가 관여하는데, 한 애그리거트는 출금을, 한 애그리거트는 입금을 수행한다.
응용 서비스는 두 계좌 애그리거트를 구해서 해당 도메인 영역의 TransferService를 이용해 계좌 이체 도메인 기능을 수행할 것이다.
도메인 서비스는 도메인 로직을 수행하지, 응용 로직을 수행하진 않는다.
트랜잭션 처리와 같은 로직은 응용 로직으로써 응용 서비스에서 처리해야 한다.
특정 기능이 응용 서비스인지 도메인 서비스인지 감을 잡기 어려울 때는 해당 로직이 애그리거트의 상태를 변경하거나 상태 값을 계산하는지 검사해보자. 계좌 이체 로직은 계좌 애그리거트의 상태를 변경한다. 결제 금액 로직은 주문 애그리거트의 주문 금액을 계산한다. 이 로직들은 각각 애그리거트를 변경하고 값을 계산하는 도메인 로직이다. 도메인 로직이면서 한 애그리거트에 넣기 적합하지 않으므로 도메인 서비스로 구현하게 된다.
외부 시스템이나 타 도메인과의 연동 기능도 도메인 서비스가 될 수 있다.
예를 들어 설문 조사 시스템과 사용자 역할 관리 시스템이 분리되어 있다고 가정하자.
설문 조사 시스템은 설문 생성 시, 사용자가 생성 권한이 있는지 확인하기 위해 사용자 역할 관리 시스템과 연동해야 한다.
시스템 간 연동은 HTTP API 호출로 이루어질 수 있지만, 설문 조사 도메인 입장에서는 사용자의 권한을 확인하는 도메인 로직으로 볼 수 있다.
이 도메인 로직은 도메인 서비스로 표현할 수 있다.
여기서 중요한 점은 도메인 로직 관점에서 인터페이스를 작성한 것이지, 역할 관리 시스템과의 연동 관점이 아니다!
public interface SurveyPermissionChecker {
boolean hasUserCreationPermission(String userId);
}
응용 서비스는 이 도메인 서비스를 이용해서 생성 권한을 검사한다.
SurveyPermissionChecker 구현 클래스는 인프라스트럭처 영역에 위치해서 연동을 포함한 권한 검사 기능을 구현한다.
도메인 서비스는 도메인 로직을 표현하므로 도메인 서비스는 다른 도메인 구성요소와 동일한 패키지에 위치한다.
예를 들어 주문 금액 계산을 위한 도메인 서비스는 주문 애그리거트와 같은 패키지에 위치한다.
도메인 서비스의 개수가 많거나 다른 구성요소와 명시적으로 구분하고 싶다면 domain 패키지 밑에 하위 패키지를 구분해 위치시킬 수 있다.
도메인 서비스의 로직이 고정되어 있지 않은 경우, 도메인 서비스 자체를 인터페이스로 구현하고 구현 클래스를 둘 수도 있다.
특히 도메인 로직을 외부 시스템이나 별도 엔진을 이용해서 구현할 때 인터페이스와 클래스를 분리하게 된다.
예를 들어, 할인 금액 계산 로직을 룰 엔진을 이용해서 구현한다면?
도메인 영역에는 도메인 서비스 인터페이스가, 실제 구현 클래스는 인프라스트럭처 영역에 위치한다.
도메인 서비스의 구현이 특정 구현의 기술에 의존하거나 외부 시스템의 API를 실행한다면 도메인 서비스는 인터페이스로 추상화해야 한다.
이를 통해 도메인 영역이 특정 구현에 종속되는 것을 방지할 수 있고, 테스트가 쉬워진다.