目次を表示する

戦術的DDD実践ガイド 2026

Services と Repository ── 「どこに責務を置くか」問題

本章の方針

DDDでプロジェクトを始めると、誰もが必ず一度は迷う 問いがある。

「このロジックは Entity に書く? Service に書く? どこに置くべき?」

本章は、この問いに2026年時点の実務的な回答を与える。扱うのは Domain Service・Application Service・Repository の3つだ。特に Domain Service は「なんでもSービス」の温床になりがちで、最も厳しく扱う必要がある。


Application Service と Domain Service の違い

まず2つの Service を区別する。名前が似ていて混乱を生みやすいので、一覧表で整理する。

項目Application ServiceDomain Service
責務ユースケースの調整、トランザクション境界ドメインロジックで、どの Entity/VO にも属さないもの
別名UseCase、Workflow、InteractorDomain 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ユースケース = 1 Application Service なので、結合テストの構造が明確になる
  2. トランザクション境界が明示される:どこまでが1単位かが読めばわかる
  3. Controller(HTTPハンドラ)が薄くなる:Controllerは「DTO変換+Application Service呼び出し」だけに専念できる

Application Service の否定的な体験

  1. 1ユースケース = 1クラス でファイル数が爆発する:中規模以上のプロジェクトで100ファイル超になるのは普通
  2. ファットな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 に書けない(外部依存がある)。SubscriptionInvoice 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 を扱う。