ドメインサービス — エンティティに属さないビジネスロジック
ドメインサービスとは
前章では集約について学びました。本章では、エンティティや値オブジェクトに属さないビジネスロジックを扱う「ドメインサービス」について学びます。
ドメインサービスは以下の特徴を持ちます。
- 状態を持たない(ステートレス)
- ドメインの操作を表現する
- 複数の集約にまたがる処理を担当することが多い
いつドメインサービスを使うのか
ドメインサービスの判断基準
ドメインサービスは「ドメイン層の概念だが、エンティティや値オブジェクトには自然に属さないビジネスロジック」を配置する場所です。
以下の条件にすべて当てはまる場合にドメインサービスを使います。
- 純粋なドメインロジックです(インフラ依存がない)
- 複数のエンティティ・値オブジェクトにまたがる処理です
- 特定のエンティティに属すると不自然な処理です
この3つすべてを満たす場合のみドメインサービスを検討します。
逆に、ドメインサービスを使うべきでないケースは以下です。
- 単一のエンティティに属する処理 → エンティティのメソッドに配置
- リポジトリやDBアクセスが必要 → ユースケース(アプリケーション層)に配置
- 外部API呼び出しが必要 → ユースケース(アプリケーション層)に配置
- ビジネスロジックがない単純な調整処理 → ユースケース(アプリケーション層)に配置
ビジネスロジックをどこに置くべきか、以下のフローチャートで判断します。
| 配置場所 | 条件 | 例 |
|---|---|---|
| エンティティ | 1つのエンティティに閉じた処理 | Order.confirm(), Order.cancel() |
| ドメインサービス | 複数エンティティにまたがるが、インフラ不要 | 価格計算、割引適用 |
| ユースケース | インフラ層(DB、外部API等)が必要 | 注文作成、在庫確認 |
具体例:割引計算
割引計算を考えてみましょう。この処理は「注文」と「顧客」という2つの集約にまたがる計算です。
// NG: Orderエンティティに配置
class Order
{
public function applyDiscount(Customer $customer): void
{
// 問題点:
// 1. Orderが「顧客のランク」という他の集約の詳細を知る必要がある
// 2. OrderがCustomerに依存してしまう(集約の独立性が失われる)
// 3. これはOrderの責務を超えている
if ($customer->isGoldMember()) {
$this->discount = $this->totalAmount()->percentage(10);
}
}
}
// OK: ドメインサービスに配置
final class DiscountCalculationService
{
/**
* 注文に適用される割引金額を計算する
*
* ビジネスルール:
* - ゴールド会員かつ10,000円以上の注文には10%割引
*/
public function calculateDiscount(Order $order, Customer $customer): Money
{
// 複数の集約(Order, Customer)を参照して計算
// この処理はOrderにもCustomerにも属さない「割引計算」というドメイン概念
if ($customer->isGoldMember() && $order->totalAmount()->isGreaterThan(new Money(10000, 'JPY'))) {
return $order->totalAmount()->percentage(10);
}
return new Money(0, 'JPY');
}
}
なぜドメインサービスが適切か?
- 純粋な計算ロジック(DB・外部APIアクセスなし)
- Order集約とCustomer集約の両方の情報が必要
- どちらのエンティティに属させても不自然
ドメインサービスの実装例
例1:在庫引当サービス
注文確定時に在庫を引き当てる処理は、Order集約とInventory集約にまたがります。
// app/Domain/Order/StockAllocationService.php
final class StockAllocationService
{
/**
* 注文に対して在庫を引き当てる
*
* この処理がドメインサービスである理由:
* 1. Order集約とInventory集約にまたがる処理
* 2. 純粋なビジネスロジック(インフラ依存なし)
* 3. OrderにもInventoryにも属さない「引当」というドメイン操作
*
* @throws InsufficientStockException
*/
public function allocate(Order $order, InventoryCollection $inventories): AllocationResult
{
$allocations = [];
foreach ($order->orderLines() as $line) {
// 在庫エンティティを取得
$inventory = $inventories->findByProductId($line->productId());
// 在庫情報が存在しない場合はビジネスルール違反
if ($inventory === null) {
throw new InsufficientStockException(
"商品ID {$line->productId()->value()} の在庫情報が見つかりません"
);
}
// 在庫が足りるかチェック(ビジネスルール)
if (!$inventory->canAllocate($line->quantity())) {
throw new InsufficientStockException(
"商品ID {$line->productId()->value()} の在庫が不足しています"
);
}
// 在庫エンティティに引当を指示
// 実際の状態変更はエンティティ自身が行う
$inventory->allocate($line->quantity());
$allocations[] = new Allocation($line->productId(), $line->quantity());
}
return new AllocationResult($allocations);
}
}
例2:送料計算サービス
送料計算は配送先住所と注文内容に基づいて行われます。
// app/Domain/Shipping/ShippingFeeCalculationService.php
final class ShippingFeeCalculationService
{
private const BASE_FEE = 500;
private const FREE_SHIPPING_THRESHOLD = 5000;
/**
* 注文の送料を計算する
*
* ビジネスルール:
* - 5,000円以上の注文は送料無料
* - 北海道・沖縄は追加料金500円
*
* この処理がドメインサービスである理由:
* 1. 注文金額と配送先住所の両方を使う計算ロジック
* 2. 純粋な計算(インフラ依存なし)
* 3. Orderに属させると「配送」の詳細を知りすぎてしまう
*/
public function calculate(Order $order): Money
{
// 一定金額以上は送料無料(ビジネスルール)
if ($order->totalAmount()->isGreaterThanOrEqual(new Money(self::FREE_SHIPPING_THRESHOLD, 'JPY'))) {
return new Money(0, 'JPY');
}
// 地域による送料差を計算
$regionFee = $this->calculateRegionFee($order->shippingAddress());
return (new Money(self::BASE_FEE, 'JPY'))->add($regionFee);
}
/**
* 配送先地域による追加料金を計算
*/
private function calculateRegionFee(ShippingAddress $address): Money
{
// 離島や遠方の追加料金(ビジネスルール)
return match ($address->prefecture()) {
'北海道', '沖縄県' => new Money(500, 'JPY'),
default => new Money(0, 'JPY'),
};
}
}
例3:重複チェックサービス
ユーザー登録時のメールアドレス重複チェックは、リポジトリを使う必要があるためドメインサービスではなくユースケースで行います。
// NG: ドメインサービスでリポジトリを使う
final class UserRegistrationService // これはドメインサービスではなく、アプリケーションサービス(ユースケース)の責務
{
public function __construct(
private readonly UserRepositoryInterface $userRepository // インフラ依存!
) {}
}
// OK: ユースケースで行う
final class RegisterUserUseCase
{
public function __construct(
private readonly UserRepositoryInterface $userRepository
) {}
public function execute(RegisterUserCommand $command): UserId
{
// 重複チェックはユースケースの責務
if ($this->userRepository->existsByEmail($command->email)) {
throw new DuplicateEmailException('このメールアドレスは既に使用されています');
}
$user = User::create(
$this->userRepository->nextIdentity(),
new EmailAddress($command->email),
new UserName($command->name)
);
$this->userRepository->save($user);
return $user->id();
}
}
ドメインサービス vs ユースケース(アプリケーションサービス)
この2つは最も混同されやすい概念ですが、明確な違いがあります。
主な違い
| 観点 | ドメインサービス | ユースケース(アプリケーションサービス) |
|---|---|---|
| 所属層 | ドメイン層 | アプリケーション層 |
| 責務 | ビジネスロジックの実行 | ワークフローの調整 |
| インフラ依存 | なし | あり(リポジトリ、外部API等) |
| トランザクション | 扱わない | 扱う |
| DBアクセス | しない | する(リポジトリ経由) |
| 状態管理 | ステートレス | ステートレス |
| テスト | 純粋関数的にテスト可能 | モックが必要 |
役割の違い
- What(何を) に集中:ビジネスルールそのものを表現
- 「割引を計算する」「送料を計算する」「在庫を引き当てる」
- ドメインエキスパートが理解できる言葉で書かれる
- インフラの詳細を知らない
- How(どのように) に集中:処理の流れを組み立てる
- 「注文を確定する」「ユーザーを登録する」「商品を検索する」
- ドメインオブジェクトやドメインサービスを組み合わせて処理を実現
- インフラ層(DB、外部API)との橋渡し
具体例で比較
ドメインサービス:価格計算
// ドメイン層:純粋なビジネスロジック
final class OrderPriceCalculationService
{
/**
* 注文の最終価格を計算する
*
* ビジネスルール:
* - 商品合計 + 送料 - 割引 = 最終価格
*/
public function calculateFinalPrice(
Order $order,
Money $shippingFee,
Money $discount
): Money {
return $order->subtotal()
->add($shippingFee)
->subtract($discount);
}
}
ユースケース:注文処理のワークフロー
// アプリケーション層:処理の流れを調整
final class ConfirmOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository, // インフラ依存
private readonly CustomerRepositoryInterface $customerRepository, // インフラ依存
private readonly ShippingFeeCalculationService $shippingFeeService, // ドメインサービス
private readonly DiscountCalculationService $discountService, // ドメインサービス
private readonly OrderPriceCalculationService $priceService, // ドメインサービス
) {}
public function execute(OrderId $orderId): void
{
// トランザクション管理(アプリケーション層の責務)
DB::transaction(function () use ($orderId) {
// 1. データ取得(インフラ層との連携)
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException();
$customer = $this->customerRepository->findById($order->customerId())
?? throw new CustomerNotFoundException();
// 2. ドメインサービスを使ってビジネスロジック実行
$shippingFee = $this->shippingFeeService->calculate($order);
$discount = $this->discountService->calculateDiscount($order, $customer);
$finalPrice = $this->priceService->calculateFinalPrice($order, $shippingFee, $discount);
// 3. エンティティの状態更新
$order->setShippingFee($shippingFee);
$order->setDiscount($discount);
$order->setFinalPrice($finalPrice);
$order->confirm(); // エンティティのビジネスロジック
// 4. 永続化(インフラ層との連携)
$this->orderRepository->save($order);
});
}
}
この例から分かるように:
- ドメインサービス:計算ロジックそのもの(純粋関数)
- ユースケース:「取得→計算→更新→保存」というワークフローの調整
この2つは混同されやすいですが、明確な違いがあります。
| 特性 | ドメインサービス | ユースケース |
|---|---|---|
| 所属層 | ドメイン層 | アプリケーション層 |
| インフラ依存 | なし | あり(リポジトリ等) |
| トランザクション | 扱わない | 扱う |
| 責務 | ドメインロジック | ワークフローの調整 |
ドメインサービスを使った実装例
ユースケースからドメインサービスを呼び出す
ユースケースは「ワークフローの調整役」として、ドメインサービスを適切に組み合わせます。
// app/Application/UseCase/Order/ConfirmOrderUseCase.php
final class ConfirmOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository, // インフラ層
private readonly InventoryRepositoryInterface $inventoryRepository, // インフラ層
private readonly StockAllocationService $stockAllocationService, // ドメインサービス
private readonly ShippingFeeCalculationService $shippingFeeService, // ドメインサービス
) {}
/**
* 注文を確定する
*
* ワークフロー:
* 1. 注文データを取得
* 2. 送料を計算(ドメインサービス)
* 3. 在庫を引き当て(ドメインサービス)
* 4. 注文を確定
* 5. 変更を永続化
*/
public function execute(OrderId $orderId): void
{
// トランザクション管理(アプリケーション層の責務)
DB::transaction(function () use ($orderId) {
// 1. 注文を取得(インフラ層との連携)
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException();
// 2. ドメインサービスで送料計算(ビジネスロジック)
$shippingFee = $this->shippingFeeService->calculate($order);
$order->setShippingFee($shippingFee);
// 3. 在庫データを取得(インフラ層との連携)
$productIds = array_map(fn($line) => $line->productId(), $order->orderLines());
$inventories = $this->inventoryRepository->findByProductIds($productIds);
// 4. ドメインサービスで在庫引当(ビジネスロジック)
$this->stockAllocationService->allocate($order, $inventories);
// 5. 注文を確定(エンティティのビジネスロジック)
$order->confirm();
// 6. 変更を永続化(インフラ層との連携)
$this->orderRepository->save($order);
foreach ($inventories as $inventory) {
$this->inventoryRepository->save($inventory);
}
});
}
}
このコードから分かるように:
- ユースケース:データ取得、ドメインサービス呼び出し、永続化を調整
- ドメインサービス:純粋なビジネスロジック(送料計算、在庫引当)
- エンティティ:自身の状態管理とビジネスルール(注文確定)
ドメインサービスの設計指針
| 指針 | 説明 |
|---|---|
| 状態を持たない | ドメインサービスはステートレスであるべき |
| インフラに依存しない | リポジトリ等を直接使わない |
| ドメイン言語を使う | メソッド名はビジネス用語で表現 |
| 単一責任 | 1つのサービスは1つの責務に集中 |
| 過度に使わない | まずエンティティに配置できないか検討 |
すべてのロジックをドメインサービスに移すと、エンティティが単なるデータの入れ物(貧血ドメインモデル)になってしまいます。まずはエンティティにロジックを配置し、それが適切でない場合にのみドメインサービスを検討しましょう。
まとめ
重要ポイント
| ポイント | 説明 |
|---|---|
| ドメインサービスの定義 | エンティティや値オブジェクトに自然に属さないビジネスロジックを表現 |
| 使うべき場面 | 複数集約にまたがる計算・操作で、インフラ依存がない場合のみ |
| ユースケースとの違い | ドメインサービス=ビジネスロジック、ユースケース=ワークフロー調整 |
| アンチパターン | すべてをドメインサービスに配置すると貧血ドメインモデルになる |
判断フロー(再掲)
- 単一のエンティティに属する? → YES: エンティティのメソッド
- インフラ(DB・外部 API)が必要? → YES: ユースケース(アプリケーション層)
- 複数エンティティにまたがる純粋なロジック? → YES: ドメインサービス
次のステップ
ドメインサービスは純粋なビジネスロジックを表現する強力なツールですが、過度に使うと逆効果です。まずはエンティティにロジックを配置できないか検討し、それが不自然な場合にのみドメインサービスを使いましょう。
次のチャプターでは、集約間の疎結合な連携を実現する「ドメインイベント」について学びます。