🐷

DDDを実践するための手引き(概論・導入編)

2021/11/11に公開

ナニコレ

DDDは「Domain-Driven Design(ドメイン駆動設計)」の略語で、エリック・エヴァンスさんという人が考えるソフトウェア設計におけるプラクティスまとめみたいなものです。

エリック・エヴァンスのドメイン駆動設計』というバイブル的な書籍がありますが、「途中で挫折した」「読んでもよくわからない」「よくわからないけど自分なりに解釈して実践している」というような感想をよく聞きます[1]。DDDの概念は幅広く、哲学的で、抽象的であるため、DDDをどのように解釈しどのように実践すればいいのかわかりにくいものです。

この記事ではそのような問題に悩んでいる人たちのために、数年に渡りDDD(的なもの)を実践してきた筆者が噛み砕いた(個人の独断的な)解釈と実践方法を解説します。

DDDってなぁに?

DDDがカバーする領域

DDDが言及する範囲はとても幅広いです。エリック・エヴァンスさんが言っていることを分類すると以下の領域に分けられます。

  • 思想・哲学としてのDDD ...複雑になりがちなシステム開発にどう取り組むか。開発の考え方や進め方
  • 設計戦略としてのDDD (戦略的DDD) ...ドメインモデリングに繋げるためのアプローチの方針 (ドメインエキスパートとの協調、ユビキタス言語、境界づけられたコンテキスト等)
  • 実装パターンとしてのDDD (戦術的DDD) ...ドメインモデルを実装レベルで体現するためのパターン(エンティティ、リポジトリ、レイヤードアーキテクチャ等)

幅広いですね。。。

DDDの定義

エリックさんによるとDDDは以下の4つの原則から成り立ちます。

  • Focus on the core complexity and opportunity in the domain
  • Explore models in a collaboration of domain experts and software experts
  • Write software that expresses those models explicitly
  • Use a ubiquitous language inside a bounded context

出典: https://www.zweitag.de/en/blog/ddd-europe-2016

日本語にするとこんな感じになると思います。

  • ドメイン(*1)の中核となる複雑さと機会にフォーカスする
  • ドメインエキスパート(*2)とソフトウェアエキスパートのコラボレーションからモデルを探求する
  • そのモデルをそのまま表現するソフトウェアを書く
  • 境界づけられたコンテキスト(*3)におけるユビキタス言語(*4)を用いる

(*1)...システムの対象とする業務領域のこと
(*2)...ドメインに関する深い知識を持っている人
(*3)...特定のモデルが定義され適用される境界(サブシステムとか)
(*4)...ドメインモデルに基づいた、チームで使う共通言語

実践的な意味で噛み砕くと

こんなイメージだと思います。

  • システムが適用されるユーザーの業務や文脈(ドメイン)を深く知ることからはじめよう。
  • ドメインに関する知識を持っている人たちと対話・協力しながら、システムを構成する業務ルールや概念をドメインモデルとして練り上げよう。
  • WEBとかDBのような"手段"や"技術"は一旦脇に置いておき、システムの問題領域に根ざす知識・ルールを純粋なモデルとして作り上げていこう。
  • プログラミング言語の表現力を駆使して、そのモデルそのものをそのまま表すコードを書こう。
  • エンジニアも非エンジニアも同じモデルを心に携え、いたる所でそのモデルに基づいた共通言語を使って会話・作業しよう。

なお、たまにある勘違いとして、「DDDとは既存の業務ルールをそのままモデル、ソフトウェアに落とし込むこと」と捉えられてしまうことがあります。これでは単なる現状をデジタル化しただけのもので終わってしまいます。

しかし、モノづくりとはもっとクリエイティブなもののはずです。プロダクトのベストな形を考えて示していくのは最終的には作り手の使命であり、そのようにして作られたプロダクトは今までになかった新たなドメインを世界にもたらします。

なのでDDDとは、どちらかとういうと、「ドメイン(=対象とする業務の世界やコンテキスト)を把握した上で、その知識を材料にプロダクトの扱う業務仕様をドメインモデルとして練り上げ、そのモデルを動くソフトウェアに落とし込むこと」ということだと思います。

重要なポイント

ドメインモデルとモデリング

ドメインモデルとはユーザーの問題を解決するために蒸溜され抽象化された知識です。

ちょっと抽象的な話になってしまいますが、すべてのプロダクトは固有の概念(モデル)を持っています。(Twitterで言うと”ツイート”や”タイムライン”、”ユーザー”など。)
プロダクトとは概念の集合体そのもののことであり、プロダクトを閲覧/操作することは概念そのものを閲覧/操作することと言えます。

DDDでは、仕様を作ること/設計することはドメインモデルを作ることであり、そのモデルはコードで表現されるようになります。そうすると、"仕様=モデル=コード"となり、コードが仕様そのものを表すようになるはずです。

また、モデルに基づいた言葉/図を用いてコミュニケーションすることにより、開発者もドメインエキスパート(問題領域の知識を持っている人たち/専門家)も通訳なしに同じ話ができるはずです。

モデリング〜実装の流れ

以下のようなイメージになると思います。

① 開発者とドメインエキスパートの共同作業によってモデルを見つけ出す
② モデルをコードで表現する
③ 継続的に学習しモデルを見直していく

① 開発者とドメインエキスパートの共同作業によってモデルを見つけ出す

エヴァンス本では専門家と対話しながらモデルを練り上げて行く事例が紹介されています。そこではUMLに似た図を用いて意思疎通を図っていますが、現在ではいろいろな人がいろいろな手法を考案しているので、いろいろ調べて試してみるといいと思います。(イベントストーミングなど)

共通するポイント:

  • ドメインエキスパートと対話しながら知識を深掘りし、その知識を噛み砕いてシステムで扱う概念として抽象化・蒸溜する → ドメインモデル
  • ドメインモデルは技術仕様ではなく、業務の概念を表現したものである。そのため開発の専門家でなくても同じドメインモデルを想起しながら話ができるはず。
  • ドメインモデルに基づいた図や言葉を使って対話し、対話から得た知識や洞察をモデルに反映する、というループ
  • 図は説明を助けるもので、モデルそのものではない
    • 成果物としての図より、参加者の共通理解が重要

② モデルをコードで表現する

  • オブジェクト指向言語の表現力を駆使し、ドメインモデルをコードに落とし込む。(ドメインオブジェクト)
    • コードは「処理の手続きを記述する」のではなく、「モデルをそのまま表現する」という感じ。
    • オブジェクト指向以外のパラダイムでもモデルをうまく表現することは可能(Domain Modeling Made Functional など)
  • モデルとコードは常に密に結びつく。
    • モデルの変更は即座にコードに反映する。
    • 逆にコーディング中に得られた新たな知識や洞察はモデルに反映する。
  • モデリングする人/実装する人、のような分断を避ける。
    • コードを書くことは設計そのものの一部。
    • 実装者はモデルを深く理解し、モデルがどうあるべきかにコミットすべき。

③ 継続的に学習しモデルを見直していく

  • モデルは一度定義して終わりではない。より深い洞察を得て改善する。
  • 開発初期における理解は不十分であることが多い
  • 状況は変化する

ユビキタス言語

ユビキタス言語とは、ドメインモデルに基づくチームの共通言語で、チーム内のすべての活動でこれを使うように努力します。

  • ドメインエキスパートと話すとき、開発者と話すとき、コードを書くとき、ドキュメントを書くとき、テストケースを書くとき、一貫し同じユビキタス言語を使用する。
  • ユビキタス言語はチーム内のすべての人や活動を繋ぐための水路。
  • ユビキタス言語はドメインモデルに基づく言語であり、この言葉を話す/聞く人たちの脳内ではモデルが想起される。
    • 言葉にぎこちなさや不明瞭さがあれば、それはモデルに問題があるというサイン。

境界づけられたコンテキスト

あるドメインモデルを適用できるコンテキストの範囲のことです。
巨大なシステムの場合、すべての問題を単一のモデルで解決しようとすると、いびつで不明瞭なモデルになりがちです。例えばECサイトで「商品」という概念を考えるとき、出品管理・販売・会計・配送などそれぞれの文脈で「商品」が解決したい問題や求められるふるまい・性質は大きく違いそうです。その場合、各コンテキスト内において役立つモデルをそれぞれ定義すべきかもしれません。

  • 境界を区切ることによって、あるモデルやユビキタス言語が適用できるコンテキストを明確にする。
    • モデルの純粋性・明確さを保つことができる。
    • モデルの概念や言語の混同や混乱がなくなる。
    • 境界外のことに惑わされなくなる。(モデルを境界外に適用できるかどうか気にしなくていい)
  • 実装レベルでは、1つのコンテキストは1つのモジュールもしくはサービスになる。
    • コンテキストは境界内のドメインモデルをカプセル化し、他のコンテキストとの連携に必要なインターフェイスを提供する。
  • 境界はチーム編成と相互に影響する。
    • 基本的に1つのコンテキストは1つのチームのみによって開発・運用される。

ドメインを技術的関心事から隔離する

DDDでシステムを構築する場合、ドメインに関する実装をクリーンに保つために、ドメイン層(ドメインオブジェクトを記述する場所)をそれ以外の関心事と切り離して独立させることが必須です。

レイヤードアーキテクチャ、ヘキサゴナルアーキテクチャ、オニオンアーキテクチャなど、いろいろな切り分け方がありますが、重要なのは中核(ドメイン層)に他の何かが紛れ込まないようにすることです。

レイヤードアーキテクチャの場合

※矢印は依存の方向

プレゼンテーション層 (UIとか)

アプリケーション層 (ユースケースとか)

ドメイン層 (ドメインオブジェクトとか)

インフラ層 (DBとか)

  • ドメイン層がソフトウェアの核心。技術的な問題ではなく、ドメインの"価値のある、複雑な部分"にフォーカスする
  • ドメイン層は他のどの層にも依存しない。する必要がない。
  • ドメインの世界ではDBやWEBなどの具体的な手段の話は一切出てこないし、それに依存することもない
  • ドメイン層に業務仕様を表すピュアなモデルを作り上げていくことにこだわる
  • ドメイン層以外に業務ロジックが漏れ出している場合は、ドメイン層に集めていく

ドメイン層を作る作業は、システムの業務仕様そのものを抽出し、ライブラリ化、API化していくイメージに近いと思います。
上図の依存の方向を見てもわかるように、ドメイン層は他の層によって使われるために存在しています。なのでドメイン層の実装をするときは、使う側にとって理解しやすく、間違って使われる余地がない概念・インターフェイスになるように心がけるのがコツだと思います。

それで、どうやってモデルを実装するの?

まずモデリングのルール

無秩序にモデリングするよりも一定の秩序があったほうが理解しやすいし効率がいい。
DDDは基本的には↓のようなパターンでモデリングします。

エンティティ

  • ドメインモデルの主役
  • アイデンティティを持つオブジェクト
  • 時間とともに変化しても「アイデンティティ(ID)」が同じものは同一のエンティティ
    • 例えば"社員"を扱うシステムにおいて、山田太郎という社員がいたとして、異動によって所属部署や役職が変わったとしても同じ山田太郎という社員である
    • "社員"はアイデンティティによって同一性が識別されるエンティティである、と言える
  • 逆に、属性がまったく同じでも、アイデンティティが違えば別物となる
    • 同姓同名で生年月日など全ての属性が同じ社員が2人いても、2人は同一人物ではなく別人である
  • データベースやORMのエンティティとは意味が違う。単なるデータの入れ物ではない。
  • エンティティはバリューオブジェクトや子エンティティを持っている場合があり、このエンティティを起点とするオブジェクトのまとまりを「集約 (Aggregate)」と呼び、起点となるエンティティを「集約ルート (Aggregate Root)」と呼ぶ

余談:
ソフトウェアはその時々の「状態」を持っていて、その状態の変化によって基本的なビジネスロジックが実現されます。(例えばTwitterで誰かがつぶやけば"ツイート"というオブジェクトが新規作成された状態になる)
DDD的な設計において、ソフトウェアの状態は各種エンティティの状態(の集合)であると言えます。エンティティの状態が変化(作成/変更/削除)することによって基本的なビジネスロジックが実現するので、基本的にはエンティティはミュータブルなクラスとして実装されます。(イミュータブルにする設計もありえます。)

バリューオブジェクト

  • 値や属性を表すもの
  • たとえば「色」や「量」のように、その属性だけが重要で、アイデンティティを考えることに意味のないオブジェクト
  • 不変。イミュータブル
  • 1つの値(バリューオブジェクト)が複合的な値から成り立つような場合もある
    • 例えば「住所」という値は、「郵便番号」「都道府県」「市区町村」「番地」「建物名・部屋番号」といった値から成り立つ、というようにモデリングすることができる

ドメインサービス

  • モノではなく純粋な処理
  • 1つの機能や処理が単体で存在していて、もの(オブジェクト)として扱うのが不自然なものをサービスとして定義
  • 純粋な処理であり、状態を持たない

リポジトリ

  • 集約ルートとなるエンティティとペアで用意する
  • エンティティは、作成→変更(*N回)→削除のようなライフサイクルを辿るが、そのエンティティ(集約)の今の状態を保存しておくものがリポジトリ
  • 保存したエンティティ(集約)を必要なときに復元して返す
  • 一般的にリポジトリのインターフェイスだけがドメイン層に置かれる
    • リポジトリの実装はインフラ層に置かれる
    • ドメイン層にとって重要なのはリポジトリのインターフェイスだけであり、それがどのように実装されるかはどうでもいい
    • リポジトリの実装のバックエンドにはRDBがよく使われるが、NoSQLでもKVSでも、エンティティ(集約)を保存、復元できればなんでもいい
  • リポジトリはエンティティを渡したらそのまま保管しておいてくれて、必要な時に保存時と全く同じ状態で取り出せる、という単純な仕様であるので、複雑なロジックが入り得ないし、使う側が中のロジックを知る必要がない

サンプル: 実際にどうやって実装するか

"通話"を管理するシステムを考えてみます。言語はJavaで実装してみます。 Kotlinに変えました。

システムとして次の業務を実現したいとします。

  • 電話をかける(相手を指定して呼び出す)
  • キャンセルする(相手が応答する前に通話をやめる)
  • 通話開始(相手が応答し、通話が始まる)
  • 通話終了(通話を終える)

まず「通話」(電話がかけられてから終了するまで)を表したモデルを作ってみましょう。
ドメインエキスパートとの対話から引き出した情報等から、「通話」に必要な構成要素は↓みたいな感じだとわかったとします。(※筆者は通話業務に詳しいわけではないので細かいところは適当)

  • かけた人
  • かけられた人
  • ステータス(電話かけてつながるまで or キャンセル or 通話中 or 終了)
  • かけた日時
  • 通話が開始日時(繋がった日時)
  • 通話が終了した日時
  • 通話料金

普通のデータ中心アプローチ的なやり方

まず普通のデータ中心アプローチに近い実装を考えてみます

通話を表したモデル

/** 通話 */
class PhoneCall {
  /** ID */
  var id: UUID? = null

  /** かけた人(のユーザID) */
  var callerUserId: UUID? = null

  /** かけられた人(のユーザID) */
  var receiverUserId: UUID? = null

  /** ステータス */
  var status: Int? = null

  /** かけた日時 */
  var createdAt: Calendar? = null

  /** 繋がった日時 */
  var talkStartedAt: Calendar? = null

  /** 通話が終了した日時 */
  var finishedAt: Calendar? = null

  /** 通話料金 */
  var callCharge: Int? = null
}

/**
 * 通話ステータス
 */
object PhoneCallStatus {
  const val WAITING_RECEIVER: Int = 1
  const val CANCELED: Int = 2
  const val TALK_STARTED: Int = 3
  const val FINISHED: Int = 4
}

ユースケースのロジック

/**
 * 電話をかける
 *
 * @param callerUserId   かける人
 * @param receiverUserId かけられる人
 * @return 通話ID
 */
fun makePhoneCall(callerUserId: UUID, receiverUserId: UUID): UUID {
  val phoneCall = PhoneCall()
  phoneCall.id = UUID.randomUUID()
  phoneCall.callerUserId = callerUserId
  phoneCall.receiverUserId = receiverUserId
  phoneCall.createdAt = Calendar.getInstance()
  phoneCall.status = PhoneCallStatus.WAITING_RECEIVER
  phoneCallRepository.save(phoneCall)
  return phoneCall.id!!
}

/**
 * 通話をキャンセルする
 *
 * @param phoneCallId 通話ID
 */
fun cancelPhoneCall(phoneCallId: UUID) {
  val phoneCall = phoneCallRepository.findById(phoneCallId)
  phoneCall.status = PhoneCallStatus.CANCELED
  phoneCall.finishedAt = Calendar.getInstance()
  phoneCallRepository.save(phoneCall)
}

/**
 * 通話開始
 *
 * @param phoneCallId 通話ID
 */
fun startTalking(phoneCallId: UUID) {
  val phoneCall: PhoneCall = phoneCallRepository.findById(phoneCallId)
  phoneCall.status = PhoneCallStatus.TALK_STARTED
  phoneCall.talkStartedAt = Calendar.getInstance()
  phoneCallRepository.save(phoneCall)
}

/**
 * 通話終了
 *
 * @param phoneCallId 通話ID
 */
fun finishPhoneCall(phoneCallId: UUID) {
  val phoneCall: PhoneCall = phoneCallRepository.findById(phoneCallId)
  phoneCall.status = PhoneCallStatus.FINISHED
  phoneCall.finishedAt = Calendar.getInstance()
  phoneCall.callCharge = calculateCharge(phoneCall)
  phoneCallRepository.save(phoneCall)
}

要件を満たしていて、一見問題ないコードができました。
が、DDD的にはまだあまりよろしくない。

問題1: 「通話」というモデルを正確に表せていない(ドメインモデル貧血症)

通話クラスはただのデータの入れ物になっている。このクラスには属性以外の情報がなく、モデルをちゃんと表せていない。
→「通話」というモデルをもっと正確に表せるようにしましょう

問題2: なんでもできてしまう

例えば、ステータスを「通話終了」にしたけど「通話終了日時」「通話料金」を入れ忘れてしまうことができてしまいます。
このモデルを使う側に知識がないと簡単にバグを作り込んでしまう可能性があります。
→理解しやすく、使い方に間違えようのないモデルを作りましょう。ドメインモデルは使う側のためにあります

問題3: ミュータブルな値をフィールドとして持っている

イミュータブルでない値を属性に持つと、その値がどこで変更されてしまうかわからないので、気にしなければいけないことが増えます

val phoneCall = PhoneCall()
val now = Calendar.getInstance()
phoneCall.finishedAt = now
// としたあとに
now.set(Calendar.HOUR, 1)
// とすると、`phoneCall`の中の値も変わってしまう

改善後: もっとDDD的なやり方

通話エンティティ

/** 通話 */
class PhoneCall(
  id: PhoneCallId,
  caller: UserId,
  receiver: UserId,
  status: PhoneCallStatus,
  createdAt: LocalDateTime,
  talkStartedAt: LocalDateTime?,
  finishedAt: LocalDateTime?,
  callCharge: Price?,
) {
  /** ID  */
  val id: PhoneCallId = id // ただのUUIDではなく、属性の意味を正確に表す値オブジェクトを作った

  /** かけた人  */
  val caller: UserId = caller // 通話作成時に必ず指定されて、変更されないので、null不可にしてfinalにする

  /** かけられた人  */
  val receiver: UserId = receiver

  /** ステータス  */
  var status: PhoneCallStatus = status; private set // セッターは公開しない

  /** かけた日時  */
  val createdAt: LocalDateTime = createdAt

  /** 繋がった日時  */
  var talkStartedAt: LocalDateTime? = talkStartedAt; private set

  /** 通話が終了した日時  */
  var finishedAt: LocalDateTime? = finishedAt; private set

  /** 通話料金  */
  var callCharge: Price? = callCharge; private set

  /** キャンセル */
  fun cancel() { // setterを公開しない代わりに、モデルができることをpublicメソッドとして公開
    // 状態をチェックし、間違った使い方ができないようにする
    check(this.status == PhoneCallStatus.WAITING_RECEIVER)
    // 必ずまとめて更新されるようにする
    this.status = PhoneCallStatus.CANCELED
    this.finishedAt = LocalDateTime.now()
  }

  /** 通話開始 */
  fun startTalking() {
    check(this.status == PhoneCallStatus.WAITING_RECEIVER)
    this.status = PhoneCallStatus.TALK_STARTED
    this.talkStartedAt = LocalDateTime.now()
  }

  /** 通話終了 */
  fun finishCalling(callCharge: Price) {
    check(this.status == PhoneCallStatus.TALK_STARTED)
    this.status = PhoneCallStatus.FINISHED
    this.finishedAt = LocalDateTime.now()
    this.callCharge = callCharge
  }

  /** 通話が終わっていないかどうか */
  val isInProgress: Boolean // 必要であれば情報を導出するメソッドを追加
    get() = (status === PhoneCallStatus.WAITING_RECEIVER || status === PhoneCallStatus.TALK_STARTED)

  /** 通話時間 */
  val durationTime: Duration
    get() = if (isInProgress) {
      Duration.between(createdAt, LocalDateTime.now())
    } else {
      Duration.between(createdAt, finishedAt)
    }

  companion object {
    // 新規作成用のファクトリを用意
    /** 新規作成 */
    fun create(caller: UserId, receiver: UserId): PhoneCall { // 新規作成時にはcallerとreceiverを必ず指定し、完全な状態で作成されるように強制する
      return PhoneCall(
        PhoneCallId.next(),
        caller,
        receiver,
        PhoneCallStatus.WAITING_RECEIVER,
        LocalDateTime.now(),
        null,
        null,
        null
      )
    }
  }
}

/** 通話ID(バリューオブジェクト) */
@JvmInline
value class PhoneCallId(val value: UUID) {
  companion object {
    fun next() = PhoneCallId(UUID.randomUUID())
  }
}

/** 通話ステータス(バリューオブジェクト) */
enum class PhoneCallStatus {
  WAITING_RECEIVER, CANCELED, TALK_STARTED, FINISHED
}

ユースケースのロジック

/**
 * 電話をかける
 *
 * @param caller   かける人
 * @param receiver かけられる人
 * @return 通話ID
 */
fun makePhoneCall(caller: UserId, receiver: UserId): PhoneCallId {
  val phoneCall = PhoneCall.create(caller, receiver)
  phoneCallRepository.save(phoneCall)
  return phoneCall.id
}

/**
 * キャンセルする
 *
 * @param phoneCallId 通話ID
 */
fun cancelPhoneCall(phoneCallId: PhoneCallId) {
  val phoneCall: PhoneCall = phoneCallRepository.findById(phoneCallId)
  phoneCall.cancel() // データをセットするのではなく、ふるまいを呼び出すことによって処理を実現
  phoneCallRepository.save(phoneCall)
}

/**
 * 通話開始
 *
 * @param phoneCallId 通話ID
 */
fun startTalking(phoneCallId: PhoneCallId) {
  val phoneCall: PhoneCall = phoneCallRepository.findById(phoneCallId)
  phoneCall.startTalking()
  phoneCallRepository.save(phoneCall)
}

/**
 * 通話終了
 *
 * @param phoneCallId 通話ID
 */
fun finishPhoneCall(phoneCallId: PhoneCallId) {
  val phoneCall: PhoneCall = phoneCallRepository.findById(phoneCallId)
  phoneCall.finishCalling(calculateCharge(phoneCall))
  phoneCallRepository.save(phoneCall)
}

イメージとしてはだいたいこんな感じです。
具体的なテクニックはまたそのうち書きます。

最後に

※記事の内容にまだまだ至らない部分もあると思います。間違って理解している箇所を見つけられた場合やさしく諭していただけるととても喜びます。筆者の理解が更新されたら、この記事も更新していきます。

UPDATES

2022/08/23: リポジトリパターンについて詳しく書きました

https://zenn.dev/kohii/articles/e4f325ed011db8

2023/08/22: 加筆修正しました

「境界づけられたコンテキスト」のセクションを追加しました。
また、理解が浅かった部分も加筆修正しています(主に「ドメインモデルとモデリング」、「ユビキタス言語」あたり)

2024/07/17: ドメインイベントについて書きました

https://zenn.dev/kohii/articles/4a68e768c93573

脚注
  1. エリック本で挫折された方や読み切れるか不安な方は、『Domain Driven Design Quickly 日本語版』をまず読んでみるといいかもしれません。 ↩︎

Discussion