本章の方針
DDDでプロジェクトを始めると、誰もが必ず一度は迷う 問いがある。
「このロジックは Entity に書く? Service に書く? どこに置くべき?」
本章は、この問いに2026年時点の実務的な回答を与える。扱うのは Domain Service・Application Service・Repository の3つだ。特に Domain Service は「なんでもSービス」の温床になりがちで、最も厳しく扱う必要がある。
Application Service と Domain Service の違い
まず2つの Service を区別する。名前が似ていて混乱を生みやすいので、一覧表で整理する。
| 項目 | Application Service | Domain Service |
|---|---|---|
| 責務 | ユースケースの調整、トランザクション境界 | ドメインロジックで、どの Entity/VO にも属さないもの |
| 別名 | UseCase、Workflow、Interactor | Domain Service(そのまま) |
| 例 | 「契約をアップグレードする」ユースケース | 「為替レート変換」ロジック |
| ドメイン知識 | 持たない(Entityに委譲) | 持つ(ドメインロジックそのもの) |
| 副作用 | 持つ(Repository呼び出し、イベント発火) | 純粋関数に近いことが多い |
| テスト | モック多め | プレーンな単体テスト |
この違いを先に押さえておく。
Application Service の実装
Application Service の責務は ユースケースの調整 だ。以下を担う。
1. 入力を受け取る(DTO → ドメイン型の変換)
2. Repository から集約を取得する
3. 集約のメソッドを呼ぶ(ドメインロジックは集約が持つ)
4. 集約を Repository に保存する
5. ドメインイベントを配信する
6. トランザクションの境界を張る
コード例を示す。
// [Application Service] アップグレードのユースケース
class UpgradeSubscriptionHandler {
constructor(
private readonly subscriptionRepository: SubscriptionRepository,
private readonly planRepository: PlanRepository,
private readonly unitOfWork: UnitOfWork,
private readonly eventBus: EventBus,
) {}
async handle(command: UpgradeSubscriptionCommand): Promise<void> {
await this.unitOfWork.transaction(async () => {
// 1. 集約を取得
const subscription = await this.subscriptionRepository.findById(
command.subscriptionId,
)
if (!subscription) throw new Error('Subscription not found')
const newPlan = await this.planRepository.findById(command.newPlanId)
if (!newPlan) throw new Error('Plan not found')
// 2. ドメインロジックは集約に委譲
subscription.upgrade(newPlan.id)
// 3. 永続化
await this.subscriptionRepository.save(subscription)
// 4. イベント配信(Outbox経由が望ましい)
await this.eventBus.publish(new SubscriptionUpgraded(
subscription.id,
command.newPlanId,
))
})
}
}
重要なのは、Application Service は ドメインロジックを書かない ということだ。「アップグレード可能かの判定」「状態遷移の実行」は subscription.upgrade() の中で起こる。
Application Service の肯定的な体験
- テストの入口が決まる:1ユースケース = 1 Application Service なので、結合テストの構造が明確になる
- トランザクション境界が明示される:どこまでが1単位かが読めばわかる
- Controller(HTTPハンドラ)が薄くなる:Controllerは「DTO変換+Application Service呼び出し」だけに専念できる
Application Service の否定的な体験
- 1ユースケース = 1クラス でファイル数が爆発する:中規模以上のプロジェクトで100ファイル超になるのは普通
- ファットなApplication Service が生まれる:迷ったロジックを入れがちで、事実上の Transaction Script 化する
これらは「大きなプロジェクト」では実質避けられない問題だ。ファイル数の増加は受け入れる、Application Service が太り始めたら、それは Domain Service か新しい Entity の合図 と捉える。
Domain Service:最も危険なパターン
Domain Service の定義は次のとおり。
Domain Service = ドメインロジックで、どの Entity/VO にも自然に属さないもの
この定義は 意図的に狭い。「どれにも属さない」ものだけを対象にする。ところが実務では、この狭さが守られず、「なんでもService」の温床になる。
Domain Service が本当に必要な例
明確に適切な例から見る。
// [Domain Service] 為替レート変換
// → Money VOには外部依存(為替レートリポジトリ)を持たせたくない
// → どのEntityにも属さない純粋なドメインロジック
class CurrencyExchangeService {
constructor(private readonly rateRepository: ExchangeRateRepository) {}
async convert(money: Money, to: Currency, at: Date): Promise<Money> {
const rate = await this.rateRepository.rateAt(
money.currency,
to,
at,
)
return Money.of(money.amount * rate, to)
}
}
このロジックは Money VO に書けない(外部依存がある)。Subscription や Invoice Entity にも属さない(為替はドメイン横断の概念)。よって Domain Service が妥当。
Domain Service のアンチパターン
// ❌ よくある「なんでもサービス」
class SubscriptionService {
upgrade(sub: Subscription, newPlan: Plan) {
if (sub.status !== 'active') throw ...
sub.planId = newPlan.id // ← Entity のフィールドを直接触っている!
// ...
}
cancel(sub: Subscription, asOf: Date) {
sub.status = 'canceled' // ← 同様
sub.canceledAt = asOf
}
}
これは Domain Service の衣を着た Transaction Script だ。ロジックが Service に染み出し、Subscription Entity は ただのデータホルダ(Anemic Model) になる。このパターンは Ch.9 のAnemic論争でも扱うが、明確にDDDに反する パターンだ。
判断フロー
「これは Domain Service か?」と迷ったら、次の順で判断する。
flowchart TD
Q1{ロジックの対象は<br/>特定のEntityまたはVOか?}
Q1 -->|はい| Place1[そのEntity/VOに書く]
Q1 -->|いいえ| Q2{複数の集約の<br/>調整役か?}
Q2 -->|はい| Place2[Application Serviceに書く]
Q2 -->|いいえ| Q3{ドメインロジックで、<br/>どの集約にも属さないか?}
Q3 -->|はい| Place3[Domain Serviceに書く]
Q3 -->|いいえ| Place4[書く場所を再検討]
実務では90%のケースが Q1 か Q2 で止まる。Domain Service に辿り着くのは残り10%だ。
Repository:永続化との境界
Repository は 集約の永続化を抽象化する 責務を持つ。ドメイン層からはコレクションのように見え、内部ではDB操作が行われる。
// [Repository] インターフェース(ドメイン層に配置)
interface SubscriptionRepository {
findById(id: SubscriptionId): Promise<Subscription | null>
save(subscription: Subscription): Promise<void>
findActiveByCustomer(customerId: CustomerId): Promise<Subscription[]>
}
// [Repository] 実装(インフラ層に配置)
class PostgresSubscriptionRepository implements SubscriptionRepository {
constructor(private readonly db: DatabaseClient) {}
async findById(id: SubscriptionId): Promise<Subscription | null> {
const row = await this.db.query(
'SELECT * FROM subscriptions WHERE id = $1',
[id.toString()],
)
return row ? this.toEntity(row) : null
}
async save(subscription: Subscription): Promise<void> {
const snapshot = this.toSnapshot(subscription)
await this.db.query(
`INSERT INTO subscriptions (...) VALUES (...)
ON CONFLICT (id) DO UPDATE SET ...`,
[...]
)
}
// Entity ⇔ 永続化DTO の変換
private toEntity(row: SubscriptionRow): Subscription { /* ... */ }
private toSnapshot(entity: Subscription): SubscriptionSnapshot { /* ... */ }
}
Repository と ORM の関係:2026年の論争
Repository パターンは、2026年時点で最も解釈が分かれるDDDパターン だ。現代のORM(TypeORM・Prisma・Drizzle・MikroORM)は十分強力で、「Repository抽象は無駄では?」という批判が根強い。
Prisma に対する典型的な批判
// Prismaを使った例
const subscription = await prisma.subscription.findUnique({
where: { id: subscriptionId },
include: { usageRecords: true },
})
批判側の主張:
- Prisma自体が「Repository的な役割」を果たしている
- 薄いRepositoryラッパーを書くのは、単に 「Prismaの呼び方を変えただけ」 の抽象
- 抽象のコストに見合うリターンがない
Repository を維持する派の主張
支持側の主張:
- Entityの内部状態(プライベートフィールド)をORMに直接扱わせない
- DB差し替え・テスト用の In-Memory 実装が書ける
- ドメイン層の純粋性が保たれる
2026年の現実解
筆者の立場は次のとおり。
・「Repository必要か?」の答えはプロジェクト次第
・Entity を意味のあるメソッド付きクラスとして設計している場合:
→ Repositoryが必要(ORMのスナップショット ⇔ Entity の変換が必要なため)
・Entity が実質プレーンなデータクラス(Anemic Model)の場合:
→ Repositoryは冗長。Prisma/Drizzleで直接OK
つまり、Repositoryを作るかは、Entityの設計方針と連動する。Entityにメソッドを持たせる設計を選ぶなら、Repositoryは必然。Anemic Modelを受け入れるなら、ORMで十分。どちらも選択肢として成立する。
Repository 実装の3つの落とし穴
落とし穴1:Repository が DAO になる
// ❌ Repository の顔をした DAO
interface SubscriptionRepository {
findById(id): Promise<Subscription | null>
findByCustomerId(id): Promise<Subscription[]>
findByStatus(status): Promise<Subscription[]>
findByPlan(planId): Promise<Subscription[]>
findCreatedBetween(from, to): Promise<Subscription[]>
findExpiring(within): Promise<Subscription[]>
findWithFilter(filters: ComplexFilter): Promise<Subscription[]> // ← 危険信号
}
検索条件が無限に増えていくRepositoryは、集約の境界を飛び越えて、ドメインロジックを漏らしている。対策は以下。
- 読み取り専用クエリは Repository の外に出す(後述のCQRS)
- Repository は「集約を単位として取得/保存する」操作に絞る
落とし穴2:Repository で集約境界を無視したロード
// ❌ 集約境界を無視
interface SubscriptionRepository {
findById(id): Promise<{
subscription: Subscription,
customer: Customer, // ← 別集約を連れてきている
invoices: Invoice[], // ← 別集約を連れてきている
}>
}
Repository は自分の集約しか返さない。他の集約が必要なら、それぞれの Repository を使う。このルールを破ると、Ch.5 で述べた「集約間の暗黙の依存」が再発する。
落とし穴3:読み取り用途でもドメインEntityを復元する
// ❌ リスト表示用にEntityを全件ロード
async listSubscriptions(): Promise<SubscriptionListItem[]> {
const subs = await repo.findAll() // Entity として復元
return subs.map(s => ({ id: s.id, status: s.status, planName: ... }))
}
リスト表示は 読み取り専用。Entityを復元する必要はない。CQRS的に 読み取り専用のQueryサービス を分離する。
// ✅ 読み取り専用Queryサービス
class SubscriptionListQuery {
constructor(private readonly db: DatabaseClient) {}
async list(filter: ListFilter): Promise<SubscriptionListDto[]> {
// SQLで直接DTOを生成(Entityは経由しない)
return this.db.query(...)
}
}
これは CQRS(Command Query Responsibility Segregation) の考え方だ。「書き込み側は Entity+Repository、読み取り側は DTOを直接返す Query」に分離する。
Services と Repositoryの肯定的な体験
体験1:Application Service導入後、Controllerが劇的に薄くなった
HTTPコントローラは「HTTP特有の処理+Application Serviceの呼び出し」だけに絞れた結果、Controllerを触らずにCLI・ワーカ・Webhookから同じユースケースを実行できる ようになった。
体験2:Repository のおかげでテストが書ける
In-Memory な Repository 実装を用意することで、ドメインロジックのテストをDB不要で書ける ようになった。CI時間が10分の1に短縮された実感がある。
Services と Repositoryの否定的な体験
体験1:Domain Service が「雑務の置き場」になった
「どこに書けばいいか分からない処理」を Domain Service に置いた結果、数十個の Service が生まれ、それぞれが薄い関数集約 になった。「XxxService.doYyy()」という名前は、責務が曖昧なサイン だと気づいた。
体験2:Repository の抽象コストが回収できなかった
小規模プロジェクトで Repository を厳密に作ったが、テスト用の In-Memory 実装を用意する以上の価値は引き出せなかった。小規模では Prisma/Drizzle 直接アクセスで十分 と判断できるケースは多い。
本章のまとめ
・Application Service = ユースケースの調整役。ドメインロジックは持たない
・Domain Service = どのEntity/VOにも属さない「最後の受け皿」。狭く使う
・Repository = 集約の永続化の抽象。ORMが強力な時代は必要性が論争的
・読み取りクエリは Repository から分離する(CQRS)
・判断フローは「まず Entity/VO → 次に Application → 最後に Domain Service」
次章では、集約間の結合を疎にする武器── Domain Events を扱う。