ドメイン駆動設計をわかりやすく - ドメインのモデル設計を手を動かしながら学ぼう

ドメイン駆動設計(DDD)が近年関心を集めていますが、同時にこの設計思想は難しい、わかりにくい、という見方もあります。さまざまなプロジェクトでドメイン駆動設計を実践してきたかとじゅんさんが、サンプル課題をもとに、ユースケース分析、モデル設計といった基礎を解説します。

ドメイン駆動設計をわかりやすく - ドメインのモデル設計を手を動かしながら学ぼう

はじめまして、Chatworkでテックリードをしている、かとじゅん1@j5ik2oです。 僕は2010年ころより、大小さまざまなプロジェクトでドメイン駆動設計、いわゆるDDD(Domain Driven Design)を導入した開発を実践してきました。ドメイン駆動設計を主題としたワークショップなども主宰していますが、最近では加速度的にこの設計思想への関心が高まっていると感じます。本稿では、なにかと分かりにくいドメイン駆動設計の基本を、架空の課題とその解決を通じ、手を動かしながら学ぶことを目的としています。

サンプルコードは僕が得意とし、また数多くのドメイン駆動設計を取り入れたプロジェクトで使用してきたScalaで記述します。できるだけシンプルな構文での記述を心がけているので、多くの方に雰囲気はご理解いただけると思います。

ソフトウェア要求は複雑化し迅速な変化が求められている

さて、ドメイン駆動設計が継続して注目を集めるのはなぜでしょうか。
僕の家の隣にある、何十年も歴史があるCD屋が最近閉店しました。シャッターに貼られた閉店のお知らせを眺める年配の通行人の姿は、時代の移り変わりを感じさせます。一昔前は音楽といえばCDが主流でしたが、今ではストリーミングサービスの方が一般的でしょう。

音楽にかぎらず、キャッシュレス決済サービスや、メッセンジャーサービスなど、僕らは便利な“サービス”を日々利用しており、「サービスの継続的な利用」が当たり前になってきました。さらに、新型コロナウイルスの現下やそれ以降においても、ソフトウェアとしてのサービスの需要や必然性は増していくでしょう。こうした時代背景を踏まえると、ソフトウェアは社会変化やより複雑な要求への迅速な対応が求められ、その点において、ドメイン駆動設計に関心が集まっているのだと感じています

【DDD前史】構造化プログラミングからオブジェクト指向へ

複雑な要求、課題に対応するには、それを解くためのうまいやり方を見つけ、ソフトウェアに反映する必要があります。その「うまいやり方」こそドメインモデルであり、ドメイン駆動設計はドメインモデル実現のための設計手法です。現在でこそ一般化した手法ですが、ドメイン駆動設計の前段階である、「構造化プログラミング」から「オブジェクト指向」へといたる歴史を振り返ると、より理解が深まるでしょう。

「構造化プログラミング」は実現する機能に注目し、手続きの流れやデータ構造に注目するアプローチ、いわゆる手続き型です。ここでは詳しく述べませんが、1980年代に人気を博し一世を風靡しましたが、同時に以下の問題を残しました。

  • 手続き中心の考え方では、人間の優れた抽象化能力を使うことが難しい
  • 手続きとデータの分離によって、データ構造変更時の影響を限定することが難しい
  • データに対する手続きを重複して実装してしまうことがあり、それがバグの温床になった

これらの問題を解決するために、1980年代の終わりに「オブジェクト指向」が登場しました。オブジェクト指向は、「データ」と「手続き」をグルーピングした部品を最小単位とし、オブジェクト間でメッセージを交換するというものでした。

オブジェクトは一つの役割を持つため、実世界から抽象化したモデルをコンピュータ上に実現できます。このような考え方を利用してオブジェクト中心の分析・設計をすれば、問題を自然な形で把握できると考えられました。モデルを実装に紐付けやすくなり、機能はオブジェクトの組み合わせによって実現する方が人にとってより自然な発想になります。また、手続き型は抽象化を反映した構造を持たないため、機能変更の際に大きな影響を受けがちです。一方で、オブジェクト指向ではオブジェクトの基本的な構造を変えることなく、メソッドの追加や変更など小さな影響範囲に止めたまま機能変更を実現できるメリットがあったのです。

ドメイン駆動設計では、ドメインのうまいやり方をソフトウェアに反映する手段として、主に「オブジェクト指向言語」を利用します。オブジェクト指向は他のモデリングパラダイムと比較して広く普及していて実用的です。そして、コードはオブジェクトとしてまとめることが可能、という技術面のメリットもあります。しかし、それ以上に「コードで表現されたモデル」が言語としての性質を持つことで、ソフトウェアに関するさまざまな活動を行いやすくなります。このようなメリットから、ドメイン駆動設計においてはオブジェクト指向が基盤として重視されるのです。

まずはドメインオブジェクトのサンプルをコードで理解しよう

前置きはこのぐらいにして、モデルを反映したドメインオブジェクトを単純な例をみていきましょう。例えば、ECサービスの「注文」における「注文数」という概念をコードで表現する方法は、以下のようにIntのようなプリミティブな整数型で表現することが一般的です。

// 注文
class Order(orderId: OrderId, 
            itemId: ItemId,
            quantity: Int, // 注文数
            orderDateTime: LocalDateTime) {
 // ...
}

しかし、このコードは本当に正しいでしょうか。注文数が負の数であったり、1000個や1億個でも問題は起きないでしょうか。バリデーションは完ぺきだから大丈夫? 果たしてそうでしょうか。

仮に、「一度の注文で指定できる注文数は1~99個まで」というビジネス・ルール(不変条件とも表現されます)があるとしたら、この実装はビジネス・ルールを表現できていません。new Order(..., .., quantity = 1000, ...)といったルールを壊すような「注文」ができてしまうからです。

他に、quantity: Intをコンストラクタで表明するという以下のような方法も思いつきます。

// 注文
class Order(/** ... */
            quantity: Int, // 注文数
            /** ... */) {
  require(quantity >= 1, "The quantity is less than 1")
  require(quantity <= 99, "The quantity is greater than 99")
}

class OrderSpec {
  // 注文数のテスト
}

// 返金
class Refund(/** ... */
             quantity: Int, // 注文数
             /** ... */) {
  require(quantity >= 1, "The quantity is less than 1")
  require(quantity <= 99, "The quantity is greater than 99")
}

class RefundSpec {
  // 注文数のテスト
}

これはよいアイデアですが、返金でも注文数を扱う場合はDRY原則に反するので注意が必要です。またテストも複製が必要になる可能性があります。さらに、注文数に関するビジネス・ルールが変化した場合、それなりの変更コストを払うことになりますし、変更漏れのリスクも考えられます。

こうした課題に対処する方法はいくつかありますが、ドメイン駆動設計としてよい方法は、以下のように「注文数」を表現する型を定義することです。一般的にはクラスを利用します。そのクラスはビジネス・ルールを表明するため、誤った値は受け付けません。

上記のコード例のクラスでは、requireを使ってビジネス・ルールを表明しています。第一引数の条件が成り立たない場合、第二引数のメッセージを持つIllegalArgumentExceptionがスローされ、インスタンスは生成されません。つまり、ビジネス・ルールに則していない、不正なオブジェクトは作られなくなります。

注文だけではなく、返金でも注文数を扱う場合は「注文数」クラスを利用するだけで済みます。重複して表明する必要はありませんし、注文数に関するテストも複製する必要がありません。

// 注文数
class OrderQuantity(value: Int) {
  require(value >= 1, "The quantity is less than 1")
  require(value <= 99, "The quantity is greater than 99")
  // ...
}

// 注文
class Order(/** ... */
            quantity: OrderQuantity, // 注文数
            /** ... */) {
  // ...
}

// 返金
class Refund(/** ... */
             quantity: OrderQuantity, // 注文数
             /** ... */) {
  // ...
}

class OrderQuantitySpec {
  // 注文数のテスト
}

この例では初期化に渡された値域を表明するだけですが、インスタンス化した後も注文数に対する操作を提供することで、誤った計算を排除できます。例えば、以下のように「注文数」を1個だけ増やすメソッドは、正しい数量計算を提供します。また、99個のときに注文数を増やそうとすると失敗すべきでしょう。失敗の表現を例外とするか、Eitherなどの型として表現するかはさておき、以下のようにビジネス・ルールを反映した正しい振る舞いを使えば、注文数を間違えることはありません。

case class OrderQuantity(value: Int) {
  require(value >= 1, "The quantity is less than 1")
  require(value <= 99, "The quantity is greater than 99")

  // 注文数を増やす
  def increment: OrderQuantity = {
    update(value + 1)
  }

  // 注文数をn個増やす
  def update(n: Int): OrderQuantity = {
    new OrderQuantity(n) 
  }
}
// 注文数99個のときに呼び出すと例外を送出する
val newQuantity = qauntity.incrementQuantity

「注文数」に関する計算・判断・加工などを一つの部品(クラス)にまとめることで、ドメインに関する知識やルールを凝集できます。プリミティブ型では、こういった知識やルールはむしろ値から離れて拡散する傾向にあります。注文数という概念を会話やドキュメントやコードに表現するとき、ひとかたまりに扱えるほうが実用的です。ビジネス・ルールが変化したときでも、概念と対応するオブジェクト群の構造が変化に柔軟に対応するでしょう。注文数を例にとって説明しましたが、他のビジネス上の概念においても同様に考えることができるでしょう。

参考: ドメインロジックはドメインオブジェクトに凝集させる

例題で学ぶドメイン駆動設計

ここからは、ドメインオブジェクトのもう少し具体な例を考えてみましょう。今回は「飲み会のお勘定を割り勘する計算システム」を例に考えていきます。今回のシステムで想定するユースケースは、便宜上、以下とします。

  • 幹事が 飲み会 を開催する(システムには含まない)
  • 幹事が、システム上で、開催した 飲み会名前, 開催日時 などを設定する
  • 幹事が、システム上で、開催した 飲み会参加者 を追加/削除する
  • 幹事が、システム上で、開催した 飲み会支払区分 (多め,普通,少なめ)ごとに 支払割合 を設定する
  • 幹事が、システム上で、開催した 飲み会請求金額 を設定する
  • 幹事が、システムを利用して 飲み会参加者ごとの支払金額 を計算する

ユースケースを分析し、ドメインを理解する

ドメイン駆動設計を適切に実行するには、ユースケースの分析が不可欠です。今回のユースケースの目的は、幹事から割り勘計算という負担を軽減することです。この目的をさらに具体化すると、「飲み会のお勘定から、参加者それぞれが支払うべき金額を計算する」と設定できます。この計算を「割り勘計算」とわかりやすく呼ぶことにします。

次に必要なのは、割り勘計算の理解です。設計というとついついデータベースのテーブルやERDの話が先行しがちですが、まずは対象ドメインの理解を深めることが重要です。

さて、今回の要求で注目すべきは、参加者数で均等に割るような単純な割り勘ではない、ということです。飲み会に最初から参加していた人なら多めに払い、後から参加した人なら少なめ、といった調整ができる割り勘計算が求められています。

ユースケースに示したとおり、多め,普通,少なめの支払区分に応じて支払割合が設定さねばなりません。たとえば、普通の支払金額を1とした場合、多めは1.2倍、少なめは0.8倍とする想定で考えてみましょう。

普通の支払金額 Ma = Ma * 1
多めの支払金額 La = Ma * 1.2
少なめの支払金額 Sa = Ma * 0.8

となるので、

La = Ma * 多めの支払割合 Lr // (式1)
Sa = Ma * 少なめの支払割合 Sr // (式2)

と考えることができます。普通の支払金額が分かればすべてを解決できそうです。 さらに、割り勘は以下の式が成り立つはずです。

// (式3)
請求金額 Ta = (普通の支払金額 Ma * 普通の人数 Mn) + (多めの支払金額 La * 多めの人数 Ln) + (少なめの支払金額 Sa * 少なめの人数 Sn) + おつり C

おつりが生じないケースならば計算は簡単ですが、参加者ごとの支払金額が1円以下の端数を持つ場合、端数の扱いによっておつりなどの過不足が生じる可能性があります。日本円では一般的に1円以下は扱わないので、参加者ごとの支払金額は1円単位に丸める必要があります。ただ、丸め方によっては、集めたお金が数円足りななくなってしまう場合があります。今回は幹事が計算する手間を減らす、という目的に照らし、端数は「1円に切り上げ」とします。

それでは、式1・2・3の連立方程式から普通の支払金額であるMaを求めてみましょう。変形の途中で括りだした項 (1 * Mn + Lr * Ln + Sr * Sn) は、割合と人数を掛けたものなので、加重和(WeightedSum)と呼ぶことにします。

La = Ma * Lr
Sa = Ma * Sr

Ta = (Ma * Mn) + (La * Ln) + (Sa * Sn)
   = (Ma * Mn) + (Lr * Ma * Ln) + (Sr * Ma * Sn)
   = Ma * (1 * Mn + Lr * Ln + Sr * Sn) // (1 * Mn + Lr * Ln + Sr * Sn) = 加重和
Ma = Ta / (1 * Mn + Lr * Ln + Sr * Sn) 

普通の支払金額Maは以下の式で求めることができます。

加重和 Ws = 普通の支払割合 1 * 普通の人数 Mn + 多めの支払割合 Lr * 多めの人数 Ln + 少なめの支払割合 Sr * 少なめの人数 Sn
普通の支払金額 Ma = 請求金額 Ta / 加重和 Ws

実際の例で計算結果を確かめてみましょう。

# 30000円を、普通1人、多め1人、少なめ2人で割り勘する場合
# 多めの支払割合 = 1.2, 少なめの支払割合 = 0.8
普通の支払金額 Ma = 請求金額 30000円 / (1 * 2 + 1.2 * 1 + 0.8 * 2)
普通の支払金額 Ma = 6250円
多めの支払金額 La = 6250円 * 1.2 = 7500円
少なめの支払金額 Sa = 6250円 * 0.8 = 5000円
おつり = 30000円 - (6250円 * 2 + 7500円 * 1 + 5000円 * 2) = 0円

上記はおつりが生じないシンプルな計算ですが、下記のように1円以下の端数が生じる場合は1円に切り上げて、少し余分にお金を集め、生じたおつりの配分はグループの判断に任せることにします。

普通の支払金額 = 35000円 / (1 * 2 + 1.2 * 1 + 0.8 * 2) = 7291.66667円 → 7292円
多めの支払金額 La = 7291.66667円 * 1.2 = 8750円
少なめの支払金額 Sa = 7291.66667円 * 0.8 = 5833.333333円 → 5834円
おつり = 35000円 - (7292円 * 2 + 8750円 * 1 + 5834円 * 2) = 2円

ドメインモデルを考えながら設計のアウトラインをつくる

エンジニアHubに会員登録すると
続きをお読みいただけます(無料)。
登録のメリット
  • すべての過去記事を読める
  • 過去のウェビナー動画を
    視聴できる
  • 企業やエージェントから
    スカウトが届く