ドメインサービス — エンティティに属さないロジック
「割引の計算、どこに書けばいいんだ」
リナは新機能の実装中に困った。FreshCartの割引ロジックは複雑だ。
- 会員ランク(ゴールド/シルバー/ブロンズ)による基本割引
- 注文金額が3,000円以上で送料無料
- 初回注文には10%割引
- キャンペーンコードによる追加割引
- 複数割引の重複適用ルール(割引は積み重ねない、最大割引を適用する)
この計算はどのエンティティに属するのか?
Order に書くと Order が巨大になる——これはFat Modelへの逆戻りだ。Customer に書くとなぜか顧客が注文の割引を計算することになる——それはおかしい。Product は関係ない。
「このロジックはどこにも自然に属さない」
ドメインサービスとは
ドメインサービス(Domain Service)とは、特定のエンティティや値オブジェクトに自然に属さないドメインロジックを担うオブジェクトだ。
ドメインサービスの特徴:
- ステートレス — 状態を持たない。入力を受け取って結果を返すだけ
- ドメイン層に属する — インフラへの依存なし(DBアクセスなし)
- 複数の集約をまたぐ — 単一のエンティティには帰属しない処理
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
endWARNING
ドメインサービスが増えすぎると「貧血ドメインモデル」になる危険がある。「このロジックは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! から分離する方法を見ていこう。