3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【まとめ】ドメイン駆動開発(実装編)

Last updated at Posted at 2024-11-15

前提

参考資料

下記を参考に情報を自分なりにまとめたものです。学習中の身 かつ ドメイン駆動開発の難解さ(故に人によって意見も違う)もあり、絶対に正しい情報という訳ではない点はご理解ください。

まとめの流れ

「ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本」がわかりやすかったので、そちらに倣い、実装戦略→設計戦略の順にまとめていく予定です(今回は実装戦略編)。

最終的に下記知識を網羅するものとなります。

<実装戦略>

  • 値オブジェクト
  • エンティティ
  • 集約
  • 仕様パターン
  • ドメインサービス
  • リポジトリパターン
  • ファクトリーパターン

<設計戦略>

  • ユビキタス言語
  • ドメインモデル
  • イベントストーミング
  • 境界づけられたコンテキスト
  • コンテキストマップ

ドメイン駆動開発の実装戦略

基本思想(実装観点)

ドメイン駆動開発における基本思想(実装観点)は、ドメイン(業務概念やビジネスロジック)をドメイン層と呼ばれる部分に集約し、アプリケーション層(ユースケースを取り扱う層)やインフラ層(データ永続化を取り扱う層)と切り離すというもの。

それによってシステムの複雑度や結合度を低減させることができ、変更容易性の高いシステムを実現することができる。

しかしながら、実際のところ自然体で実装すると、ついついアプリケーション層やインフラ層にビジネスロジックが流出してしまいがち。かといって躍起になってそれを防ごうとすると、逆に複雑化してしまったりパフォーマンスの悪いシステムが出来上がってしまったりするのが悩みどころ。

つまり、ドメイン駆動開発の実装戦略は大きく分けて3種類に分かれる。

  1. ドメイン(業務概念やビジネスロジック)をドメイン層にまとめるための技法
  2. ドメイン以外の情報・操作をドメインに持ち込まないための技法
  3. ドメインをドメイン層以外に流出させないための技法

ドメイン層にまとめるための技法

ドメイン層にまとめた、ビジネスロジックを凝縮したオブジェクトをドメインオブジェクトという。

下記は全てドメインオブジェクトに該当する。

値オブジェクト

何らかの業務ルールを持つ項目を、ただのプリミティブな型(IntやString、Floatなど)で表すことはできない。

data class Book(
  val isbn: String //ISBNコードは13桁で表すという業務ルールがある
)

そのため、業務ルールと値を内包したクラス定義を行い、値を表すオブジェクトとする。

data class Book(
  val isbn: ISBN
)

data class ISBN(
  val code: String
) {
  init {
    require(code.length == 13) { "13桁じゃなきゃあかんよ" }
  }
}

保守性向上のため、この値オブジェクトは下記を満たす必要がある。

  • 値が同一であれば等価と判断できる
  • 不変である(値オブジェクトの中身を書き換えるのは不可)
  • 交換可能である(値を書き換えるのはだめだけどオブジェクト自体を入れ替えるのはOK)

エンティティ

ISBNのような値オブジェクトは、値が同一であれば等価と判断できる。

ISBN:1234567890123 <=これと、
ISBN:1234567890123 <=これは全く同じもの

しかしながら例えば人間のように、年齢や住所、名前が同一だったとしても、同一とは判断できないものもある。

人間A:A田B助さん <=同姓同名だろうと、
人間B:A田B助さん <=違う人と区別される

このように、持っている属性に関わらず常に同一であることが保証されている(これを同一性という)存在をエンティティと呼ぶ。エンティティの特徴は下記の通り。

  • 同一性により区別される(全く同じ属性を持っていたとしても等価ではない)
  • 可変である(属性の書き換えが可能)
  • ライフサイクルを持つ

ライフサイクルとは、生成、変更、場合によっては削除という状態の変化を持つことを意味する。

システムにおいては、同一性はIDという形で表現される。そして状態を管理するにはDBに最新の状態を永続化する必要があることから、エンティティは重要なデータモデルとなることが多い。

//貸出記録も、同じ人が同じ本を何度借りても別物なので同一性がある
data class CheckoutRecord(
  val checkoutId: Int,
  val bookIds: List<Int>
)

data class Book(
  val bookId: Int,
  val title: String,
  val isbn: ISBN
)

data class ISBN(
  val code: String
) {
  init {
    require(code.length == 13) { "13桁じゃなきゃあかんよ" }
  }
}

集約

ひとつのルートエンティティとそれに紐づくエンティティや値オブジェクトをまとめたもの。

業務ではいくつかの概念が絡み合ってひとつの概念を成しているケースが存在するため、そういったものを表現するための手法。

実装レベルの話で言えば他のエンティティや値オブジェクトを属性として持つ新たなドメインオブジェクトを定義する。

例えば本の検索システムで、第何版なのか、いつ以降に改訂されたものかといったキーワードで検索できるようにする必要がある場合、改訂履歴は本というドメインオブジェクトに内包される情報であるため、下記のように集約する。

data class Book(
  val bookId: Int,
  val title: String,
  val isbn: ISBN,
  val revisionHistories: List<RevisionHisotry> //ルートエンティティBookが改訂履歴を内包する
  val publishedDateTime: LocalDateTime
)

data class RevisionHisotry(
  val revisionDateTime: LocalDateTime
  val version: String
)

この集約は一見すると単純なように見えて、実装時には最も頭を悩まされる概念となっている。

というのも、どこからどこまでを集約として切り出すか、が非常に難解だからだ。考え方次第でいかようにも捉えられるという落とし穴にハマると、何が正解なのかさっぱりわからなくなる。

大事なのは、集約はデータ永続化の単位(トランザクション)であるということ。このトランザクションは、ユースケースにおけるトランザクションの意味ではない。

例えば、商品を買ったら在庫が減って購入履歴が更新されるというユースケースがあった時、一連の流れでひとつのトランザクションなのだからこれらは全てひとつの集約にすべきだ、ということではない。

例えば商品情報を更新するなら商品だけが更新対象になるし、商品が入荷されたら在庫だけが更新される。このように、ユースケース毎に更新範囲が大きく変わってしまうのであれば、それは集約とすべきではない。

集約は業務概念として一括りに定義されるものであり、結果的に多くのユースケースにおいてひとまとめで更新されるべき範囲(これをトランザクション境界という)で集約されたものを指す。

ここでポイントとなるのは、多くのユースケースにおいて(全てとは言っていない)、という部分。

トランザクション境界を意識しすぎるあまり、全てのユースケースで更新範囲が共通なもののみを集約の単位としてしまうと、クラスが細分化し過ぎて変に複雑化してしまうことになる。

//ユーザの情報は住所だけを更新するケースと、名前だけを更新するケースと、あと他にも色々あるからそれ毎に分けよう!
data class UserAddress()
data class UserName()
....
//なんてことをすると、大量のクラス情報に振り回されて、実装も保守も大変なことになってしまう

あくまで業務概念をドメインオブジェクトとして表現することを優先するべきで、その上で集約は最小限のトランザクション範囲で区切ることを意識するのが大事。

//ドメインのひとつの概念としては、ユーザという単位で集約した方がわかりやすいし、
//ビジネスロジックの表現もしやすい(もちろんユーザという単位じゃない方がいい場合もある。そこはケースバイケース)
data class User(
  val address: String,
  val name: String
)

もしユースケース毎に一時的なトランザクションが発生するのであれば、アプリケーション層で実装するのが望ましい。

//商品を買ったら、
suspend fun purchase() {
  transaction {  
  	//在庫が減って
  	stockRepository.save(stock)
  
  	//購入履歴が更新される
    purchaseHistoryRepository.save(purchaseHisotry)
	}
  //一時的なトランザクションはアプリケーション側で設定する
}

ドメイン以外の情報・操作をドメインに持ち込まないための技法

例えばDBへの保存やドメインに対する手続き的な操作などは、直接ビジネスロジックには関わらない。

以下は、そういったドメイン以外の情報・操作をドメイン層以外にまとめ、ドメイン層に持ち込ませないための技法である。

リポジトリパターン

現実世界では物質が突然消滅することはまずないが、プログラムの世界ではデータは基本的に揮発性だ。そのため、プログラムの世界ではデータベースを用意し、データを永続的なものにするための操作を行う必要がある。

このプログラム都合の操作であるデータ永続化を、ドメイン層から分離するためのデザインパターンがリポジトリパターンである。

これはデータ永続化処理をRepositoryというクラスにまとめるだけなので、とてもわかりやすいと思う。

class BookRepository {
  suspend fun getById(bookId: String): Book {
    //DBからbookIdが一致するレコードを取得してBookオブジェクトとして返す
  }
}

Repositoryはインフラ層にまとめられるが、この時、Repositoryを利用する層はインフラ層に対して依存していることになる。

Repositoryの利用層はRepositoryが持つメソッドを利用しているので、Repositoryの都合に振り回される
 =Repositoryに依存している

このままだと、例えばシステムのデータベースを入れ替えたり、バージョンアップに伴って改修が入ったりした時にインフラ層の都合で他の層を修正しなきゃいけなくなる可能性がある。

この問題を解消するために、利用したい側がインターフェースを定義しRepositoryがそれに合わせて実装する、すなわち依存性逆転の原則を適用する。

//Repositoryインターフェースはドメイン層に定義しておく
interface BookRepository {
  suspend fun getById(bookId: String): Book
}

//RepositoryImplはインフラ層に定義し、Repositoryインターフェースを実装する
class BookRepositoryImpl: BookRepository {
  override suspend fun getById(bookId: String): Book {
    //DBからbookIdが一致するレコードを取得してBookオブジェクトとして返す
  }
}

ドメイン以外の情報・操作をドメインに持ち込ませないための技法なのに、Repositoryインターフェースをドメイン層に定義するのは矛盾しているように感じるかもしれない。実際、疑問に従ってアプリケーション層に配置すべきという人もいる。

ただ、後述のドメインサービスにおいてもRepositoryを利用するケースがある。もしアプリケーション層にRepositoryを配置すると、ドメイン層からアプリケーション層に対する依存が発生してしまう。

この問題を防ぐため、またデータ永続化は集約の単位で行われることから集約とセットで定義すべきという考え方から、ドメイン層に配置すべきという人もいる。

よりクリーンな層構成を好むのであれば、ドメイン層とインフラ層の間にもうひとつ層をクッションとして挟むというのも良いかもしれない。

ただ冗長になる印象もあるし、実際のところ集約の近くにRepositoryもあった方が開発しやすいので、自分はドメイン層に配置する方式を採用している。

アプリケーションサービス

実際の業務(ユースケース)を表現しようとした場合、ドメインオブジェクトが持つメソッドを組み合わせて実行することになる。

ビジネスロジック自体はドメインオブジェクトに内包されているため、それらの実行順や結果の制御、データ永続化処理などはビジネスロジックに直接関わらない、プログラム処理都合の手続き的な処理に該当する。

こういったユースケース毎の手続きを取り扱うのがアプリケーションサービスである。

class CheckoutService(
  private val checkoutRecordRepository: CheckoutRecordRepository
) {
  suspend fun execute(): Result {
    val checkoutRecord = CheckoutRecord.create()
    checkoutRecordRepository.save(checkoutRecord)
    return Result(
      //実行結果の作成
    )
  }
}

リポジトリパターンとアプリケーションサービスを適用することで、ドメイン層からデータ永続化と手続き処理を切り離すことができる。

  1. アプリケーション層(アプリケーションサービスが手続きを取り扱う)
  2. ドメイン層(ビジネスロジックの集約)
  3. インフラ層(リポジトリパターンでデータ永続化を取り扱う)

ドメイン層以外に流出させないための技法

単純な例であれば、何もかもをうまくドメイン層にまとめられるように見える。

しかしながら実際にはドメイン層にうまくまとめられないビジネスロジックやユースケースが存在する。

下記は、そういった問題をひとつひとつ現実的な実装に落とし込むための技法である。

仕様パターン

ドメインオブジェクトの状態はそのドメインオブジェクト自身を表すものなので、ドメインオブジェクト自体が持つべき振る舞いである。ただし実際には他のドメインオブジェクトやリポジトリへの問い合わせ結果を踏まえないと評価できないことが多い。

例えば図書館利用ユーザが本を延滞しているかどうかは、本の貸出状況を取得しなければ評価できない。

この時ついついやってしまいがちなのがリポジトリ層にチェック機能を持たせたり、アプリケーション層で複数のドメインオブジェクトを扱って状態を評価したりする実装。

class CheckoutRecordRepositoryImpl: CheckoutRecordRepository {
  override suspend fun isOverdue(userId: String): Boolean {
		//延滞者かどうかを判定する処理を記述
  }
}

どうせ最終的にDBに問い合わせするなら別にいいじゃんと思うかもしれないが、それがビジネスロジックであるならば、ドメイン層にまとめておくべきである。とはいえドメインオブジェクトにリポジトリの参照や操作は持たせたくない。

こういった実装上の問題を解消するために存在するのが仕様パターンである。

仕様と呼ばれるクラスに状態評価の処理を内包することで、ドメイン層にビジネスロジックを保持しつつ、ドメインオブジェクトを汚さずに済むようになる。

class OverdueSpecification(
  private val checkoutRecordRepository: CheckoutRecordRepository
) {
  suspend fun isOverdue(userId: String): Boolean {
    //延滞者かどうかを判定する処理を記述
  }
}

//ドメインオブジェクトはクリーンな実装を保つことができる
data class User(
  val userId: Int,
  val name: String
)

また、状態評価がドメインオブジェクトの取得に関わる場合、仕様クラスを一種のフィルターとみなしてリポジトリに渡すという運用もできる。

ドメインサービス

対象となるドメインオブジェクトに関連するものの、ドメインオブジェクト自体にメソッドとして実装すると不自然なビジネスロジックというものが出てくることがある。

class BookRepositoryImpl: BookRepository {
  override suspend fun isRegistered(bookId: String): Boolean {
		//登録済かどうかを判定する処理を記述
  }
}

登録済かどうかは状態であるが、それはドメインオブジェクト自体の状態というより、ドメインオブジェクトと周囲の関係性に基づく状態である。

こういったドメインオブジェクト自体が持つと不自然なビジネスロジックを別の形に切り出したものがドメインサービスである。

class BookService(
  private val checkoutRecordRepository: CheckoutRecordRepository
) {
  suspend fun isRegistered(book: Book): Boolean {
    //登録済かどうかを判定するビジネスロジックを記述
  }
}

対象となるドメインオブジェクトに関連するが、対象となるドメインオブジェクト自体が持つべきではない振る舞いは、ドメインサービスとして切り出す。

これにより、インフラ層やアプリケーション層にドメインが流出することを防ぐことができる。

仕様パターンとどっちで実装するか混乱しやすいが、仕様パターンはあくまでドメインオブジェクト自体が持つべき状態評価が複雑化している場合に適用するもので、ドメインサービスはドメインオブジェクトが持つと不自然なビジネスロジックを別に切り出すもの。

それぞれの目的を意識して、どちらで表現するべきかは考える必要がある。

ファクトリーパターン

複雑な組み合わせによってドメインオブジェクトが生成される場合、どのように組み立てることでドメインオブジェクトが成り立つかという知識はドメインオブジェクト自身が持つべきではない。かといって、アプリケーション層やインフラ層には流出させたくない。

このように、何らかの条件や組み立てを必要とする生成処理を別に切り出したい時に用いるのがファクトリーパターンである。

ドメインオブジェクトの生成を責務とするファクトリークラスを定義し、そこに生成処理を持たせるというもの。

class BookFactory {
  suspend fun create(): Book {
    //本の組み立て処理
  }
}

まとめ

現時点の自分の理解では、下記の考え方で判断していくのが良さそう。

domain_chart.jpg

アプリケーション層の手前にプレゼンテーション層があるため、実際には下記4階層構造となる。

  1. プレゼンテーション層(入出力を担当。Controllerクラスが所属)
  2. アプリケーション層(ユースケース毎の手続きを担当。ApplicationServiceクラスが所属)
  3. ドメイン層(業務モデル、ビジネスロジックを集約。ドメインオブジェクト、ドメインサービス、仕様クラスが所属。自分の考え方ではリポジトリのインターフェースもここに配置)
  4. インフラ層(Repositoryクラスが所属。ドメイン層のインターフェースを実装する)

実際の実装ではやっぱりそう単純にはいかなくて悩む場面が多々出るとは思いますが、ひとまず考え方や位置付けは自分なりに整理・理解できたかなと思います。

また自分の中の情報をアップデートできたら更新します。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?