コンテンツにスキップ

Kotlinでのドメインサービス実装例

ドメインサービスの概念を Kotlin で実装する際の注意点とコード例をまとめます。
ドメインサービスそのものの考え方を先に確認したい場合は、次の記事を参照してください。

Kotlin でドメインサービスを実装するときは、次の点を意識します。

  • ドメインの言葉で名前を付けるUserService のような広い名前ではなく、UserNameDuplicationService のように扱うルールが分かる名前にします。
  • 状態を持たせない — 判定に必要な問い合わせはしても、サービス自身に処理中の状態や前回結果を保持させません。
  • リポジトリ依存は必要な範囲に留める — 外部情報が必要なドメイン判断に集中し、ユースケース全体の流れはアプリケーションサービスへ残します。
  • エンティティから責務を奪いすぎない — 対象オブジェクト自身で判断できるルールは、まずエンティティや値オブジェクトに置きます。
  • 集約ルートはファクトリ経由で生成する — コンストラクタを private にし、生成時のルールは create などのファクトリメソッドに集約します。

エンティティを private constructor とファクトリメソッドで実装する理由は、次の記事でも説明しています。


ここでは「プレイヤーをパーティーへ参加させる」例で、ドメインサービスを使った実装を見ていきます。

プレイヤーは同時に 1 つのパーティーにしか所属できない、というルールを PartyMembershipService にします。

class PartyMembershipService(
private val partyRepository: PartyRepository
) {
fun verifyCanJoin(party: Party, playerId: PlayerId) {
val existingParty = partyRepository.findByMember(playerId)
require(existingParty == null || existingParty.id == party.id) {
"プレイヤーは既に別のパーティーに所属しています。"
}
}
}

PartyMembershipServicePartyRepository を使って既存の所属状況を確認します。
この判断は、参加先の Party だけを見ても分からないため、ドメインサービスとして表現しています。

Party 自身が持っているメンバー数の上限など、集約内で完結するルールは Party に置きます。

class Party private constructor(
val id: PartyId,
private val memberIds: MutableList<PlayerId>
) {
fun addMember(playerId: PlayerId) {
require(memberIds.size < 4) {
"パーティーには最大4人までしか参加できません。"
}
require(playerId !in memberIds) {
"同じプレイヤーは重複して参加できません。"
}
memberIds.add(playerId)
}
companion object {
fun create(id: PartyId, ownerId: PlayerId): Party {
val party = Party(id, mutableListOf())
party.addMember(ownerId)
return party
}
fun reconstruct(id: PartyId, memberIds: List<PlayerId>): Party {
require(memberIds.isNotEmpty()) {
"パーティーには最低1人のメンバーが必要です。"
}
require(memberIds.size <= 4) {
"パーティーには最大4人までしか参加できません。"
}
require(memberIds.distinct().size == memberIds.size) {
"同じプレイヤーは重複して参加できません。"
}
return Party(id, memberIds.toMutableList())
}
}
}

「最大 4 人まで」「同じパーティー内で重複しない」は Party の内部状態だけで判断できます。
そのため、これらはドメインサービスにせず、Party のメソッドとして表現します。

また、コンストラクタを private にしているため、外部から不正な memberIds を渡して Party を直接生成することはできません。
新規作成は create、DB からの復元は reconstruct のように入口を分け、生成時にも集約ルート自身のルールを通すようにします。

private constructor にして生成経路を絞る考え方は、エンティティ実装の基本方針と同じです。

アプリケーションサービスは、リポジトリから集約を取得し、ドメインサービスで必要なルールを確認し、集約のメソッドを呼び出して保存します。

class JoinPartyUseCase(
private val partyRepository: PartyRepository,
private val membershipService: PartyMembershipService
) {
fun execute(partyId: PartyId, playerId: PlayerId) {
val party = partyRepository.find(partyId)
membershipService.verifyCanJoin(party, playerId)
party.addMember(playerId)
partyRepository.save(party)
}
}

この形にすると、責務は次のように分かれます。

クラス責務
JoinPartyUseCaseユースケースの流れを組み立てる
PartyMembershipService他パーティーへの所属有無を確認する
Party自分自身のメンバー追加ルールを守る
PartyRepositoryParty の取得・保存を担当する

ただし、この実装では membershipService.verifyCanJoin(...) の呼び出しを忘れると、他パーティー所属チェックが抜けてしまいます。
private constructor とファクトリメソッドは生成時のルールを守るための入口制御であり、外部情報を使う変更時の検証漏れまでは防ぎきれません。
このような適用漏れを構造的に防ぎたい場合は、ダブルディスパッチという選択肢があります。


ユーザー名の一意性は、これから作る User 自身だけでは判断できません。
既存ユーザー全体を参照する必要があるため、ドメインサービスとして表現できます。

class UserNameDuplicationService(
private val userRepository: UserRepository
) {
fun exists(userName: UserName): Boolean {
return userRepository.existsByName(userName)
}
}

アプリケーションサービスでは、登録の流れの中でこのドメイン判断を呼び出します。

class RegisterUserUseCase(
private val userRepository: UserRepository,
private val duplicationService: UserNameDuplicationService
) {
fun execute(command: RegisterUserCommand) {
val userName = UserName(command.userName)
require(!duplicationService.exists(userName)) {
"同じユーザー名は使えません。"
}
val user = User.create(userName)
userRepository.save(user)
}
}

アプリケーションサービスは「登録する」という手順に集中し、重複確認というドメイン判断は UserNameDuplicationService にまとまります。


UserServiceOrderService のような広すぎる名前は、責務が膨らみやすくなります。
UserNameDuplicationServicePartyMembershipServiceShippingFeeCalculator のように、何のルールを扱うのかが分かる名前にします。

エンティティから責務を奪いすぎない

Section titled “エンティティから責務を奪いすぎない”

ドメインサービスを増やしすぎると、エンティティがただのデータ入れ物になってしまいます。
対象オブジェクト自身が判断できるルールは、まずそのオブジェクトに置きます。

class Order private constructor(
private val status: OrderStatus
) {
fun canCancel(): Boolean {
return status == OrderStatus.Pending
}
companion object {
fun create(): Order {
return Order(OrderStatus.Pending)
}
fun reconstruct(status: OrderStatus): Order {
return Order(status)
}
}
}

このように Order 自身の状態だけで判断できるなら、OrderService.canCancel(order) にする必要はありません。

エンティティや集約ルートの生成ルールも、できるだけコンストラクタを直接公開せず、ファクトリメソッドに集約しておくと安全です。
この理由は、次の記事でも扱っています。

アプリケーションサービスと混同しない

Section titled “アプリケーションサービスと混同しない”

アプリケーションサービスは「いつ、どの順番で、何を呼ぶか」を調整します。
ドメインサービスは「ドメイン上どう判断するか」を表現します。

たとえば、トランザクション開始、認可チェック、メール送信、保存順序の制御はアプリケーションサービス側の関心ごとです。
それらをドメインサービスに入れると、ドメインモデルがユースケースやインフラの事情に引っ張られてしまいます。

リポジトリ依存は必要な範囲に留める

Section titled “リポジトリ依存は必要な範囲に留める”

ドメインサービスがリポジトリを使うこと自体は珍しくありません。
ただし、リポジトリを使って取得したオブジェクトを大量に操作し始めると、ドメインサービスが複雑な手続きの置き場所になってしまいます。

問い合わせが必要なドメイン判断に集中し、ユースケース全体の流れはアプリケーションサービスに残します。


Kotlin でドメインサービスを実装するときは、エンティティや値オブジェクトに自然に置きにくいドメイン判断だけを切り出します。

ポイントは次の 3 つです。

  1. 名前で扱うルールを表す — 広すぎる XxxService ではなく、業務上の判断が分かる名前にする
  2. ステートレスに保つ — 判定に必要な情報は使っても、サービス自身に状態を持たせない
  3. ユースケースの流れを持ち込まない — 取得、保存、トランザクション管理はアプリケーションサービスに残す