DDDとトランザクション

去年の夏前くらいにDDDまわりの勉強していた。でこのメモを書いていたので少し直して晒してみる。 前回の続編。

ソフトウェアアーキテクチャや設計論はたくさんあってややこしい。なので自分なりに整理してみる。

前回、戦術的モデリングの最後に「実装するときに難しい点があると感じた」と書いたのでその辺りに触れる。 一貫性境界となる集約とライフサイクルに関わるファクトリとリポジトリについて考えてみる。

今回のテーマ

  • 集約のルール
  • リポジトリのパターン
  • ファクトリのパターン
  • 注意点
    • 集約から他の集約を参照する場合: ドメインサービスを利用する
    • トランザクションを使いたい: 参照ロックなど
      • アプリケーションサービス(ユースケース)で実装する
      • Txサーポートするインフラ依存をインタフェース経由で外部から受け取る
    • エンティティを生成する時点で識別子を与えたい; ファクトリと永続化のタイミング
      • ID発行のタイミングはドメイン全体で統一する
      • リポジトリで保存するタイミングで発行
      • ファクトリで作成時に発行

集約のルール

集約は生合成の協会を定めるものであり、オブジェクトグラフを設計したいという理由で作るものではない。

IDDD本で説明されているルールは4つ。

  • 真の不変条件を、整合性の境界内にモデリングする
  • 小さな集約を設計する
  • 他の集約への参照は、その識別子を利用する
  • 境界の外部では結果整合性を用いる

リポジトリのパターン

トランザクションはドメインレベルで実装しない、アプリケーションやユースケースレベルで実装する。 クリーンアーキテクチャのユースケース部分が実装することになる。 クリーンアーキテクチャではアダプタをインタフェースで実装していて外部から渡すことになっている。

DDDでのリポジトリは典型的には次の2つが多い。

  • コレクション指向のリポジトリ
  • 永続指向のリポジトリ

提供するインタフェースが違う。 どちらのインタフェースも永続化については辞書のように単一のインスタンスを保存する。 複数の集約やエンティティを同時に扱うことはしない。

コレクション指向のリポジトリ

1
2
3
4
5
6
7
interface UserRepository {
  fun add(user: User): Boolean
  fun addAll(user: List<User>): Boolean
  fun remove(user: User): Boolean
  fun removeAll(user: List<User>): Boolean
  fun size(): Int
}

永続指向のリポジトリ

1
2
3
interface UserRepository {
    fun save(user: User): Boolean
}

ファクトリのパターン

値やエンティティを生成する責任を持つのがファクトリ。 ファクトリはドメインサービスや集約のメソッドとして提供されることが多い。

注意点

実装するときに特に悩む部分について注意点として3つ説明する。

  • 集約から他の集約,エンティティの属性を使いたい
  • トランザクションを利用したい
  • エンティティを生成する時点で識別子を与えたい

集約から他の集約,エンティティの属性を使いたい

ある集約が他のエンティティや集約の属性を利用する場合には、コマンドメソッドにオブジェクトを渡す形でサービスを設計する。

リポジトリやドメインサービスを集約に渡す設計にしてしまうとユースケースで同一オブジェクトを使い回せる場合にもインフラへの再取得などの余計な処理が多発しがちになる。 (Goっぽい疑似コードを書く。Goで書いたつもりだけど久しぶりなのとコンパイラに通していない)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type SomeAggregation struct {
    root SomeEntity
    status Status
    budget int
}

func (a *SomeAggregation) goodExample(other SomeAggregation) error {
   a.budget += other.budget
}

func (a *SomeAggregation) badExample(id int, repo *SomeAggregationRepo) error {
   other := repo.get(id)
   a.budget += other.budget
}

トランザクションを使いたい

一般的に集約は一貫性の境界と一致させる。 こんな記事も書かれている。 ただし記事で書かれているインフラを変更しやすいというメリットをリポジトリのメソッドにトランザクションを閉じ込める事から得るわけではない。

一貫性の境界と集約を一致させるのが原則だが、集約と一貫性の境界を一致させにくいこともある。 集約に複数エンティティが含まれると一貫性の境界が集約の外に漏れてしまいがちだ。

例えば、2つの銀行口座間での送金を考えてみる。 この例では口座残高より多額の送金は禁止されるとする。

また送金による状態変化は常に維持される必要があるとして送金を集約として扱う。 言い換えると、システムはどの時点でも全ての口座の残高が非負の整数であり、送金による全ての口座の総額は変化しない。

これを制約として、送金という集約に2つの口座を含む設計をしたとする。 そして、この送金をリポジトリ経由で永続化する方法を3つ考える。 結局、トランザクションを受け入れるのが現実的に思える。

  • CHECK制約とストアドプロシージャで頑張る
  • 集約をイベントソーシングで全て永続化する
  • トランザクションを受け入れる

まず1つめの方法として、IDから口座のエンティティを参照しないでデータベースの制約(CHECK制約など)で対応することを考える。

1
2
3
4
5
CREATE TABLE account (
  id     int,
  amount int,
  CHECK (amount >= 0)
);

このテーブルに対してストアドプロシージャで送金を表現してリポジトリの内部でストアドプロシージャを呼び出す。 このようにすれば、ドメインモデルのコードにはミドルウェアへの依存が含まれない。 しかし、ドメイン知識である口座の制約がデータベースやクライアントライブラリで実装することになる。 言い換えると、ドメインを表しているコードに明示するべき制約が別の箇所に移動してしまう。 これは辛すぎる。(もちろんバグなどへの保険として、DBで制約を保証するのは正しい)

2つめの方法として集約の永続化では全てをユニークなイベントとして扱ってみる。 そしてイベントソーシングやCQRSで参照時に整合するスナップショットを構築する。 参照時に、シリアライズできなかったイベントをアボートとして無視する。

この方法をとれば常に制約を満たしたスナップショットを得られる。 しかし、口座を取得するために全てのイベントを読み込み計算するか、イベントにその時点での口座を明記する必要がある。

前者では読み込み時の負担が高すぎる。 後者では読み込み時にアボートされるイベントが過剰に生じる。

さらには集約を永続化する時点でアボートされてしまうか判断できず、アプリケーションとして役に立たない。

そのため永続時にアボートかコミットか判断することが必要になる。 これはトランザクションなど並行性制御が要求されることを意味する。

自前で実装するよりも、(不完全と指摘されてはいるものの)トランザクションをサポートするデータベースを利用した方がいい。

最後に3つめとしてトランザクションを受け入れる。 制約をドメインモデルの集約で表明(アサーション)しつつ、ユースケースやアプリケーションで全体のトランザクションを管理する。 要するに、アプリケーションはミドルウェアの機能に依存しているので仕方ないと諦めてしまう。 それでもアプリケーションからインフラ・ミドルウェアへの依存を隠したかったらインタフェースを定義すれば良い。 そこまで徹底するとクリーンアーキテクチャに似てくる。

Goっぽい擬似コードを書くと下みたいになる。

1
2
3
4
5
6
7
8
func NewTransaction(amount int, from, to *Account) (*Transaction, error) {
  if from.amount - mount < 0 {
    return nil, errors.New("lack of amount")
  }
  return &Transaction{
    from: from, to: to, amount: amount
  }, nil
}

これでドメインルールがドメインモデル内に明記することができた。 しかし一貫性の範囲が集約外のエンティティも含んでしまった。 そのため複数のエンティティを取得したり集約を永続化することをトランザクションとして保護する必要がでてくる。

IDDD本などDDDな人が主張する 集約を一貫性境界とするために他の集約を書き換えてはならない というガイドラインや 外部の集約の参照には識別子を使う といったルールを守っても 関係がない

トランザクションがないと結果整合性すら実現できなくなる。 Write Skey AnomaryLost Update を考えるとわかりやすいが基本的にトランザクションを保護しないのであれば全てのアノマリーが起きえる。

トランザクションはアプリケーションレイヤで管理する。クリーンアーキテクチャではユースケースレイヤと呼ばれる部分とほぼ同じ。

アプリケーションレベルでトランザクションを扱うため、リポジトリの実装は少なくともアプリケーションより外側で行うことになる。

アプリケーションレイヤでインタフェース(トランザクションを共有するリポジトリ達のファクトリ)を定義して外から実装を渡す形にするとクリーンアーキテクチャにとても近くなる。

Goで擬似コードを書いた。

DataAccessDataAccessImpl の分離などJavaっぽいというかGoっぽくない。 ただ、この分離は必要になった時にすればよく最初から実装を分離しなくていい。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
type DataAccess interface {
  func Transaction(tx func())
  func AccountRepository() AccountRepository
  func TransactionRepository() TransactionRepository
}

type DataAccessImpl struct {
  conn *db.Connection
  session *db.Session
}
func (i *DataAccessImpl) Transaction(tx func()) error {
  i.session = i.conn.Begin()
  if e := tx(); e != nil {
    i.session.Rollback()
    return e
  }
  return i.session.Commint()
}
func (i *DataAccessImpl) AccountRepository() AccountRepository {
  return &AccountRepoImp{
    session: i.session,
  }
}
func (i *DataAccessImpl) TransactionRepository() TransactionRepository {
  return &TransactionRepoImp{
    session: i.session,
  }
}

func (i *DataAccess) SomeApplicatinoService(from, to account.Id, amount int) (err error) {
  return i.Transaction(func () error {
    accountRepo := i.AccountRepository()
    transactionRepo := i.TransactionRepository()
    
    fa := accountRepo(from)
    ta := accountRepo(to)
    transaction, e := domain.NewTransaction(fa, ta, amount)
    if e != nil {
      return e
    }
    transactionRepo.Put(transaction)
    return nil
  })
}

エンティティを生成する時点で識別子を与えたい

エンティティにはライフサイクルがある。その同一性の判定に識別子が必要となる。 この識別子の作り方はモデリングで問題になる。

まず、ファクトリでの生成時に識別子を割り当てるのか。 派生して、ファクトリで識別子を割り当てない場合にリポジトリを経由するまで同一性をどうするか。

以下に実装候補を列挙するが、サブコンテキスト内で全てのエンティティ,集約で同じ実装方針を選ぶと混乱が少ない。

  • UUIDなど確率的に衝突が少ないIDをアプリケーション内で割り当てる
    • 発行したUUIDを永続化の際に識別子として利用する
    • 発行したUUIDは永続化まで利用して、永続化の際に別の識別子をミドルウェアの機構を利用して再発行する
  • ファクトリの実装でミドルウェアを利用して事前にIDを発行する
    • 発行せず、同一性は扱わない方針とする
    • 発行せず、ID未定義時の同一性はポインタの一致で定義する
    • 発行せず、ID未定義時は同一性は値の一致で定義する

最後の「発行せず、ID未定義時は同一性は値の一致で定義する」は選ぶとバグの温床になるのであまり勧められない。 「発行せず、ID未定義時の同一性はポインタの一致で定義する」という方針が現実的で扱いやすいと考えている。

感想

DDDのトランザクションの関係を考えてみた。 トランザクションを捨てるのが困難な場合がある。 そしてアプリケーションレイヤで管理せざる得ない。

ただし、避けられないかをはじめに考えるのが大事だ。

comments powered by Disqus