Code

[도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지] 7. 도메인 서비스

noahkim_ 2025. 6. 19. 19:59

최범균 님의 "도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지" 책을 정리한 포스팅 입니다.

 

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

  • 도메인 영역의 코드를 작성하다 보면, 한 애그리거트로 기능을 구현할 수 없을 때가 있음
  • ✅ 도메인 / 애플리케이션 서비스에서 처리하기
  • ❌ 여러 애그리거트의 데이터/규칙을 한 번에 써야하는 유스케이스는, 한 애그리거트에 집어넣지 말기

 

예) Order - 결제 금액 계산

더보기

주문을 확정하려면 주문 애그리거트는 외부 애그리거트 정보를 읽어와서 계산하고, 결과 스냅샷만 자기 내부에 기록함

애그리거트 주문 시 필요한 정보(읽기) 소유/책임 비고
Product 상품 가격, 판매 가능 여부, 카테고리 상품 카탈로그/정책
가격은 주문 시점 스냅샷으로 Order에 저장
Coupon/Promotion 쿠폰 유효성, 할인 방식(정액/정율),
중복/제약(카테고리 등)
할인 도메인
적용 결과(할인내역)를 스냅샷으로 기록
User/Member 회원 등급/상태, 등급별 추가 할인 여부 회원 도메인
개인화 정책 판단용 읽기
Shipping/Policy 배송비 규칙(무료 조건, 지역/금액별) 배송/정책 도메인
계산된 배송비를 스냅샷으로 기록
// 포트(외부 호출 인터페이스) - 도메인이 아는 건 '계약'뿐
public interface ProductPricingPort {
    Map<ProductId, Money> getPrices(Set<ProductId> ids);
}
public interface MemberTierPort {
    MemberTier getTier(UserId userId);
}

// 순수 도메인 서비스(계산 전용, I/O 없음)
public final class PricingCalculator {
    public Pricing calc(List<OrderLineReq> lines,
                        Map<ProductId, Money> prices,
                        MemberTier tier,
                        ShippingPolicy policy) {
        // 할인/배송비/총액 계산…
    }
}

// 애그리거트(네트워크 X, 자기 상태만)
public class Order {
    public static Order place(UserId userId, List<OrderLine> lines, Pricing snapshot) {
        // 불변식 검증 + 스냅샷 적용
    }
}

// 애플리케이션 서비스(오케스트레이션 담당)
@Transactional
public class OrderAppService {
    private final ProductPricingPort pricingPort;   // 인프라 어댑터가 구현
    private final MemberTierPort memberPort;
    private final PricingCalculator calculator;
    private final OrderRepository orders;

    public OrderId place(PlaceOrderCmd cmd) {
        var prices = pricingPort.getPrices(cmd.productIds());  // 네트워크 호출(포트)
        var tier   = memberPort.getTier(cmd.userId());         // 네트워크 호출(포트)
        var pricing = calculator.calc(cmd.lines(), prices, tier, ShippingPolicy.current());
        var order = Order.place(cmd.userId(), toLines(cmd), pricing.snapshot());
        orders.save(order);
        // outbox 이벤트 발행 등
        return order.getId();
    }
}

 

2. 도메인 서비스

  • 도메인 영역에 위치한 도메인 로직을 표현할 때 사용함

 

도메인 서비스

  • 여러 애그리거트가 필요한 계산 로직 담당
  • 도메인 개념을 명시적으로 드러내기 (상태 없이 로직만 구현)

 

예시) Order - 결제 금액 계산

더보기
// 도메인 포트: 외부 값을 '읽기'로만 받기 위한 계약
public interface PricingPolicyPort {
    BigDecimal getDiscountRate(MemberTier tier);   // 읽기 전용
}

// 도메인 서비스: 순수 계산(부작용 없음)
public final class PricingCalculator {
    private final PricingPolicyPort policy; // 포트에만 의존
    public Money calc(List<OrderLine> lines, MemberTier tier, Money shippingFee) {
        var subtotal = lines.stream().map(OrderLine::subtotal).reduce(Money.zero(), Money::plus);
        var rate = policy.getDiscountRate(tier);    // 읽기
        var discount = subtotal.percent(rate);
        return subtotal.minus(discount).plus(shippingFee);
    }
}

 

애플리케이션 서비스

  • 유스케이스 오케스트레이션 담당
  • 구현하기 위해 외부 시스템을 연동이 필요한 도메인 로직에 사용됨

 

예시) Order - 결제 금액 계산

더보기
@Transactional
public class OrderAppService {
    private final ProductReader productReader;   // 외부/다른 애그리거트 조회
    private final MemberReader memberReader;
    private final ShippingPolicyProvider policyProvider;
    private final PricingCalculator calculator; // 도메인 서비스
    private final OrderRepository orders;

    public OrderId place(PlaceOrderCmd cmd) {
        var prices = productReader.loadPrices(cmd.productIds());  // 외부 조회(I/O)
        var tier   = memberReader.loadTier(cmd.userId());         // 외부 조회(I/O)
        var ship   = policyProvider.current();                    // 외부 조회(I/O)

        var lines = toOrderLines(cmd.lines(), prices);            // 도메인 입력 준비
        var total = calculator.calc(lines, tier, ship.fee());     // 순수 계산
        var order = Order.place(cmd.userId(), lines, total.snapshot());

        orders.save(order);
        // 결제 승인/재고 예약 등 '부작용'은 여기서 커맨드/사가로 처리
        return order.getId();
    }
}

 

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

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