SpringBootのDataJPAに戦術的DDDの要素を導入するにはどうすれば良いか調べてみた

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関数を生やすという感じ。

https://stackoverflow.com/questions/61757512/ddd-implementation-with-spring-data-and-jpa-hibernate-problem-with-identities

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_atupdated_atを無条件にテーブルに付与したいが、そのカラムはDBのためのカラムなのでアプリケーションからはアクセスさせたくない(もしアクセスしたいのであれば、別でアプリケーションで利用するためのカラムを必ず作るべきだと思っている)。こういうときにエンティティにcreated_atupdated_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と言われているぐらいなので

https://www.martinfowler.com/bliki/TellDontAsk.html

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

この記事で紹介された方法で実現できた

https://qiita.com/cyperaceae/items/7316bc49eda21f6812d8

    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
}

https://stackoverflow.com/a/56351220/10040146

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および@OneToManyCascadeType.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

@OneToManyfetch = FetchType.EAGERを付与するか、テストコードに@Transactionalを付与すると解決できた

https://stackoverflow.com/questions/22821695/how-to-fix-hibernate-lazyinitializationexception-failed-to-lazily-initialize-a

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)

https://stackoverflow.com/a/9763680/10040146

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を利用した場合、RootEntityentitiesCから除外された場合、sub_entitycテーブルに削除されないデータが残ることがわかった。orphanRemovalの設定が@ManyToManyにもあば良さげだが、現段階ではこの問題は解決されていないらしい。

https://stackoverflow.com/questions/3055407/how-do-i-delete-orphan-entities-using-hibernate-and-jpa-on-a-many-to-many-relati

実際に利用したテストコードは以下の通り。

@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`のデータがゴミとして残される
    }
}

See also