SpringBootのDataJPAに戦術的DDDの要素を導入するにはどうすれば良いか調べてみた。
戦術的DDDで知っている手法をSpringDataJpa・Kotlinでどう適用するか調べてみた、そのログ。JavaではなくてKotlinで調べたのでJavaの人にとってはあんまり参考にならないかも
SpringBootのバージョンは2.4.0。Kotlinのバージョンは1.4。TargetJVMは11
1.DataJPAが利用するエンティティとDDDのエンティティは別クラスに分離する
DataJPAの@Entity
や@OneToMany
はDDDのエンティティとは別クラスに分離する。というのも、DDDでいうエンティティには本来アプリケーションが実現したい振る舞いを記述するもので、そこにORマッパーの事情を持ち込んでしまい、もしORマッパーを変更したい場合にDDDのエンティティの修正が必要になってしまうから。
分離するとなると、DataJPAがデータを保存・復元する際にエンティティを変換する必要がある。これを行う方法はいくつか調べてみたが手法はいくつかありそう。
1-1.infra層のRepositoryで変換する
ここで言及されていた方法
https://stackoverflow.com/questions/31400432/ddd-domain-entities-vo-and-jpa
Kotlinで実装するとこんな感じ?
/**
* DDDが利用するエンティティ
*/
data class Book(
val name: String,
val author: Author?,
var description: String,
var imagePath: String?,
) {
val id: Long? = null
val createdAt: LocalDateTime = LocalDateTime.now()
}
/**
* Jpaが利用するエンティティ
*/
@Entity
data class BookJpa(
@field:NotNull @field:Length(max = 255) var name: String,
@field:ManyToOne var authorJpa: AuthorJpa?,
@field:NotNull @field:Length(max = 2000) var description: String,
var imagePath: String?,
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
@field:CreationTimestamp
lateinit var createdAt: LocalDateTime
}
// Repository
class JpaBookRepository: JpaRepository<BookJpa, Long?> {
fun save(book: Book) {
val bookJpa = mapToJpaEntity(book)
save(bookJpa)
}
fun findAllBooks(): List<Book> {
return this.findAll().map {
mapToEntity(it)
}
}
}
レイヤーごとに必要な処理だけ書かれていて保守性が高そう
1-2.Mementoパターン
こちらで紹介されていたパターン。Repositoryで変換をかますのではなく、Book
自身に、BookJpa
インスタンスのexport/import関数を生やすという感じ。
infra層のために、domain層であるEntityに2つ関数が生えているのが気になる。domain層のことを知ってしまっているので。
実装するとこんな感じ↓
data class Book(
// ...
fun toBookJpa(): BookJpa {
return TODO()
}
companion object {
fun fromBookJpa(bookJpa: BookJpa): Book {
return TODO()
}
}
}
1-3.そもそも分離しない
上記で2パターン考えてみたけど、どちらの場合でもBook
クラスにコンストラクタを追加する必要があることに気づいた。
というのも、Jpaが扱ってくれるEntityは復元時に引数なしのコンストラクタを経由して対象のインスタンスが作成されるが、上の2つの方法では自前でBookJpa
からBook
に変換する必要がある。なのでBook
には、ドメインとして生成されるためのコンストラクタ以外にも、JPAが利用するためのコンストラクタが必要になってしまって、結局のところDDDのエンティティの中にinfra層のための処理が入ってきてしまう
長年Java/Kotlin・DataJPAに慣れ親しんだエンジニアなら、JPAがBookJpa
に対して行っているようなことを自前でBook
に行うことができるかもしれないが、残念ながら僕はそれができないのでそもそも「分離しない」という選択肢が妥当に感じた。
分離しないからと言ってinfraとdomainの処理がごちゃごちゃになってしまうかと言われるとそうでもないような気がする。
というのも、今のところinfra層(=JPA)の処理は全てアノテーションとして表現されており、domainとしての振る舞いに一切関与しないようになっているからだ。ビルドされた後のコードはJPAのアノテーションが含まれることによってinfraとdomainが絡み合って複雑になるかもしれないが、人間が見る段階でのソースコードはアノテーションだけに収まっているから許容できる範囲だと思った。この恩恵がAOPの本質なのかもしれない(知らんけど)
これ以降「分離しない」設計で考えることにする
1-3-1.JPAで利用するためのプロパティの扱い
created_at
とupdated_at
を無条件にテーブルに付与したいが、そのカラムはDBのためのカラムなのでアプリケーションからはアクセスさせたくない(もしアクセスしたいのであれば、別でアプリケーションで利用するためのカラムを必ず作るべきだと思っている)。こういうときにエンティティにcreated_at
とupdated_at
を設ける必要があるが、private
にしておけばアプリケーションからアクセスされることはなくなる
2.エンティティの生成処理
エンティティの生成処理はどう書くのが良いのか調べてみた。
ドメイン貧血症という言葉があって多分世界の多くのPJはそれに陥っているらしいが、実際SpringBootのサンプルをネットで探ってもだいたいそんな感じがする。ドメインの成立条件自体がServiceクラスにしか書かれてなくてドメインクラスにはgetter/setterのみ、みたいな。おそらくそこまで複雑になるプロジェクトがないのだろうが
試しに、今は書籍の管理システムを作っていると仮定して、ドメインには書籍自体を表すBook
と借りる人を表すBorrower
と書籍の貸し出しを表すBorrowing
があるとする。仕様として
- 貸し出し時には誰(どのユーザー)が何の本をいつまで借りるか(=返却予定日時)の情報を記録
- その本がすでに現在借りられていないこと
- そのユーザーは本を借りることができるか(貸し出し数を超えていないか)
- いつまで借りるかの値が不正ではないこと
- 返却時には、返却された日時を記録
- すでに返却済みではないこと
- 返却予定日時を過ぎていた場合は借りた人に警告メールを送信すること
書籍の貸し出しは、このシステムではBorrowing
の生成だとすると、Borrowing
クラスおよびその初期化処理は以下のように書けることがわかった
@Entity
data class Borrowing( // 2-1. コンストラクタにはエンティティとして必要なプロパティのみ
@field:ManyToOne val book: Book, // 2-2. 不変なものは`var`
@field:ManyToOne val borrower: UserBorrower,
var returnScheduledDate: LocalDateTime // 2-3. 可変なものは`var`
) {
var borrowedAt: LocalDateTime? = null // 2-4. エンティティとしの生成時に不要なプロパティやは通常のプロパティとして定義
var returnedAt: LocalDateTime? = null
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
@field:CreationTimestamp
lateinit var createdAt: LocalDateTime
/**
* 貸し出し
*/
init { // 2-5. `init`にエンティティ生成時のロジックを記述
val now = LocalDateTime.now()
val days = ChronoUnit.DAYS.between(returnScheduledDate, now)
require(days in 0..20) { // 2-6. 不整合がある場合は `IllegalArgumentException`をthrow
"返却予定日時が不正です。返却予定日時は現在日から20日後まで可能です"
}
require(borrowedAt == null && returnedAt == null) {
"現在この書籍は貸し出しできません"
}
borrowedAt = now
// 2-7. エンティティでは保存処理はしない
}
2-1. コンストラクタにはエンティティとして必要なプロパティのみ
DataJPAがエンティティを復元する時にはこのコンストラクタは利用されない(上でも述べた通りDataJPAがDBからエンティティを復元する際には特別な引数なしのコンストラクタを利用するため)、なのでここには**(DDDとしての)エンティティとして必要な引数だけを定義することができる**。また、Kotlinを利用することで、Javaで実装した場合に比べて簡単にプロパティを定義することができ、getter/setterの記述も全くいらない(Lombok使えばできるとは思うが)
2-2. 不変なものはvar
2-3. 可変なものはvar
ドメインが持つプロパティが不変なのか可変なのか(生成時以降に変更されうるのか)というのがvar/val
で簡単にかき分けれるし、エンティティを初めてみる人から見てエンティティを理解するのにとても役立つ情報だと思う
2-4. エンティティとしの生成時に不要なプロパティは通常のプロパティとして定義
これも重要な情報。エンティティのライフサイクルの中で必要になってくるプロパティはこのように記載できる
2-5. init
にエンティティ生成時のロジックを記述
2-6. 不整合がある場合は IllegalArgumentException
をthrow
一番重要。init
の中で生成時のロジックを記述している。DataJPAによるDBからの復元処理の際には、なぜかここは通らない。そのためゴリゴリにビジネスロジックを書いてもDataJPAの復元時とはバッティングせずドメインロジックに集中して記述できるまた、引数が正しいかどうかのチェックもここで行うことになる。
処理を呼び出す側でチェックする方法もあるが、それよりかはエンティティ自身の振る舞いなのでエンティティに定義した方が100%良い。Tell. Don't ask
と言われているぐらいなので
2-7. エンティティでは保存処理はしない
あくまでエンティティはプログラムの上で操作されることだけ注力すれば良いので、永続化に関することはここでは一切記述しないようにした。
実際は呼び出し元がこの処理の後にborrowingRepository.save
することになるがそれで良いと思われる
エンティティ生成時にチェックしていない仕様について
上にあげた仕様のうち、以下の2つの仕様に関してはエンティティ生成時ではチェックしない
- その本がすでに現在借りられていないこと
- そのユーザーは本を借りることができるか(貸し出し数を超えていないか)
というのも、「その本がすでに現在借りられていないこと」というものは他の複数のBorrowing
に関する話で、単独のBorrowing
自体とは関係がないため。また、貸し出しという行為に関する制約というよりかは書籍貸し出しサービスを行う上でのビジネス上の制約のようにも思える。
このような複数のBorrowing
に対する重複した処理はドメインサービスクラスで定義するのが良いと言われている。ただし、ドメインに書くべきロジックをドメインサービスに書かないように気をつける必要がある
ドメインサービスを用意することで、自身に重複を問い合わせたり、チェック専 用のインスタンスを用意したりする必要がなくなりました。 リスト4.5のコードは 開発者を困惑させない自然なものでしょう。 値オブジェクトやエンティティに定義すると不自然に感じる操作はドメインサー ビスに定義することで、そこに存在する不自然さは解消されます。
成瀬 允宣 『ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本』(翔泳社、2020/2/13) 4.2 ドメインサービスとは
また、「そのユーザーは本を借りることができるか(貸し出し数を超えていないか)」に関しても、他の複数のBorrowing
に関する話なので同様にドメインサービスに記述するのが妥当に感じる
3.エンティティの更新処理
エンティティの更新処理も、エンティティ自体に処理を生やす。今回のBorrowing
クラスでは書籍を返却するという関数を実装する
- 返却時には、返却された日時を記録
- すでに返却済みではないこと
- 返却予定日時を過ぎていた場合は借りた人に警告メールを送信すること
この仕様に基づくとこんな感じの実装になる。
@Entity
data class Borrowing(
@field:ManyToOne val book: Book,
@field:ManyToOne val borrower: UserBorrower,
var returnScheduledDate: LocalDateTime
) {
// ...略
/**
* 返却
*/
fun `return`() {
check(returnedAt == null) {
"現在この書籍は返却できません"
}
returnedAt = LocalDateTime.now()
}
引数に対するチェックではなく状態に関するチェックなのでrequire()
ではなくcheck()
を使っている。それ以外に特に生成時と変わったことはなく、条件をチェックしてプロパティに記録しているだけ。
4.クラス外からの可視性
エンティティにせっかく振る舞いを記述したのにエンティティ外から直接プロパティを変更されると困る。
例えばこんな感じ
class BorrowingService() {
fun evilBorrow() {
// ...
borrowing.returnedAt = LocalDateTime.now()
borrowingRepository.save(borrowing)
}
}
これを防ぐ方法を調べてみた。
4-1.private set
この記事で紹介された方法で実現できた
var borrowedAt: LocalDateTime? = null
private set
var returnedAt: LocalDateTime? = null
private set
DataJPAのエンティティ復元時にエラーが出ないかにならないか検証したが特段問題なかった。
ただし、コンストラクタで定義されたプロパティに対して private set
をつけることはできない。例えばBorrowing
クラスのreturnScheduledDate
を外部から書き換え不可にしようとするとprivate set
は利用できず、以下の2つの方法しかないらしい
A.getterを生やす
data class Borrowing(
private var _returnScheduledDate: LocalDateTime
) {
val returnScheduledDate: LocalDateTime get() = _returnScheduledDate
}
B.dataclassではなくclassを利用する
Aの方法だとインスタンス内に余分なプロパティ_returnScheduledDate
が保存されてしまうのでそれを避ける場合に使える
class Borrowing(_returnScheduledDate: LocalDateTime) {
var returnScheduledDate = _returnScheduledDate
private set
}
dataclassではなくclassになってしまう点は非常に残念だが、Kotlinがコンストラクタにプロパティを定義できること自体が元々Javaに比べて便利な機能なので、そこまで不便とは感じない
5.集約
集約についてもDataJPAでどう実装できるか調べてみた。
具体的には、集約ルートおよびそこから1:N, N:M, N:1のリレーション関係を持つオブジェクト(集約ルートではない)を用意して、それらが集約ルートが登録されたり更新されたり削除されたりしたときに期待される挙動をするかどうかをテストした(テストコードは下部に記載した)
結果として、以下のような集約ルートだと期待した挙動をすることがわかった
@Entity
@Table(name="root_entity")
data class RootEntity(
// @field:ManyToOne(cascade = [CascadeType.ALL]) var entityA: SubEntityA,
@field:OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval=true)
@field:JoinColumn(name = "root_entity_id")
var entitiesB: MutableSet<SubEntityB>,
@field:ManyToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER)
var entitiesC: MutableSet<SubEntityC>,
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
}
テストを実行する中で実際に遭遇したエラーを書いておく
5-1.集約ルート登録時に、他のオブジェクトも登録できるか
@ManyToOne
および@OneToMany
に関するテストで以下のエラーが出た
org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : com.atma.bookmanager.domain.ddd.RootEntity.entityA -> com.atma.bookmanager.domain.ddd.SubEntityA
@ManyToOne
および@OneToMany
にCascadeType.ALL
を付与すると問題なく動作した。
だが、よく考えるとRootEntity
が削除されてしまった時に合わせて@ManyToOne
が削除されてしまう処理は好ましくない(他のRootObject
が依存しているかもしれないので)し実際問題そのような状態(複数の集約に紐づくオブジェクト)は同じ集約には存在しないはず。なのでそもそも@ManyToOne
はテストケースとして考慮しなくてもよかった
5-2.lazyローディングに関するエラー
インメモリのh2databaseでのテストは発生しなかったがmysqlにすると発生した。
failed to lazily initialize a collection of role: com.atma.bookmanager.domain.ddd.RootEntity.entitiesB, could not initialize proxy - no Session
@OneToMany
にfetch = FetchType.EAGER
を付与するか、テストコードに@Transactional
を付与すると解決できた
https://vladmihalcea.com/the-open-session-in-view-anti-pattern/
この状態ではFetchTypeをEAGERかLAZYかどちらか選分ことができた。そもそも戦術的DDを考える場面では、集約ルートのプロパティが変化した時点で自動でDBに反映されるLAZYは便利かと言われると、Repositoryの責務をEntityが背負ってしまっていると考えれるため逆に扱いづらくて不便のように感る。
なのでFetchTypeはEAGERを選択することにした。
5-3.@OneToMany
との関係にあるオブジェクトに対して中間テーブルができてしまった
@OneToMany
の場合、DataJpaによって生成されたSQLではテーブルが2つできてしまった。これは@JoinColumn
を付与することで対応できた
@field:OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER)
+@field:JoinColumn(name = "root_entity_id")
5-4.@OneToMany
なListから要素を削除すると、レコードが削除されず外部キー部分にnullが入ってしまった
以下のように、@OneToMany
なListから要素を削除すると、レコードが削除されず外部キー部分にnullが入ってしまった。
// 追加して削除
val b3 = SubEntityB("B3")
newRoot.entitiesB.add(b3)
newRoot.entitiesB.remove(SubEntityB("B2"))
orphanRemoval
をtrueにすることで、レコード自体を削除できる
@field:JoinColumn(name = "root_entity_id", orphanRemoval=true)
5-5.結果
@OneToMany
に関しては、
@field:OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval=true)
@field:JoinColumn(name = "root_entity_id")
var entitiesB: MutableSet<SubEntityB>,
とすることで、期待した通りの動作をすることができた
また、@ManyToMany
に関しては、
@field:ManyToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER)
var entitiesC: MutableSet<SubEntityC>,
とすることで、ほぼ期待した通りの動作をすることができた。ただし、@ManyToMany
を利用した場合、RootEntity
のentitiesC
から除外された場合、sub_entityc
テーブルに削除されないデータが残ることがわかった。orphanRemoval
の設定が@ManyToMany
にもあば良さげだが、現段階ではこの問題は解決されていないらしい。
実際に利用したテストコードは以下の通り。
@DataJpaTest
internal class RootEntityTest() {
@Autowired
private lateinit var syuyakuRepository: SyuyakuRepository
@Test
fun `期待した挙動かテスト`() {
val a = SubEntityA("A")
val bSet = mutableSetOf(
SubEntityB("B1"),
SubEntityB("B2")
)
val cSet = mutableSetOf(
SubEntityC("C1"),
SubEntityC("C2")
)
val root = RootEntity(bSet, cSet)
syuyakuRepository.save(root)
val newRoot = syuyakuRepository.findByIdOrNull(root.id!!)!!
assertThat(newRoot.entitiesB).isNotEmpty
assertThat(newRoot.entitiesC).isNotEmpty
// 追加して削除
val b3 = SubEntityB("B3")
newRoot.entitiesB.add(b3)
newRoot.entitiesB.remove(SubEntityB("B2"))
// 追加して削除
val c3 = SubEntityC("C3")
newRoot.entitiesC.add(c3)
newRoot.entitiesC.remove(SubEntityC("C2"))
syuyakuRepository.save(newRoot)
val newRoot2 = syuyakuRepository.findByIdOrNull(root.id!!)!!
assertThat(newRoot2.entitiesB.size).isEqualTo(2)
assert(newRoot2.entitiesB.contains(b3))
assertThat(newRoot2.entitiesC.size).isEqualTo(2)
assert(newRoot2.entitiesC.contains(c3))
syuyakuRepository.delete(newRoot2)
// この段階で、`C2`のデータがゴミとして残される
}
}