読者です 読者をやめる 読者になる 読者になる

DDDにおいて、なぜ複数の集約にまたがってトランザクションをかけてはいけないのか(multiple aggregates in one transaction)

DDDでは 集約 = トランザクション境界 でなければならないので、
複数の集約をまたがるデータの永続化処理は結果整合性になる。

なぜ集約をまたいでトランザクションをかけてはいけないのかというと、
集約で「データの一貫性の境界」を表現するため。
なので、集約同士はデータの一貫性を保証しない = 結果整合性 ということになる。

集約がトランザクション境界ではない場合はどうなるのかというと、「データの一貫性の境界」がドメインレイヤで表現できなくなる。

あるときは 集約A, 集約B が一緒のトランザクションで登録され、
あるときは 集約A, 集約B, 集約C が一緒のトランザクションで登録される、というケースがあると、
「データの一貫性の境界」はアプリケーションレイヤのトランザクション開始から終了までのコードで表現されてしまうので、
それぞれの集約がどの粒度で一貫性を保たれるのかが分からなくなる。

一応、トランザクション境界を集約単位にすることで(結果整合性を受け入れることで)
一貫性以外にも以下のメリットが生まれる。
・ロックが発生しにくい
・集約ごとに別のデータストレージを選択できる
・Repositoryの実装に技術的なコードを隠蔽できるので、
 技術的な実装が変更されてもドメインレイヤ、アプリケーションレイヤに影響を与えない。

「Repositoryの実装に技術的なコードを隠蔽できる・・・」がどういうことかというと、
トランザクションが複数の集約にまたがる場合、
以下のように Repository にDB接続などを渡す必要がある。

type UserRepoImpl struct {
    con database.Connection //DB接続
}

//引数でDB接続を持つ。
func NewUserRepoImple(con database.Connection) *NewUserRepoImple {
    return &UserRepoImpl{
        con: con,
    }
}

func (u *UserRepoImpl) InsertUser(user *User) error {
    //ここで con を利用して Insert する
    return con.Insert(user)
}

そして、 UserRepoImpl を利用するドメインレイヤやアプリケーショレイヤのコードは以下になるので・・・

func InsertUser(user User) error {
    con := database.NewConnection()
    con.Transaction()

    repo := NewUserRepoImple(con)
    err := repo.InsertUser(user)
    if err != nil {
        con.Rollback()
        return err
    }

    con.Commit()
}

Userの保存先がファイルになったり、
WebAPIになったりすると、
DB接続を保つ必要が無いので、
UserRepoImpl を修正する必要がある。

//DB接続を持たないように修正
type UserRepoImpl struct {}

func NewUserRepoImple() *NewUserRepoImple {
    return &UserRepoImpl{}
}


もちろん、ドメインレイヤ、アプリケーションレイヤのコードも修正する。

func InsertUser(user User) error {
    //DB接続をNewする処理を削除してある
    repo := NewUserRepoImple()
    err := repo.InsertUser(user)
    if err != nil {
        return err
    }
}


トランザクション境界を集約単位にすると、
以下のように UserRepoImpl の InsertUser() にDB接続を突っ込んでおけるので、
仮に DB が WebAPI になろーが、ファイルになろーが、UserRepoImpl 単体の修正で済む。

type UserRepoImpl struct {}

func NewUserRepoImple() *NewUserRepoImple {
    return &UserRepoImpl{}
}

func (u *UserRepoImpl) InsertUser(user *User) error {
    //ここでDB接続を管理するので、
    //保存先がファイルになっても、
    //ドメインレイヤ、アプリケーションレイヤに影響がない。
    con := database.NewConnection()
    con.Transaction()

    err := con.Insert(user)
    if err != nil {
        con.Rollback()
        return err
    }

    con.Commit()
    return nil
}

もちろん、フレームワークやORMによっては
このような影響がでないこともあるので、
常にこれがメリットになるということではない。

ということで、説明終わり。



ただ、現実問題として、結果整合性にすることができないケースがある。

不整合データができてしまう可能性があったり、
そもそもトランザクションかけた方がロールバックできて楽じゃんって意見があったり、
チームメンバーの理解が得られなかったり、
上司に「トランザクション使え」って言われたり・・・

その場合はどうするのかというと、以下の選択肢があると思う。

・複数の集約を持つ大きな1つの集約を作る or 一方の集約にもう一方の集約を含めてしまう
 とはいえ、これはドメイン表現上自然な場合に適用可能なものだと思うので、
 結果整合性を回避するために無理やり大きな集約を作ってはいけない。

トランザクションをかけてしまう
 これはそのまま。
 トランザクション使っちゃう。

実践DDD本には結果整合性のルールを破るパターンについても言及されているし、
以下のようなことも書いてある。

・データの整合性を保つのが誰の役割なのかに注目する
・それがユーザー自身であれば、トランザクション整合性を保つようにすればいい
・それが別のユーザーであれば、結果整合性を保つようにすればいい

結果整合性を守った方が集約の一貫性と普遍性を明確にできるのだが、
そうもいかないことがあるので、
自分の属する組織やシステム要件に対して適切なソリューションを選択することが大事だと思う。
ルールを破るところと、守るところのバランスは難しい。