도메인 주도 개발 시작하기 - 7. 도메인 서비스

 Date: 2022-10-24

7.1 여러 애그리거트가 필요한 기능
7.2 도메인 서비스

7.1 여러 애그리거트가 필요한 기능

  • 도메인 영역의 코드를 작성하다 보면, 한 애그리거트로 기능을 구현할 수 없을 때가 있다.
  • 결제 금액 계산 로직
    • 상품 애그리거트: 구매하는 상품의 가격, 배송비 추가 여부
    • 주문 애그리거트: 상품별 구매 개수
    • 할인 쿠폰 애그리거트: 쿠폰별 할인 금액이나 비율에 따른 할인, 할인 쿠폰 사용 조건
    • 회원 애그리거트: 회원 등급에 따른 할인
  • 한 애그리거트에 넣기 애매한 도메인 기능을 억지로 특정 애그리거트에 구현하면 안된다.
    • 책임 범위를 넘어서는 기능을 구현하면 코드가 길어지고, 외부에 대한 의존이 높아지게 된다.
    • 책임 범위를 넘어서는 도메인 개념이 애그리거트에 숨어들어 명시적으로 드러나지 않게 된다.

    → 이에 대한 가장 쉬운 해결 방법은 도메인 기능을 별도 서비스로 구현하는 것이다.

7.2 도메인 서비스

  • 도메인 서비스는 도메인 영역에 위치한 도메인 로직을 표현할 때 사용한다.
    • 계산 로직: 여러 애그리거트가 필요한 계산 로직이나, 한 애그리거트에 넣기에는 다소 복잡한 계산 로직
    • 외부 시스템 연동이 필요한 도메인 로직: 구현하기 위해 타 시스템을 사용해야 하는 도메인 로직

7.2.1) 계산 로직과 도메인 서비스

  • 응용 영역의 서비스가 응용 로직을 다룬다면, 도메인 서비스는 도메인 로직을 다룬다.
  • 도메인 영역의 애그리거트나 밸류와 같은 구성요소와 도메인 서비스를 비교할 때 다른 점은 도메인 서비스는 상태 없이 로직만 구현한다는 점이다.
  • 할인 금액 계산 로직을 위한 도메인 서비스

      public class DiscountCalculationService {
          public Money calculateDiscountAmounts(List<OrderLint> 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(mambershipDiscount);
          }
        
          private Money calculateDiscount(Coupon coupon) { ... }
        
          private Money calculateDiscount(MemberGrade grade) { ... }
      }
    
  • 할인 금액 계산 서비스를 사용하는 주체는 애그리거트가 될 수도 있고, 응용 서비스가 될 수도 있다.
  • 방법 1) 애그리거트가 도메인 서비스를 의존하는 방법

      public class Order {
          public void calculateAmounts(DiscountCalculationService service,
                                       MemberGrade grade) {
              Money totalAmounts = getTotalAmounts();
              Money discountAmounts = service.calculateDiscountAmounts(this.orderLines, this.coupons, grade);
              this.paymentAmounts = totalAmounts.minus(discountAmounts);
          }
          ...
      }
    
    • 애그리거트 객체에 도메인 서비스를 전달하는 것은 응용 서비스의 책임이다.

      public class OrderService {
          private DiscountCalculationService service;
          
          @Transactional
          public OrderNo placeOrderer(OrderRequest orderRequest) {
              OrderNo orderNo = orderRepository.nextId();
              Order order = createOrder(orderNo, orderRequest);
              orderRepository.save(order);
              // 응용 서비스 실행 후 표현 영역에서 필요한 값 리턴
              return orderNo;
          }
          
          private Order createOrder(OrderNo orderNo, OrderRequest orderReq) {
              Member member = findMember(orderReq.getOrdererId());
              Order order = new Order(OrderNo, orderReq.getOrderLines(), 
                                      orderReq.getCoupons(), createOrderer(member),
                                      orderReq.getShippingInfo());
              order.calculateAmounts(this.service, member.getGrade());
              return order;
          }
          ...
      }
      

    💡 도메인 서비스 객체를 애그리거트에 주입하지 않기
    애그리거트의 메서드 파라미터에 도메인 서비스를 전달하는 것은 애그리거트가 도메인 서비스를 의존한다는 것을 의미한다.
    → 그렇다고 애그리거트에 도메인 서비스에 대한 참조를 필드를 추가하여 의존성을 주입하는 것은 좋은 방법이 아니다.

  • 방법 2) 도메인 서비스의 기능 애그리거트를 전달
    • 대표적으로 계좌 이체 기능은 두 계좌 애그리거트를 도메인 서비스에 전달하여, 출금과 입금을 처리한다.

      public class TransferService {
          public void transfer(Account fromAcc, Account toAcc, Money amounts) {
              fromAcc.withDraw(amounts);
              toAcc.credit(amounts);
          }
          ...
      }
      
    • 도메인 서비스는 도메인 로직을 수행하지 응용 로직을 수행하지는 않는다.
    • 트랜잭션 처리와 같은 로직은 응용 로직이므로 도메인 서비스가 아닌 응용 서비스에서 처리해야 한다.

      💡 특정 기능이 응용 서비스인지 도메인 서비스인지 감을 잡기 어려울 때는 해당 로직이 애그리거트의 상태를 변경하거나 애그리거트의 상태 값을 계산하는지 검사해보면 된다.
      → 도메인 로직이면서 한 애그리거트에 넣기에 적합하지 않다면 도메인 서비스로 구현하면 된다.

7.2.2) 외부 시스템 연동과 도메인 서비스

  • 외부 시스템이나 타 도메인과의 연동 기능도 도메인 서비스가 될 수 있다.

      public interface SurveyPermissionChecker {
          boolean hasUserCreateionPermission(String userId);
      }
    
    • 여기서 중요한 점음 도메인 로직 관점에서 인터페이스를 작성했다는 것이다.

      public class CreateSurveyService {
          private SurveyPermissionChecker permissionChecker;
              
          public Long createSurvey(CreateSurveyRequest req) {
              validate(req);
              // 도메인 서비스를 이용해서 외부 시스템 연동을 표현
              if (!permissionChecker.hasUserCreateionPermission()) {
                  throw new NoPermissionException();
              }
          }
      }
      
    • SurveyPermissionChecker 인터페이스를 구현한 클래스는 인프라스트럭처 영역에 위치해 연동을 포함한 권한 검사 기능을 구현한다.

7.2.3) 도메인 서비스의 패키지 위치

  • 도메인 서비스의 위치는 다른 도메인 구성요소와 동일한 패키지에 위치한다.
  • 도메인 패키지 하위에 domain.model, domain.service, domain.repository와 같이 구분해도 된다.

7.2.4) 도메인 서비스의 인터페이스와 클래스

  • 도메인 서비스의 로직이 고정되어 있지 않은 경우, 도메인 서비스 자체를 인터페이스로 구현하고 이를 구현한 클래스를 둘 수도 있다.
    • 도메인 로직을 외부 시스템이나 별도 엔진을 이용해서 구현할 때 인터페이스와 클래스를 분리하게 된다.
    • 예를 들어, 할인 금액 계산 로직을 룰 엔진을 이용해서 구현한다면, 도메인 영역에는 서비스 인터페이스가 위치하고 실제 구현은 인프라스트럭처 영역에 위치한다.
  • 도메인 서비스의 구현이 특정 구현 기술에 의존하거나 외부 시스템의 API를 실행한다면, 도메인 영역의 도메인 서비스는 인터페이스로 추상화해야 한다.