ドメインサービスが増えてきた時、再モデリングの機運説
この記事は株式会社エス・エム・エスAdvent Calendar2025の12月10日の記事です。
介護/障害福祉事業者向け経営支援サービス「カイポケ」のソフトウェアエンジニアの @b0ntenmaru_ です。
日頃開発しているソフトウェアの設計思想にはドメイン駆動設計が採用されていて、私は日々顧客の課題を解決するために奔走しています。
ある時ふと「ドメインサービスが増えてきた時ってビジネス要求にドメインモデルが耐えられなくなってきているのでは?」と考え始めました。
この記事では私が考え始めた「ドメインサービスが増えてきた時、再モデリングの機運説」について考察していこうと思います。
ドメインサービスとは
値オブジェクトやエンティティなどのドメインオブジェクトには、自分自身に関するロジックが記述されます。
ドメインサービスには、値オブジェクトやエンティティに実装するには不自然な振る舞いを記述します。
例えば、新しいユーザーを登録する際に、メールアドレスがすでに存在していないかを確認する処理を考えます。この重複チェックは、重要なドメインの仕様です。
// ユーザー集約内部での不自然な問い合わせ
val email = EmailAddress.create(input.email);
val newUser = User.create(name, email)
user.exists(email) // ユーザー自身が、他のユーザーの存在を知ることは不自然
この「重複を確認する」という振る舞いは、新しい User オブジェクト自身が、他のすべての既存の User オブジェクトのデータを知り、問い合わせることを意味します。これは集約の境界外の知識が必要であるため、User オブジェクトの責務として保持するのは不自然です。
ドメイン層でこのような不自然な振る舞いをカプセル化するためにドメインサービスを導入します。
// ドメインサービスによる不自然な振る舞いのカプセル化
class UserValidationDomainService(
private val userRepository: UserRepository
) {
/**
* 新しいユーザーを作成できるか(メールアドレスが重複しないか)を判定する
*/
fun isUnique(email: EmailAddress): Boolean {
// リポジトリに問い合わせる(インフラ層への依存はインターフェース経由)
return !userRepository.existsByEmail(email)
}
}
ドメインサービスの濫用
先述の通り、ドメインサービスに記述するものは不自然な振る舞いに限定しないといけません。
ドメインサービスには、やろうと思えばあらゆる振る舞いを記述できてしまうため、濫用すると本来エンティティや値オブジェクトが持つべき振る舞いが漏れ出し、エンティティや値オブジェクトがドメインを何も語らない貧血なオブジェクトになってしまうからです。
そのため、ドメインサービスの使用は必要最小限に留めた方が良いと考えています。
不自然な振る舞いを書きたいときはどんな時か
ドメインサービスを実装したいケースは例えば以下などがあると思っています。
- インターフェースを通じてインフラ層の振る舞いと組み合わせたい時
- 複数集約の整合性を維持したい時
- 複数の集約を組み合わせて何かを判断したい時
インターフェースを通じてインフラ層の振る舞いと組み合わせたい時
先述の「ドメインサービスとは」のセクションで説明したユーザー登録時のメールアドレスの重複チェックをしたい時です。
ドメイン層は、インフラ層の具体的な実装に直接依存すべきではありません。そこでドメイン層にインターフェースを定義し、それを通じてインフラ層にアクセスします。
// Domain Layer: UserRepositoryインターフェース
interface UserRepository {
// ドメイン層で必要な契約
fun existsByEmail(email: EmailAddress): Boolean
}
// Domain Layer: UserValidationDomainService (ドメインサービス)
class UserValidationDomainService(
private val userRepository: UserRepository // インターフェースに依存
) {
fun isUnique(email: EmailAddress): Boolean {
// ドメインサービスは、インフラ層の技術的な詳細を知る必要はない
return !userRepository.existsByEmail(email)
}
}
複数集約の整合性を維持したい時
集約はその内部の不変条件を守る整合性の単位であり、その境界外のオブジェクトの状態を知るべきではありません。しかし、ビジネスプロセスの中には複数の独立した集約にまたがり、それらの状態を同時に、矛盾なく更新しなければならないものがあります。
例えば銀行システムでの口座間送金処理があるとして、送金元である口座集約と送金先である口座集約があるとします。
送金処理は「送金元口座の残高を減らす」と「送金先口座の残高を増やす」という二つの異なる口座集約の操作を行います。
こうした処理において、送金元口座の残高が減ったにもかかわらず送金先口座の残高が増えないといった矛盾を防ぐため、整合性維持を責務とするドメインサービスを実装することがあります
/**
* 複数集約の整合性を維持するためのドメインサービス。
*
* NOTE:
* この例ではドメインサービスの責務を
* 「複数集約の状態を矛盾なく更新すること」に限定しています。
* 更新後の永続化(repository.save など)は UseCase 側で行う想定です。
*/
class TransferDomainService {
/**
* 送金のドメインロジックを実行し、更新済みの集約を返す
*/
fun transfer(
fromAccount: Account,
toAccount: Account,
amount: Money
): TransferResult {
fromAccount.withdraw(amount) // 送金元から引き出す
toAccount.deposit(amount) // 送金先に入金する
return TransferResult(
from = fromAccount,
to = toAccount
)
}
}
/**
* 送金結果
*/
data class TransferResult(
val from: Account,
val to: Account
)
複数の集約を組み合わせて何かを判断したい時
このパターンは複数の集約のデータを参照して、新しい値の計算や複雑なビジネス判断を下す役割をドメインサービスが担うケースです。このサービスは、主に値を返すステートレスな振る舞いに特化しています。
あるeコマースシステムで、顧客に適用される年間割引率を計算する処理で考えます。
この割引率の決定ロジックは、顧客集約と、過去の注文実績を表す注文履歴集約の両方が必要です。そのため、両集約の情報を参照し、複雑なロジックに基づいて最終的な割引率を判断するドメインサービスが必要です。
以下のように複数の集約の状態(例:顧客の会員ランクや年間購入総額など)を組み合わせて、最終的な割引率を計算し、その結果を返すケースです。
class DiscountCalculationDomainService {
/**
* 複数の集約を参照して、適用される割引率を判断・計算する
* @return 割引率 (例: 0.15 for 15%)
*/
fun calculateApplicableRate(customer: Customer, history: OrderHistory): Double {
// 例: ランクと総額に基づいて複雑な計算ロジックを実行
if (customer.rank == Rank.PREMIUM && history.annualTotal.isGreaterThan(Money(100000))) {
return 0.20 // 20%
}
// ... 他の複雑な条件分岐
return 0.05
}
}
なぜドメインサービスが増えてきたとき再モデリングの機運だと考えるのか
ドメインサービスが増えているということはアプリケーション内に不自然な振る舞いが増えていることを意味します。私はこの状態を「集約の境界線が、現在のビジネスの実態と乖離している」と考えています。
※ ここでいう「ドメインサービスが増える」とは、ユースケースの中核ロジックが集約に回帰しきれず、調整や判断がドメインサービスに滞留する場面が増えていく状態を指します。
そしてドメインモデルが置かれている状態のパターンとして以下のような可能性があると考えています。
- 実は未発見のドメインモデルがある
- 既存の複数のドメインモデルをまとめた、新しい上位のドメインモデルがある
実は未発見のドメインモデルがある
例えば複数の集約を引数に取り、そこで集約を組み合わせての判定を行っているものです。
オンラインで学習に関する様々なコースを提供するシステムにおける受講登録プロセスのケースで考えてみます。
オンライン学習システムで考えてみる
- 既存の集約:
- 受講者(Enrollee): 会員ランク、過去の成績など、受講者自身に関する情報を保持。
- コース(Course): 価格、最大受講期間など、コース自身に関する情報を保持。
受講者がコースに登録する際のコアロジックは、どちらの集約にも属せないとして EnrollmentService というドメインサービスに漏れ出ていました。
class EnrollmentDomainService {
/**
* 受講料金を計算し、登録可否を判断する
*/
fun determineEnrollmentPrice(enrollee: Enrollee, course: Course): EnrollmentDecision {
// 【判断ロジック: 複数の集約を参照】受講資格チェック
if (
course.prerequisiteRequired &&
enrollee.pastGrades.none { it.courseId == course.prerequisiteId && it.isPassing }
) {
return EnrollmentDecision(
isAllowed = false,
finalPrice = Money(0)
)
}
// 【計算ロジック: 新しい値の生成】初期料金を計算
var finalPrice = course.basePrice
if (enrollee.memberStatus == MemberStatus.PREMIUM) {
finalPrice = finalPrice.discount(0.10) // プレミアム割引
}
// 複雑な計算結果と判断を内包した新しい値オブジェクトを返す
return EnrollmentDecision(
isAllowed = true,
finalPrice = finalPrice
)
}
// ... さらに、受講期限の設定・管理、期限の延長処理など、ライフサイクルに関するロジックが続く
}
このサービスは、受講者の状態とコースの前提条件の両方をチェックし、登録を確定する初期の判断を担っていました。
しかし実際にはそれだけに留まらず、受講期限の設定・管理、期限の延長、進捗率に基づく修了判定など、受講登録後のプロセスまで手続き的に制御するようになっていました。
具体的には、EnrollmentService が扱っていた関心は、単なる受講者とコースのIDの紐付けではなく、次のようなものです。
- 開始日・終了日の決定と整合性
- 期限延長の条件と制約
- 進捗に応じた「受講中/修了/失効」などの状態遷移
ここまで扱っているのであれば、EnrollmentService に漏れ出ていた不自然な振る舞いの正体は、
「受講登録という独自のライフサイクルとルールを持つ独立したビジネス概念」だと解釈できます。
つまり、これは Enrollment(受講登録/受講契約)という新しい集約の候補 です。
Enrollment を集約として定義し、受講期間の整合性、進捗状況の更新、受講期限の延長といった振る舞いを内側にカプセル化します。
これにより、登録に関するドメインロジックは適切な集約に回帰し、調整役だった EnrollmentService は不要になるか、少なくとも極小化されるはずです。
このように、特定のユースケースで頻繁に必要になり、さらに判断に加えてライフサイクル管理までドメインサービスが抱え始めたら、その概念は集約として独立させるべきサインだと考えています。
既存の複数のドメインモデルをまとめた、新しい上位のドメインモデルがある
このパターンは、本来は同じライフサイクルを持つ概念が細かく分割され、調整役のドメインサービスが必要になるケースです。
請求システムで考えてみる
請求における月次請求書の確定プロセスを例に説明します。
- 既存の集約:
- 請求明細(BillingDetail): 請求金額、請求日など請求内容に関する情報を保持
- 請求者(Biller): 名前、所在地など請求を実施する人の情報を保持
このシステムでは 「請求金額の確定」という一つのユースケース を実行するたびに、請求明細と請求者が 必ずセットで扱われ、同時に状態遷移する 必要がありました。
本来、集約がそれぞれ独立したライフサイクルを持つのであれば、ここまで常に二つを同時に扱う必要はないはずです。この 「常にセットで動く」 という事実は、両者が実質的に 「請求書」という一つの不可分なライフサイクル を共有していることを示唆します。
しかし当時の設計では、「請求書」という単位でのモデル化ができていなかったため、ユースケースの中核ロジックは二つの集約の整合性と同時確定を成立させる調整役のドメインサービスに漏れ出ていました。
/**
* 請求明細と請求者が分離されている状態で、
* 「請求金額の確定」を成立させるための調整役のドメインサービス。
*
* 本来守りたい関心は、
* 「請求書という単位で内容と主体が一緒に確定する」こと。
* しかし集約が分かれているため、
* ここで整合性維持と同時更新を担うことになる。
*
* NOTE:
* このような構造では、同じ2集約を前提とした
* 追加の“判定ロジック(修正可否/再計算可否/取消可否など)”も
* 派生的に増えやすい。
* 確定後の永続化(repository.save など)は UseCase 側で行う想定です。
*/
class BillingFinalizationDomainService {
fun finalize(
detail: BillingDetail,
biller: Biller
) {
// 「同じ請求月の明細と請求者をセットで扱う」前提の整合チェック
require(detail.billingYearMonth == biller.billingYearMonth)
// 例: 双方が確定可能な状態か(詳細ルールは各集約の責務)
require(detail.canFinalize())
require(biller.canFinalize())
// 2集約の“同時確定”を手続き的に成立させる
return BillingFinalizationResult(
detail = detail.finalize()
biller = biller.finalize()
)
}
}
/**
* 請求書の確定結果
*/
data class BillingFinalizationResult(
val detail: BillingDetail,
val biller: Biller
)
このドメインサービスが管理していたのは、単なる「明細と請求者の紐付け」ではありません。
実態としては、
- 内容(請求明細)
- 主体(請求者)
- そして確定という状態遷移
を一つの不可分な単位として扱う「請求書」という概念の関心 そのものです。
つまり、ここで漏れ出ていた不自然な振る舞いは「請求書」という上位概念として統合されるべきサイン だと解釈できます。
統合後は、
- 「同じ請求月の内容と主体がセットで存在する」
- 「内容と主体が同時に確定状態へ遷移する」
- 「請求書としての修正・取消・再計算のルールを守る」
といった関心が 請求書集約の不変条件と振る舞い として自然に表現できるようになります。
その結果、元々で調整役として存在していたドメインサービスは請求書集約のロジックに回帰することで役割を終え、消えていくはずです。
このように、同じユースケースで常に複数集約がセットで扱われ、
その整合性維持や同時状態遷移の調整がドメインサービスとして増えてきている場合、
これは「請求書」という上位概念として統合されるべきサインであると解釈できます
個人的な見解
本記事では「ドメインサービスが増えてきた時、それは再モデリングの機運ではないか」という仮説を扱ってきました。
ただし私は、ドメインサービスが増えること自体を悪だとは考えていません。
私が重視したいのは、そのドメインサービスがユビキタス言語、またはビジネスルールとして言い切れるかどうかだと考えます。
例えば本記事中の「送金」や「年間割引率」は、複数集約にまたがる前提でもそれ自体がドメインの重要なルールとして成立する概念であるため、無理に特定の集約へ押し込むより、ドメインサービスとして独立して表現しても不自然ではないと考えています。
一方で、怪しいドメインサービスのサインは次の3つだと考えています。
- 名前がドメインの業務を説明していない
- (例:XxxManager / YyyCoordinator のように技術的な役割しか語らない)
- 同じ集約の組み合わせ前提のドメインサービスが派生して増殖する
- (例:前述の請求書集約における確定、修正可否、取消可否、再計算可否…)
- 判断だけだったドメインサービスが、状態遷移の中心になり始める
- (例: コースにおける受講開始/延長/完了/失効などの進行管理まで支配するなど)
例えば「請求明細集約と請求者集約の整合性維持」は、一見すると集約間の整合性維持というそれっぽい責務を持っているかのように見えます。しかし口座間送金のようなドメイン由来の必然というより、構造や技術的な都合で分かれているものを調整しているだけの可能性もあります。
こうした業務の言葉になりにくい調整役ドメインサービスが増えてきたら、上位概念への統合や新しい集約の発見を含めて、再モデリングを検討すべきサインだと考えています。
おわりに
本記事では、「ドメインサービスが増えてきたとき、それはモデルの構造的な歪みを示すシグナルではないか」という仮説のもと、その理由と具体的なパターンを元に考察してみました。
ドメインモデルを育てている人の参考になれば嬉しいです。
Discussion