mybook

ドメインサービス — エンティティに属さないロジック

「割引の計算、どこに書けばいいんだ」

リナは新機能の実装中に困った。FreshCartの割引ロジックは複雑だ。

  • 会員ランク(ゴールド/シルバー/ブロンズ)による基本割引
  • 注文金額が3,000円以上で送料無料
  • 初回注文には10%割引
  • キャンペーンコードによる追加割引
  • 複数割引の重複適用ルール(割引は積み重ねない、最大割引を適用する)

この計算はどのエンティティに属するのか?

Order に書くと Order が巨大になる——これはFat Modelへの逆戻りだ。Customer に書くとなぜか顧客が注文の割引を計算することになる——それはおかしい。Product は関係ない。

「このロジックはどこにも自然に属さない」

ドメインサービスとは

ドメインサービス(Domain Service)とは、特定のエンティティや値オブジェクトに自然に属さないドメインロジックを担うオブジェクトだ。

ドメインサービスの特徴:

  • ステートレス — 状態を持たない。入力を受け取って結果を返すだけ
  • ドメイン層に属する — インフラへの依存なし(DBアクセスなし)
  • 複数の集約をまたぐ — 単一のエンティティには帰属しない処理
Loading diagram...

INFO

「このロジックはどのエンティティのメソッドにするか迷う」と感じたら、ドメインサービスのサインだ。ただし、安易にサービスに逃げると「貧血ドメインモデル(Anemic Domain Model)」になる。まず「エンティティに自然に属せないか」を確認してから判断する。

ドメインサービス vs 貧血ドメインモデルの罠

ドメインサービスを多用するとドメインオブジェクトが薄くなりすぎる問題がある。

# NG: 貧血ドメインモデル — エンティティに入るべきロジックがサービスに漏れている
class Order
  attr_accessor :status, :items, :total_amount  # ゲッター・セッターだけ
end
 
class OrderService
  def confirm(order)
    # これはOrderエンティティに入るべきビジネスロジック
    raise "Cannot confirm" unless order.status == 'pending'
    raise "No items" if order.items.empty?
    order.status = 'confirmed'
  end
end
 
# OK: エンティティが自分のロジックを持つ
class Order
  def confirm!
    # Orderのビジネスルールはここ
    raise InvalidStateTransition unless pending?
    raise EmptyOrder if order_items.empty?
    @status = OrderStatus::CONFIRMED
  end
end
 
# ドメインサービスは「どこにも属さないロジック」のみ担当
class DiscountCalculationService
  def calculate(order:, customer:, campaign_code: nil)
    # 複数エンティティをまたぐ計算
  end
end

割引計算ドメインサービスの実装

module OrderContext
  class DiscountCalculationService
    # ステートレス: 状態を持たない純粋な計算
    def calculate(order:, customer:, campaign_code: nil)
      discounts = []
 
      # 1. 会員ランク割引
      membership_discount = membership_discount_for(customer, order.subtotal)
      discounts << membership_discount if membership_discount.amount.positive?
 
      # 2. 初回注文割引
      if customer.has_never_ordered?
        first_order_discount = first_order_discount_for(order.subtotal)
        discounts << first_order_discount
      end
 
      # 3. キャンペーンコード割引
      if campaign_code && campaign_code.applicable?(order)
        campaign_discount = campaign_code_discount_for(campaign_code, order.subtotal)
        discounts << campaign_discount
      end
 
      # 4. 重複適用ルール: 会員ランク + キャンペーンは重複しない。大きい方を使う。
      discounts = apply_combination_rules(discounts)
 
      # 5. 送料計算
      shipping_fee = shipping_fee_for(order.subtotal, order.delivery_address)
 
      DiscountResult.new(
        subtotal: order.subtotal,
        discounts: discounts,
        shipping_fee: shipping_fee
      )
    end
 
    private
 
    def membership_discount_for(customer, subtotal)
      rate = case customer.membership_rank
             when :gold   then Rational(10, 100)
             when :silver then Rational(5, 100)
             when :bronze then Rational(3, 100)
             else Rational(0, 100)
             end
 
      DiscountItem.new(
        type: :membership,
        amount: subtotal.multiply(rate),
        description: "#{customer.membership_rank_display}会員割引(#{(rate * 100).to_i}%)"
      )
    end
 
    def first_order_discount_for(subtotal)
      DiscountItem.new(
        type: :first_order,
        amount: subtotal.apply_discount_percentage(10),
        description: '初回注文特典10%割引'
      )
    end
 
    def campaign_code_discount_for(campaign_code, subtotal)
      discount_amount = campaign_code.calculate_discount(subtotal)
      DiscountItem.new(
        type: :campaign,
        amount: discount_amount,
        description: "キャンペーン割引: #{campaign_code.display_code}"
      )
    end
 
    def apply_combination_rules(discounts)
      membership = discounts.find { |d| d.type == :membership }
      campaign   = discounts.find { |d| d.type == :campaign }
      first_order = discounts.find { |d| d.type == :first_order }
      others = discounts.reject { |d| [:membership, :campaign].include?(d.type) }
 
      # ビジネスルール: 会員割引とキャンペーン割引は重複適用しない。大きい方を使う。
      exclusive_discount = if membership && campaign
        membership.amount >= campaign.amount ? membership : campaign
      else
        membership || campaign
      end
 
      ([exclusive_discount, first_order] + others).compact
    end
 
    def shipping_fee_for(subtotal, delivery_address)
      # 無料送料しきい値
      free_threshold = SharedKernel::Money.new(amount: 3_000, currency: :jpy)
      return SharedKernel::Money.new(amount: 0, currency: :jpy) if subtotal >= free_threshold
 
      # 地域別送料
      if delivery_address.remote_area?
        SharedKernel::Money.new(amount: 880, currency: :jpy)
      else
        SharedKernel::Money.new(amount: 550, currency: :jpy)
      end
    end
  end
 
  # 割引の1項目を表す値オブジェクト
  class DiscountItem
    attr_reader :type, :amount, :description
 
    def initialize(type:, amount:, description:)
      @type = type
      @amount = amount   # SharedKernel::Money
      @description = description
      freeze
    end
 
    def positive?
      amount.positive?
    end
 
    def to_h
      { type: type, amount: amount.to_h, description: description }
    end
  end
 
  # 割引計算結果を表す値オブジェクト
  class DiscountResult
    attr_reader :subtotal, :discounts, :shipping_fee
 
    def initialize(subtotal:, discounts:, shipping_fee:)
      @subtotal = subtotal
      @discounts = discounts.freeze
      @shipping_fee = shipping_fee
      freeze
    end
 
    def total_discount_amount
      discounts.map(&:amount).reduce(
        SharedKernel::Money.new(amount: 0, currency: subtotal.currency),
        :add
      )
    end
 
    def discounted_subtotal
      subtotal.subtract(total_discount_amount)
    end
 
    def final_amount
      discounted_subtotal.add(shipping_fee)
    end
 
    def free_shipping?
      shipping_fee.zero?
    end
 
    def discount_rate
      return 0 if subtotal.zero?
      (total_discount_amount.to_f / subtotal.to_f * 100).round(1)
    end
 
    def to_h
      {
        subtotal: subtotal.to_h,
        discounts: discounts.map(&:to_h),
        total_discount: total_discount_amount.to_h,
        shipping_fee: shipping_fee.to_h,
        final_amount: final_amount.to_h,
        free_shipping: free_shipping?
      }
    end
  end
end

在庫引き当てサービス(複数集約を調整するドメインサービス)

複数の集約(Order と Stock)を調整するドメインサービス。

module OrderContext
  class StockReservationService
    def initialize(stock_repository:)
      @stock_repository = stock_repository
    end
 
    # 注文の全商品に対して在庫を引き当てる
    def reserve_for_order(order)
      # Step1: 全商品の在庫を確認(すべてが十分か検証)
      stock_checks = order.order_items.map do |item|
        stock = @stock_repository.find_by_product(item.product_id)
 
        unless stock
          raise StockNotFound,
            "商品(#{item.product_id})の在庫情報がありません"
        end
 
        unless stock.available_quantity >= item.quantity
          raise InventoryContext::InsufficientStock.new(
            product_id: item.product_id,
            requested: item.quantity,
            available: stock.available_quantity
          )
        end
 
        { stock: stock, quantity: item.quantity }
      end
 
      # Step2: 全商品の確認が取れたら一括引き当て(部分的な引き当てを避ける)
      stock_checks.each do |check|
        check[:stock].reserve(
          quantity: check[:quantity],
          order_id: order.id
        )
        @stock_repository.save(check[:stock])
      end
    end
 
    # 注文の在庫引き当てを全解放する(キャンセル時)
    def release_for_order(order)
      order.order_items.each do |item|
        stock = @stock_repository.find_by_product(item.product_id)
        next unless stock  # 在庫情報がない場合はスキップ
 
        stock.release_reservation(order_id: order.id)
        @stock_repository.save(stock)
      rescue InventoryContext::ReservationNotFound => e
        # 引き当てが見つからない場合はログだけ記録(既に解放済みの可能性)
        Rails.logger.warn("在庫解放スキップ: #{e.message}")
      end
    end
  end
end

キャンペーンコードのドメインオブジェクト

module PromotionContext
  class CampaignCode
    attr_reader :code, :discount_type, :discount_value,
                :valid_from, :valid_until, :max_uses, :current_uses,
                :minimum_order_amount
 
    class InvalidCode < StandardError; end
    class ExpiredCode < StandardError; end
    class ExhaustedCode < StandardError; end
    class InsufficientOrderAmount < StandardError; end
 
    def initialize(
      code:, discount_type:, discount_value:,
      valid_from:, valid_until:, max_uses:, current_uses:,
      minimum_order_amount: nil
    )
      @code = code
      @discount_type = discount_type  # :percentage / :fixed_amount
      @discount_value = discount_value
      @valid_from = valid_from
      @valid_until = valid_until
      @max_uses = max_uses
      @current_uses = current_uses
      @minimum_order_amount = minimum_order_amount
      freeze
    end
 
    # 注文に適用可能かどうかを検証
    def applicable?(order, current_time: Time.current)
      raise ExpiredCode, "キャンペーンコードの有効期限が切れています" if expired?(current_time)
      raise ExhaustedCode, "キャンペーンコードの使用回数上限に達しました" if exhausted?
 
      if minimum_order_amount && order.subtotal < minimum_order_amount
        raise InsufficientOrderAmount,
          "このキャンペーンコードは#{minimum_order_amount}以上の注文に適用できます"
      end
 
      true
    end
 
    def calculate_discount(subtotal)
      case discount_type
      when :percentage
        subtotal.apply_discount_percentage(discount_value)
      when :fixed_amount
        discount = SharedKernel::Money.new(amount: discount_value, currency: subtotal.currency)
        discount > subtotal ? subtotal : discount  # 注文金額を超えない
      else
        raise "未知の割引タイプ: #{discount_type}"
      end
    end
 
    def display_code
      code.upcase
    end
 
    private
 
    def expired?(current_time)
      current_time < valid_from || current_time > valid_until
    end
 
    def exhausted?
      max_uses && current_uses >= max_uses
    end
  end
end

ドメインサービス vs アプリケーションサービス の違い

この2つは混同しやすいので整理する。

比較軸ドメインサービスアプリケーションサービス
属する層ドメイン層アプリケーション層
扱う概念ビジネスロジックの計算ユースケースの調整
依存先ドメインオブジェクトのみドメイン層 + インフラ層
DB操作しないリポジトリ経由でする
具体例割引計算、在庫引き当て確認注文確定フロー全体
# ドメインサービス: 純粋なビジネス計算ロジック
module OrderContext
  class DiscountCalculationService
    # DBアクセスなし。引数のオブジェクトのみを使って計算
    def calculate(order:, customer:, campaign_code: nil)
      # 純粋なビジネスロジック計算
    end
  end
end
 
# アプリケーションサービス: ユースケースの調整(次章で詳しく)
class PlaceOrderUseCase
  def initialize(
    order_repository:,           # インフラ依存
    stock_reservation_service:,  # ドメインサービス
    discount_service:,           # ドメインサービス
    event_bus:                   # インフラ依存
  )
    # アプリケーション層は両方に依存できる
  end
 
  def call(command)
    # ドメインサービスを呼び出す
    discount_result = @discount_service.calculate(
      order: order,
      customer: customer,
      campaign_code: command.campaign_code
    )
    # リポジトリで保存する(インフラ操作)
    @order_repository.save(order)
  end
end

WARNING

ドメインサービスが増えすぎると「貧血ドメインモデル」になる危険がある。「このロジックはOrderエンティティに自然に属せないか?」を常に確認する。order.confirm! はOrderに自然に属する。「注文と顧客の組み合わせで割引率を計算する」はどちらにも自然に属さないのでドメインサービスになる。

ドメインサービスのテスト

ステートレスなので、純粋に入力と出力をテストできる。

RSpec.describe OrderContext::DiscountCalculationService do
  subject(:service) { described_class.new }
 
  let(:delivery_address) do
    instance_double(OrderContext::DeliveryAddress, remote_area?: false)
  end
 
  def build_order(subtotal_amount)
    instance_double(
      'OrderContext::Order',
      subtotal: SharedKernel::Money.new(amount: subtotal_amount, currency: :jpy),
      delivery_address: delivery_address
    )
  end
 
  def build_customer(rank:, first_order: false)
    instance_double(
      'CustomerContext::Customer',
      membership_rank: rank,
      membership_rank_display: rank.to_s,
      has_never_ordered?: first_order
    )
  end
 
  context 'ゴールド会員、通常注文(5000円)' do
    let(:order) { build_order(5_000) }
    let(:customer) { build_customer(rank: :gold) }
 
    it '10%の会員割引が適用される' do
      result = service.calculate(order: order, customer: customer)
 
      membership_discount = result.discounts.find { |d| d.type == :membership }
      expect(membership_discount.amount)
        .to eq(SharedKernel::Money.new(amount: 500, currency: :jpy))
    end
 
    it '3000円以上なので送料無料' do
      result = service.calculate(order: order, customer: customer)
      expect(result.free_shipping?).to be true
    end
 
    it '最終金額は4500円(5000 - 500割引 + 0送料)' do
      result = service.calculate(order: order, customer: customer)
      expect(result.final_amount)
        .to eq(SharedKernel::Money.new(amount: 4_500, currency: :jpy))
    end
  end
 
  context '初回注文の顧客(ブロンズ、2000円注文)' do
    let(:order) { build_order(2_000) }
    let(:customer) { build_customer(rank: :bronze, first_order: true) }
 
    it '初回注文10%割引が適用される' do
      result = service.calculate(order: order, customer: customer)
 
      first_order_discount = result.discounts.find { |d| d.type == :first_order }
      expect(first_order_discount).not_to be_nil
      expect(first_order_discount.amount)
        .to eq(SharedKernel::Money.new(amount: 200, currency: :jpy))
    end
 
    it '3000円未満なので通常送料(550円)' do
      result = service.calculate(order: order, customer: customer)
      expect(result.shipping_fee)
        .to eq(SharedKernel::Money.new(amount: 550, currency: :jpy))
    end
  end
 
  context '沖縄県への配送(1000円注文)' do
    let(:okinawa_address) do
      instance_double(OrderContext::DeliveryAddress, remote_area?: true)
    end
    let(:order) do
      instance_double(
        'OrderContext::Order',
        subtotal: SharedKernel::Money.new(amount: 1_000, currency: :jpy),
        delivery_address: okinawa_address
      )
    end
    let(:customer) { build_customer(rank: :bronze) }
 
    it '離島追加送料(880円)が適用される' do
      result = service.calculate(order: order, customer: customer)
      expect(result.shipping_fee)
        .to eq(SharedKernel::Money.new(amount: 880, currency: :jpy))
    end
  end
 
  context 'ゴールド会員がキャンペーンコードも持っている場合' do
    let(:order) { build_order(5_000) }
    let(:customer) { build_customer(rank: :gold) }  # 10%会員割引
    let(:campaign_code) do
      instance_double(
        'PromotionContext::CampaignCode',
        applicable?: true,
        calculate_discount: SharedKernel::Money.new(amount: 800, currency: :jpy),  # 800円引き
        display_code: 'SUMMER24'
      )
    end
 
    it 'より大きい割引が適用される(800円 vs 500円)' do
      result = service.calculate(order: order, customer: customer, campaign_code: campaign_code)
 
      # 会員割引(500円)とキャンペーン(800円)は重複しない → 800円が選ばれる
      expect(result.discounts.map(&:type)).not_to include(:membership)
      campaign_discount = result.discounts.find { |d| d.type == :campaign }
      expect(campaign_discount.amount)
        .to eq(SharedKernel::Money.new(amount: 800, currency: :jpy))
    end
  end
end

ドメインサービスの適切な粒度

「何でもドメインサービスにする」のは避けたい。判断基準を持つ。

# これはドメインサービスにすべきか?
 
# Case 1: 注文の合計金額を計算する
# → NOl。Orderエンティティのメソッドが自然
class Order
  def total_amount
    order_items.sum(&:subtotal)  # Orderの内部で完結する
  end
end
 
# Case 2: 顧客の会員ランクを判定する
# → NO。Customerエンティティのメソッドが自然
class Customer
  def membership_rank
    case total_spent_this_year
    when 100_000.. then :gold
    when 30_000..  then :silver
    else                 :bronze
    end
  end
end
 
# Case 3: 注文と顧客の情報を組み合わせて割引を計算する
# → YES。どちらのエンティティにも自然に属さない
class DiscountCalculationService
  def calculate(order:, customer:, campaign_code: nil)
    # OrderとCustomerの両方が必要な計算
  end
end
 
# Case 4: 2つの住所が同じ配送ゾーンにあるか確認する
# → YES。DeliveryAddress同士の関係を計算する(属するエンティティがない)
class DeliveryZoneService
  def same_zone?(address_a, address_b)
    zone_for(address_a) == zone_for(address_b)
  end
end

リナの気づき

「ドメインサービスという名前が『サービス』なので、なんでも入れてしまいたくなる。でも、ドメインロジック以外は入れない。それを守ることで、ドメインサービスが本当に価値あるものになる」

以前は DiscountService という「なんでも屋サービス」があった。DBにアクセスして割引設定を取ってきて、メールを送って、分析データも記録していた。今は DiscountCalculationService は純粋な計算のみを担い、DBアクセスはアプリケーションサービス層が担う。

ドメインサービスがステートレスになったことで、テストが単純になった。DBのセットアップ不要、モックは最小限、テストが1ファイルで完結する。

まとめ

  • ドメインサービス = エンティティに自然に属さないドメインロジックの置き場
  • ステートレス = 状態を持たず、引数を受け取って結果を返す
  • 適切な場所 = 複数集約をまたぐ計算、複合ビジネスルール
  • 貧血ドメインモデルを避ける = エンティティが自分のロジックを持つことを優先
  • テストしやすい = 外部依存なし、入力と出力だけをテスト

次の章では、ドメイン内で起きた出来事を表現する「ドメインイベント」を学ぶ。注文確定後の6つの後続処理を、Order#confirm! から分離する方法を見ていこう。