ドメインサービス — エンティティに属さないビジネスロジック
ドメインサービスとは
前章では集約について学びました。本章では、エンティティや値オブジェクトに属さないビジネスロジックを扱う「ドメインサービス」について学びます。
ドメインサービスは以下の特徴を持ちます。
- 状態を持たない(ステートレス)
- ドメインの操作を表現する
- 複数の集約にまたがる処理を担当することが多い
いつドメインサービスを使うのか
ドメインサービスの判断基準
ドメインサービスは「ドメイン層の概念だが、エンティティや値オブジェクトには自然に属さないビジネスロジック」を配置する場所です。
以下の条件にすべて当てはまる場合にドメインサービスを使います。
- 純粋なドメインロジックです(インフラ依存がない)
- 複数のエンティティ・値オブジェクトにまたがる、または特定のエンティティに属させると不自然な処理です
これらをすべて満たす場合にドメインサービスを検討します。
逆に、ドメインサービスを使うべきでないケースは以下です。
- 単一のエンティティに属する処理 → エンティティのメソッドに配置
- リポジトリやDBアクセスが必要 → ユースケース(アプリケーション層)に配置
- 外部API呼び出しが必要 → ユースケース(アプリケーション層)に配置
- ビジネスロジックがない単純な調整処理 → ユースケース(アプリケーション層)に配置
ビジネスロジックをどこに置くべきか、以下のフローチャートで判断します。
| 配置場所 | 条件 | 例 |
|---|---|---|
| エンティティ | 1つのエンティティに閉じた処理 | Order.confirm(), Order.cancel() |
| ドメインサービス | 複数エンティティにまたがるが、インフラ不要 | 価格計算、割引適用 |
| ユースケース | インフラ層(DB、外部API等)が必要 | 注文作成、在庫確認 |
具体例:割引計算
割引計算を考えてみましょう。この処理は「注文」と「顧客」という2つの集約にまたがる計算です。
先に、この節で使う Customer 集約の最小形を示します。注文は顧客を保持しない(第14章)ので、顧客はリポジトリから注文IDで引きます。
// app/Domain/Customer/Customer.php
final class Customer
{
public function __construct(
private readonly UserId $id,
private readonly EmailAddress $email, // 注文確定メールの宛先(第9章)
private readonly bool $isGoldMember,
) {}
public function id(): UserId { return $this->id; }
public function email(): EmailAddress { return $this->email; }
public function isGoldMember(): bool { return $this->isGoldMember; }
}
// app/Domain/Customer/CustomerRepositoryInterface.php
interface CustomerRepositoryInterface
{
// orders.user_id をたどって注文の顧客を返す
public function findByOrderId(OrderId $orderId): ?Customer;
}
// この interface の実装は本書では示さないので、読者のプロジェクト側で用意してください
// app/Domain/Customer/Exception/CustomerNotFoundException.php
final class CustomerNotFoundException extends DomainException
{
public function __construct(OrderId $orderId)
{
parent::__construct(
message: "注文ID {$orderId->value()} の顧客が見つかりません",
errorCode: 'CUSTOMER_NOT_FOUND',
);
}
}
// 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()->isGreaterThanOrEqual(new Money(10000, 'JPY'))) {
return $order->totalAmount()->percentage(10);
}
return new Money(0, 'JPY');
}
}
なぜドメインサービスが適切か?
- 純粋な計算ロジック(DB・外部APIアクセスなし)
- Order集約とCustomer集約の両方の情報が必要
- どちらのエンティティに属させても不自然
ドメインサービスの実装例
例1:在庫引当サービス
注文確定時に在庫を引き当てる処理は、Order集約とInventory集約にまたがります。Inventory集約はここが初出なので、まず最小形を示します。在庫が足りるかの判定は decrease() が内包し、呼び出し側では判定しません。
// app/Domain/Inventory/Inventory.php
use App\Domain\Inventory\Exception\InsufficientStockException;
final class Inventory
{
public function __construct(
private readonly ProductId $productId,
private int $stock,
) {}
public function productId(): ProductId { return $this->productId; }
public function stock(): int { return $this->stock; }
/**
* 在庫を減らす
* 不足しているかの判定はこのメソッドが持つ(呼び出し側で二重に判定しない)
*/
public function decrease(int $quantity): void
{
if ($this->stock < $quantity) {
throw new InsufficientStockException($this->productId, $quantity, $this->stock);
}
$this->stock -= $quantity;
}
}
// app/Domain/Inventory/InventoryRepositoryInterface.php
interface InventoryRepositoryInterface
{
public function findByProductId(ProductId $productId): ?Inventory;
/**
* @param ProductId[] $productIds
* @return array<int, Inventory> 商品IDの値をキーにした連想配列
*/
public function findByProductIds(array $productIds): array;
// 悲観的ロック付きで取得する(第15章)
public function findByProductIdForUpdate(ProductId $productId): ?Inventory;
public function save(Inventory $inventory): void;
}
// app/Infrastructure/Eloquent/InventoryModel.php
final class InventoryModel extends Model
{
use HasFactory;
protected $table = 'inventories';
protected $fillable = ['product_id', 'stock'];
}
// app/Domain/Inventory/Exception/InventoryNotFoundException.php
final class InventoryNotFoundException extends DomainException
{
public function __construct(ProductId $productId)
{
parent::__construct(
message: "商品ID {$productId->value()} の在庫情報が見つかりません",
errorCode: 'INVENTORY_NOT_FOUND',
);
}
}
// app/Domain/Order/StockAllocationService.php
use App\Domain\Inventory\Exception\InventoryNotFoundException;
final class StockAllocationService
{
/**
* 注文に対して在庫を引き当てる
*
* この処理がドメインサービスである理由:
* 1. Order集約とInventory集約にまたがる処理
* 2. 純粋なビジネスロジック(インフラ依存なし)
* 3. OrderにもInventoryにも属さない「引当」というドメイン操作
*
* @param array<int, Inventory> $inventories 商品IDの値をキーにした連想配列
* @throws InventoryNotFoundException|InsufficientStockException
*/
public function allocate(Order $order, array $inventories): void
{
foreach ($order->orderLines() as $line) {
// 在庫エンティティを取得
$inventory = $inventories[$line->productId()->value()] ?? null;
// 在庫情報が存在しない場合はビジネスルール違反
if ($inventory === null) {
throw new InventoryNotFoundException($line->productId());
}
// 判定と減算はエンティティが行う(不足していれば例外を投げる)
$inventory->decrease($line->quantity());
}
}
}
例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
{
// 値オブジェクトは1回だけ作って重複チェックと生成の両方で使う
$email = EmailAddress::fromString($command->email);
// 重複チェックはユースケースの責務
if ($this->userRepository->existsByEmail($email)) {
throw new DuplicateEmailException('このメールアドレスは既に使用されています');
}
$user = User::create(
$this->userRepository->nextIdentity(),
$email,
UserName::fromString($command->name)
);
$this->userRepository->save($user);
return $user->id();
}
}
// app/Domain/User/Exception/DuplicateEmailException.php
// メッセージ1引数の形(第16章の InvalidOrderStateException と同型)
final class DuplicateEmailException extends DomainException
{
public function __construct(string $message)
{
parent::__construct(message: $message, errorCode: 'DUPLICATE_EMAIL');
}
}
ドメインサービス vs ユースケース(アプリケーションサービス)
この2つは最も混同されやすい概念ですが、明確な違いがあります。
主な違い
| 観点 | ドメインサービス | ユースケース(アプリケーションサービス) |
|---|---|---|
| 所属層 | ドメイン層 | アプリケーション層 |
| 責務 | ビジネスロジックの実行 | ワークフローの調整 |
| インフラ依存 | なし | あり(リポジトリ、外部API等) |
| トランザクション | 扱わない | 扱う |
| DBアクセス | しない | する(リポジトリ経由) |
| テスト | 純粋関数的にテスト可能 | モックが必要 |
役割の違い
- What(何を) に集中:ビジネスルールそのものを表現
- 「割引を計算する」「送料を計算する」「在庫を引き当てる」
- ドメインエキスパートが理解できる言葉で書かれる
- インフラの詳細を知らない
- How(どのように) に集中:処理の流れを組み立てる
- 「注文を確定する」「ユーザーを登録する」「商品を検索する」
- ドメインオブジェクトやドメインサービスを組み合わせて処理を実現
- インフラ層(DB、外部API)との橋渡し
具体例で比較
ドメインサービス:価格計算
// ドメイン層:純粋なビジネスロジック
final class OrderPriceCalculationService
{
/**
* 注文の最終価格を計算する
*
* ビジネスルール:
* - 商品合計 + 送料 - 割引 = 最終価格
*/
public function calculateFinalPrice(
Order $order,
Money $shippingFee,
Money $discount
): Money {
return $order->totalAmount()
->add($shippingFee)
->subtract($discount);
}
}
ユースケース:注文処理のワークフロー
// アプリケーション層:処理の流れを調整
use App\Domain\Order\Exception\OrderNotFoundException;
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(ConfirmOrderCommand $command): void
{
// トランザクション管理(アプリケーション層の責務)
DB::transaction(function () use ($command) {
// 1. データ取得(インフラ層との連携)
$orderId = new OrderId($command->orderId);
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);
// 注文は顧客を保持しないので、注文IDから顧客を引く
$customer = $this->customerRepository->findByOrderId($orderId)
?? throw new CustomerNotFoundException($orderId);
// 2. ドメインサービスを使ってビジネスロジック実行
$shippingFee = $this->shippingFeeService->calculate($order);
$discount = $this->discountService->calculateDiscount($order, $customer);
$finalPrice = $this->priceService->calculateFinalPrice($order, $shippingFee, $discount);
// 3. エンティティの状態更新
// 金額はOrderに保持させない。都度計算する totalAmount() と真実の源が二重になるため
// (計算結果は請求や表示など、この処理の外側で使う)
$order->confirm(); // エンティティのビジネスロジック
// 4. 永続化(インフラ層との連携)
$this->orderRepository->save($order);
});
}
}
この例から分かるように:
- ドメインサービス:計算ロジックそのもの(純粋関数)
- ユースケース:「取得→計算→更新→保存」というワークフローの調整
ドメインサービスを使った実装例
ユースケースからドメインサービスを呼び出す
ここで示す ConfirmOrderUseCase は、前節「具体例で比較」の例とは別の実装です。在庫確認を含み、依存するドメインサービスも異なります。
ユースケースは「ワークフローの調整役」として、ドメインサービスを適切に組み合わせます。
// app/Application/UseCase/Order/ConfirmOrderUseCase.php
use App\Domain\Order\Exception\OrderNotFoundException;
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(ConfirmOrderCommand $command): void
{
// トランザクション管理(アプリケーション層の責務)
DB::transaction(function () use ($command) {
// 1. 注文を取得(インフラ層との連携)
$orderId = new OrderId($command->orderId);
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);
// 2. ドメインサービスで送料計算(ビジネスロジック)
// 金額はOrderに保持させず、算出結果をこの処理の外側で使う
$shippingFee = $this->shippingFeeService->calculate($order);
// 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つの責務に集中 |
| 過度に使わない | まずエンティティに配置できないか検討 |
すべてのロジックをドメインサービスに移すと、エンティティが単なるデータの入れ物(貧血ドメインモデル)になってしまいます。まずはエンティティにロジックを配置し、それが適切でない場合にのみドメインサービスを検討しましょう。
まとめ
重要ポイント
| ポイント | 説明 |
|---|---|
| ドメインサービスの定義 | エンティティや値オブジェクトに自然に属さないビジネスロジックを表現 |
| 使うべき場面 | 複数集約にまたがる計算・操作で、インフラ依存がない場合のみ |
| ユースケースとの違い | ドメインサービス=ビジネスロジック、ユースケース=ワークフロー調整 |
| アンチパターン | すべてをドメインサービスに配置すると貧血ドメインモデルになる |
次のステップ
ドメインサービスは純粋なビジネスロジックを表現する強力なツールですが、過度に使うと逆効果です。まずはエンティティにロジックを配置できないか検討し、それが不自然な場合にのみドメインサービスを使いましょう。
次のチャプターでは、集約間の疎結合な連携を実現する「ドメインイベント」について学びます。