도메인 주도 개발 스터디

Chapter 7 도메인 서비스

막이86 2023. 11. 8. 17:41
728x90

도메인 주도 개발 시작하기 7장을 요약한 내용입니다.

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

  • 한 애그리거트로 기능을 구현할 수 없을 때가 있다.
  • 결제 금액을 계산할 때 필요한 내용
    • 상품 애그리거트: 구매하는 상품의 가격이 필요하다. 상품에 따라 배송비가 추가되기도 한다.
    • 주문 애그리거트: 상품별로 구매 개수가 필요하다.
    • 할인 쿠폰 애그리거트: 쿠폰별로 지정한 할인 금액이나 비율에 따라 주문 총 금액을 할인한다. 할인 쿠폰을 조건에 따라 중복 사용할 수 있다거나 지정한 카테고리의 상품에만 적용할 수 있다는 제약 조건이 있다면 할인 계산이 복잡해지낟.
    • 회원 애그리거트: 회원 등급에 따라 추가 할인이 가능하다.
  • 결제 금액을 계산해야 하는 주체는 어떤 애그리거트일까?
    • 총 주문 금액에서 할인 금액을 계산해야 하는데 이 할인 금액을 구하는 것은 누구 책임일까?
    • 할인 쿠폰이 할인 규칙을 갖고 있으니 할인 쿠폰 애그리거트가 계산해야 할까?
  • 주문 애그리거트가 필요한 데이터를 모두 가지도록 한 뒤 할인 금액 계산 책임을 주문 애그리거트에 할당
    • 결제 금액 계산 로직이 주문 애그리거트의 책임이 맞을까?
    • 할인 정책 추가로 인한 수정으로 주문 애그리거트의 코드 수정을 해야할 수도…
    public class Order {
    	private Orderer orderer;
    	private List<OrderLine> orderLines;
    	private List<Coupon> usedCoupons;
    
    	private Money calculatePayAmounts() {
    		Money totalAmounts = calculateTotalAmounts();
    		Money discount = coupons.stream()
    											.map(coupon -> calculateDiscount(coupon))
    											.reduce(Money(0), (v1, v2) -> v1.add(v2));
    		Money membershipDiscount = calculateDiscount(orderer.getMember().getGrade());
    		return totalAmounts.minus(discount).minus(membershipDiscount);
    	}
    
    	private Money calculateDiscount(Coupon coupon) {
    		// orderLines의 각 상품에 대해 쿠폰을 적용해서 할인 금액 계산하는 로직.
    		// 쿠폰의 적용 조건 등을 확인하는 코드
    		// 정책에 따라 복잡한 if-else와 계산 코드 
    	}
    
    	private Money calculateDiscount(MemberGrade grade) {
    		// 등급에 따라 할인 금액 계산
    	}
    
    }
    
  • 한 애그리거트에 넣기 애매한 도메인 기능을 억지로 특정 애그리거트에 구현하면 안된다.
    • 자신의 책임 범위를 넘어서는 기능을 구현하기 때문에 코드가 길어지고 외부에 대한 의존이 높아지게 됨
    • 코드를 복잡하게 만들어 수정이 어렵다
  • 도메인 기능을 별도 서비스로 구현하는 것이 좋다.

7.2 도메인 서비스

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

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

  • 할인 금액 규칙 계산처럼 한 애그리거트에 넣기 애매한 도메인 개념을 구현하려면 애그리거트에 억지로 넣기보다는 도메인 서비스를 이용해서 도메인 개념을 명시적으로 드러내면 된다.
  • 도메인 서비스는 상태 없이 로직만 구현한다.
  • 할인 금액 계산 로직을 위해 도메인 서비스는 도메인의 의미가 드러나는 용어를 타입과 메서드 이름을 갖는다.
  • 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) { // orderLines의 각 상품에 대해 쿠폰을 적용해서 할인 금액 계산하는 로직. // 쿠폰의 적용 조건 등을 확인하는 코드 // 정책에 따라 복잡한 if-else와 계산 코드 } private Money calculateDiscount(MemberGrade grade) { // 등급에 따라 할인 금액 계산 } }
  • 할인 계산 서비스를 사용하는 주체는 애그리거트가 될 수도 있고 응용 서비스가 될 수도 있다.
  • public class Order { public void cacluateAmounts(DiscountCalculationService disCalSvc, MemberGrade grade) { Money totalAmounts = getTotalAmounts(); Money discountAmounts = disCalSvc.calculateDiscountAmount(this.orderLines, this.coupons, grade); this.paymentAmounts = totalAmounts.minus(discountAmounts); } }
  • 애그리거트 객체에 도메인 서비스를 전달하는 것은 응용 서비스 책임
  • public class OrderService { private DiscountCalculationService discountCalculationService; @Transactional public OrderNo placeOrder(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.discountCalculationService, member.getGrade()); return order; } }
  • 애그리거트 메서드를 실행할 때 도메인 서비스를 인자로 전달하지 않고 도메인 서비스의 기능을 실행할 때 애그리거트를 전달하기도 한다.
    • 계좌 이체에는 두 계좌 애그리거트가 관여하는데 한 애그리거트는 금액을 출금하고 한 애그리거트는 금액을 입금한다.
    • 응용 서비스는 두 Account 애그리거트를 구한뒤 계좌 이체 도메인 기능을 실행
    public class TransferService {
    	public void transfer(Account fromAcc, Account toAcc, Money amounts) {
    		fromAcc.withdraw(amounts);
    		toAcc.credit(amounts)
    	}
    }
    
  • 도메인 서비스는 도메인 로직을 수행하지 응용 로직을 수행하진 않는다.
    • 트랜잭션 처리와 같은 로직은 응용 로직이므로 응용 서비스에서 처리 해야한다.

<aside> 👉 특정 기능이 응용 서비스인지 도메인 서비스인지 감을 잡기 어려울 때는 해당 로직이 애그리거트의 상태를 변경하거나 애그리거트의 상태 값을 계산하는지 검사해 보면 된다.

</aside>

Note: 도메인 서비스 객체를 애그리거트에 주입하지 않기

  • 애그리거트의 메서드를 실행할 때 도메인 서비스 객체를 파라미터로 전달한다는 것은 애그리거트가 도메인 서비스에 의존한다는 것을 의미
  • public class Order { @Autowired private DiscountCalculationService discountCalculationService; ... }
  • 도메인 객체는 필드로 구성된 데이터와 메서드를 이용해서 개념적으로 하나인 모델을 표현
    • discountCalculationService 필드는 데이터 자체와는 관련이 없음
    • Order 객체를 DB에 보관할 때 다른 필드와 달리 저장 대상이 아님
  • Order가 제공하는 모든 기능에서 discountCalculationService를 필요로 하는 것은 아님
    • 일부 기능만 필요로함
  • 일부 기능을 위해 도메인 서비스 객체를 애그리거트에 의존 주입할 이유는 없음

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

  • 외부 시스템이나 타 도메인과의 연동 기능도 도메인 서비스가 될수 있다.
  • 설문 조사 시스템과 사용자 역활 관리 시스템이 분리되어 있을 때
    • 설문 조사 시스템은 설문 조사를 생성할 때 사용자가 생성 권한을 확인하기 위해 역활 관리 시스템과 연동 해야함
    • 시스템 간 연동은 HTTP API 호출로 이루어질 수 있지만 설문 조사 도메인 입장에서는 사용자가 설문 조사 생성 권한을 가졌는지 확인하는 도메인 로직으로 볼수 있다.
    • 도메인 로직 관점에서 역할 관리 시스템과 연동하는 관점으로 인터페이스를 작성
    public interface SurveyPermissionChecker {
    	boolean hasUserCreationPermission(String userId);
    }
    
    • 응용 서비스는 도메인 서비스를 이용해서 생성 권한을 검사
    public class CreateSurveyService {
    	private ServeyPermissionChecker permissionChecker;
    
    	public Long createSurvey(CreateSurveyRequest req) {
    		validate(req);
    		if (!permissionChecker.hasUserCreationPermission(req.getRequestorId()) {
    			throw new NoPermissionException();
    		}
    	}
    }
    

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

  • 도메인 서비스는 도메인 로직을 표현하므로 도메인 서비스의 위치는 다른 도메인 구성요소와 동일한 패키지에 위치
  • 도메인 서비스의 개수가 많거나 엔티티나 밸류와 같은 다른 구성요소와 명시적으로 구분하고 싶다면 domain 패키지 밑에 하위 패키지를 구분하여 위치시켜도 된다.
    • domain.model
    • domain.service
    • domain.repository

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

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

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

  • 한 애그리거트로 기능을 구현할 수 없을 때가 있다.
  • 결제 금액을 계산할 때 필요한 내용
    • 상품 애그리거트: 구매하는 상품의 가격이 필요하다. 상품에 따라 배송비가 추가되기도 한다.
    • 주문 애그리거트: 상품별로 구매 개수가 필요하다.
    • 할인 쿠폰 애그리거트: 쿠폰별로 지정한 할인 금액이나 비율에 따라 주문 총 금액을 할인한다. 할인 쿠폰을 조건에 따라 중복 사용할 수 있다거나 지정한 카테고리의 상품에만 적용할 수 있다는 제약 조건이 있다면 할인 계산이 복잡해지낟.
    • 회원 애그리거트: 회원 등급에 따라 추가 할인이 가능하다.
  • 결제 금액을 계산해야 하는 주체는 어떤 애그리거트일까?
    • 총 주문 금액에서 할인 금액을 계산해야 하는데 이 할인 금액을 구하는 것은 누구 책임일까?
    • 할인 쿠폰이 할인 규칙을 갖고 있으니 할인 쿠폰 애그리거트가 계산해야 할까?
  • 주문 애그리거트가 필요한 데이터를 모두 가지도록 한 뒤 할인 금액 계산 책임을 주문 애그리거트에 할당
    • 결제 금액 계산 로직이 주문 애그리거트의 책임이 맞을까?
    • 할인 정책 추가로 인한 수정으로 주문 애그리거트의 코드 수정을 해야할 수도…
    public class Order {
    	private Orderer orderer;
    	private List<OrderLine> orderLines;
    	private List<Coupon> usedCoupons;
    
    	private Money calculatePayAmounts() {
    		Money totalAmounts = calculateTotalAmounts();
    		Money discount = coupons.stream()
    											.map(coupon -> calculateDiscount(coupon))
    											.reduce(Money(0), (v1, v2) -> v1.add(v2));
    		Money membershipDiscount = calculateDiscount(orderer.getMember().getGrade());
    		return totalAmounts.minus(discount).minus(membershipDiscount);
    	}
    
    	private Money calculateDiscount(Coupon coupon) {
    		// orderLines의 각 상품에 대해 쿠폰을 적용해서 할인 금액 계산하는 로직.
    		// 쿠폰의 적용 조건 등을 확인하는 코드
    		// 정책에 따라 복잡한 if-else와 계산 코드 
    	}
    
    	private Money calculateDiscount(MemberGrade grade) {
    		// 등급에 따라 할인 금액 계산
    	}
    
    }
    
  • 한 애그리거트에 넣기 애매한 도메인 기능을 억지로 특정 애그리거트에 구현하면 안된다.
    • 자신의 책임 범위를 넘어서는 기능을 구현하기 때문에 코드가 길어지고 외부에 대한 의존이 높아지게 됨
    • 코드를 복잡하게 만들어 수정이 어렵다
  • 도메인 기능을 별도 서비스로 구현하는 것이 좋다.

7.2 도메인 서비스

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

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

  • 할인 금액 규칙 계산처럼 한 애그리거트에 넣기 애매한 도메인 개념을 구현하려면 애그리거트에 억지로 넣기보다는 도메인 서비스를 이용해서 도메인 개념을 명시적으로 드러내면 된다.
  • 도메인 서비스는 상태 없이 로직만 구현한다.
  • 할인 금액 계산 로직을 위해 도메인 서비스는 도메인의 의미가 드러나는 용어를 타입과 메서드 이름을 갖는다.
  • 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) { // orderLines의 각 상품에 대해 쿠폰을 적용해서 할인 금액 계산하는 로직. // 쿠폰의 적용 조건 등을 확인하는 코드 // 정책에 따라 복잡한 if-else와 계산 코드 } private Money calculateDiscount(MemberGrade grade) { // 등급에 따라 할인 금액 계산 } }
  • 할인 계산 서비스를 사용하는 주체는 애그리거트가 될 수도 있고 응용 서비스가 될 수도 있다.
  • public class Order { public void cacluateAmounts(DiscountCalculationService disCalSvc, MemberGrade grade) { Money totalAmounts = getTotalAmounts(); Money discountAmounts = disCalSvc.calculateDiscountAmount(this.orderLines, this.coupons, grade); this.paymentAmounts = totalAmounts.minus(discountAmounts); } }
  • 애그리거트 객체에 도메인 서비스를 전달하는 것은 응용 서비스 책임
  • public class OrderService { private DiscountCalculationService discountCalculationService; @Transactional public OrderNo placeOrder(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.discountCalculationService, member.getGrade()); return order; } }
  • 애그리거트 메서드를 실행할 때 도메인 서비스를 인자로 전달하지 않고 도메인 서비스의 기능을 실행할 때 애그리거트를 전달하기도 한다.
    • 계좌 이체에는 두 계좌 애그리거트가 관여하는데 한 애그리거트는 금액을 출금하고 한 애그리거트는 금액을 입금한다.
    • 응용 서비스는 두 Account 애그리거트를 구한뒤 계좌 이체 도메인 기능을 실행
    public class TransferService {
    	public void transfer(Account fromAcc, Account toAcc, Money amounts) {
    		fromAcc.withdraw(amounts);
    		toAcc.credit(amounts)
    	}
    }
    
  • 도메인 서비스는 도메인 로직을 수행하지 응용 로직을 수행하진 않는다.
    • 트랜잭션 처리와 같은 로직은 응용 로직이므로 응용 서비스에서 처리 해야한다.

<aside> 👉 특정 기능이 응용 서비스인지 도메인 서비스인지 감을 잡기 어려울 때는 해당 로직이 애그리거트의 상태를 변경하거나 애그리거트의 상태 값을 계산하는지 검사해 보면 된다.

</aside>

Note: 도메인 서비스 객체를 애그리거트에 주입하지 않기

  • 애그리거트의 메서드를 실행할 때 도메인 서비스 객체를 파라미터로 전달한다는 것은 애그리거트가 도메인 서비스에 의존한다는 것을 의미
  • public class Order { @Autowired private DiscountCalculationService discountCalculationService; ... }
  • 도메인 객체는 필드로 구성된 데이터와 메서드를 이용해서 개념적으로 하나인 모델을 표현
    • discountCalculationService 필드는 데이터 자체와는 관련이 없음
    • Order 객체를 DB에 보관할 때 다른 필드와 달리 저장 대상이 아님
  • Order가 제공하는 모든 기능에서 discountCalculationService를 필요로 하는 것은 아님
    • 일부 기능만 필요로함
  • 일부 기능을 위해 도메인 서비스 객체를 애그리거트에 의존 주입할 이유는 없음

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

  • 외부 시스템이나 타 도메인과의 연동 기능도 도메인 서비스가 될수 있다.
  • 설문 조사 시스템과 사용자 역활 관리 시스템이 분리되어 있을 때
    • 설문 조사 시스템은 설문 조사를 생성할 때 사용자가 생성 권한을 확인하기 위해 역활 관리 시스템과 연동 해야함
    • 시스템 간 연동은 HTTP API 호출로 이루어질 수 있지만 설문 조사 도메인 입장에서는 사용자가 설문 조사 생성 권한을 가졌는지 확인하는 도메인 로직으로 볼수 있다.
    • 도메인 로직 관점에서 역할 관리 시스템과 연동하는 관점으로 인터페이스를 작성
    public interface SurveyPermissionChecker {
    	boolean hasUserCreationPermission(String userId);
    }
    
    • 응용 서비스는 도메인 서비스를 이용해서 생성 권한을 검사
    public class CreateSurveyService {
    	private ServeyPermissionChecker permissionChecker;
    
    	public Long createSurvey(CreateSurveyRequest req) {
    		validate(req);
    		if (!permissionChecker.hasUserCreationPermission(req.getRequestorId()) {
    			throw new NoPermissionException();
    		}
    	}
    }
    

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

  • 도메인 서비스는 도메인 로직을 표현하므로 도메인 서비스의 위치는 다른 도메인 구성요소와 동일한 패키지에 위치
  • 도메인 서비스의 개수가 많거나 엔티티나 밸류와 같은 다른 구성요소와 명시적으로 구분하고 싶다면 domain 패키지 밑에 하위 패키지를 구분하여 위치시켜도 된다.
    • domain.model
    • domain.service
    • domain.repository

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

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