DDDとトランザクション

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

前回、戦術的モデリングの最後に「実装ではアプリケーションレベルでの難所があるよなぁと思った」と書いたのでその辺りを考える。

わけのわからない文になっていたので少し直した。

難しさは主にトランザクションにある。 トランザクションはアプリケーションレベルの概念でドメインでは直接は扱わない。 ドメイン駆動設計(DDD)でドメインレベルで関連する概念に集約がある。

今回はまず一貫性境界となる集約とライフサイクルに関わるファクトリとリポジトリについて考えてみる。 集約やファクトリ・リポジトリによる表現が扱いにくさを生む側面について考える。 そのあとサブコンテキストごとにサービス化を行うマイクロサービス(MS)・サービス指向アーキテクチャ(SOA)で出てくる問題について触れる。 最後に、コード実装上の選択肢で悩みがちなところをまとめておく。

今回のテーマ

  • トランザクションと関わるドメインオブジェクト
    • 集約のルール
    • リポジトリのパターン
    • ファクトリのパターン
  • 集約・リポジトリの困難
    • 集約の表現力について
    • リポジトリの表現力について
    • ドメインモデルから漏れる正当性
      • イベントの永続化について
      • マイクロサービスでの正当性の管理
  • 実装上の注意点
    • 集約から他の集約,エンティティの属性を使いたい
    • 集約で表現しづらい制約はトランザクションを受け入れる
    • エンティティを生成する時点で識別子を与えたい

トランザクションと関わるドメインオブジェクト

前回の復習になる。

要素 特徴
集約 一貫性を保つ境界を為し内部にエンティティ,値を保持する
ファクトリ 生成を担当する
リポジトリ 永続化,検索などを担当する

集約のルール

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

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
}

ファクトリのパターン

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

1
2
3
4
5
6
7
8
class User(val id: String, val name: String) {
    companion object Factory {
      fun create(name: String): User {
        val id = UUID.randomUUID().toString()
        return User(id, name)
      }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class UserService() {
    companion object Factory {
      fun create(name: String): User {
        val id = UUID.randomUUID().toString()
        return User(id, name)
      }
      fun apple(u1: User, u2: User): Boolean {
        // ...
        return true
      }
    }
}

集約・リポジトリの困難

振り返った集約・リポジトリを中心に困難について考える。 主に2つ。集約と関連してリポジトリの表現力に関するもの。

集約の表現力について

集約はルート要素から値・エンティティ・集約を辿れる構造になっている。そのため木構造となる。 ツリーは表現力が高いがそれでも1対多の関係が限界で多対多を扱えない。 そのため集約では多対多に関わる制約を表現できない。

このような制約をドメインレベルで扱うには、ドメインサービスによる確認が考えられる。 この場合、複数のドメインサービスによる参照を通じた操作が線形化できるかはデータ基盤や設定・利用方法(ユースケースやリポジトリ)に依存する。

これにより下の2つを選ばないとならないことになる。無自覚にアプリケーションを書くと1つめになることが多い。 (トレードオフを自覚して選べば問題ないが無自覚に選ぶとバグということになる)

  • ドメインモデル自体が結果整合性を前提にする
    • アプリケーションの制約を緩くする
  • ドメインモデルは適切な参照を行った前提で設計する
    • リポジトリの実装やユースケースで満たすべき適時性を保証する

リポジトリの表現力について

集約は一貫性を保つ境界に対応する。 そのためドメインモデルは複数の集約の永続化が(いつ読み込んだとしても)同期的に扱われるとは期待しない。 言い換えると、複数の集約をまたいた線形性は期待できない。

これを前提にエンティティ間に多対多の関係がある操作の実装を考える。 DDD的に正しい集約は操作対象となるエンティティをすべて含む操作イベントとなる。

悩んだのはそのような集約をドメインモデルに含めた場合に、どのようにリポジトリを実装するかだ。 データ基盤を正しく使わないとアプリケーションが壊れる。実装候補についてあとで書く。

ただ、いくつかの実装は大きくなるため、扱うアプリケーションが小さい場合にはDDD的に正しくない実装を受け入れる選択肢もありだと思った。 このグレーノウハウは、複数のエンティティを扱うリポジトリとアプリケーションレイヤでのトランザクションの実行を指している。

ドメインモデルから漏れる正当性

  • 自己の事前状態に依存した更新
  • 部分更新
  • 複数のエンティティを含んだ操作

これらをドメインモデルで表現しても永続化の正当性がシステムの状態に依存していて扱いが難しい。 ただし、3つめはエンティティを管理している場所が異なる場合に現れる。

ドメイン駆動設計ではこれらをイベントとして捉えることが行われることが多い。 そしてイベントの永続化の中や後続処理で適切に扱うという形で実現する。

イベントの永続化について

前述したイベントを永続化する部分について考える。 まず、操作イベントの永続化を完了した時点でエンドユーザーに操作の完了を通知することはできない。 特に関連するエンティティがシステム内の別々の場所で管理される場合には、整合した状態を保つための合意が必要になる。

そのためドメインレベルで操作イベントを表現してリポジトリ経由で永続化を行う場合、少なくとも以下の実装方法を考えることになる。

  • イベントを 意図 として永続化して、別途、 完了イベント(Commit/Abort) を発行する
  • イベントの永続化時点で同期的に対応する
    • トランザクションを扱えるDBを内部で利用する
    • 合意・全順序ブロードキャストを使う

1つ目はログベースのイベントストリームを基盤にしたイベントソーシングを使ったアプローチ。 強い整合性を諦めている。ログベースのストリーム基盤を使うことで結果整合性を実現できる。

2つ目はトランザクションを使っている。 合意・全順序ブロードキャストを別記したのはアプリケーションコードの見た目が大きく変わりがちなため。

この3つのアプローチはどれも利用できる。

小さいアプリケーションでヘテロなシステムでもなく書き込みへのスケールを軽視できるなら、 単一のDBでのトランザクションを使うのは悪くない選択肢だと思う。

どのスタイルでの実現でもエンドユーザーからの誤った複数の要求を防止するためにユニークなIDを利用してユニーク制約を実現する必要がある。

マイクロサービスでの正当性の管理

複数のコンテキストにまたがった処理が行われる場合について考える。 ここでは強い整合性を諦めた場合について考える。

結果整合性までに妥協するときは、サーガ(長期プロセス)やコーディネーターを担当するコンシューマが必要になる。 これらは、複数サービスへリクエストと合意アルゴリズムを使ったり別のイベントストリームを生成しイベントストリーム上で合意を実現したりする。

イベントストリームを用いたアプローチではログストリーム基盤(KafkaとかAmazon Kinesis)を使う必要がある。 同期的に完了をエンドユーザーに通知する場合は、これらの処理を待つようにリポジトリ実装を作る必要がある。

実装上の注意点

最後に、実装するとき悩みがちな選択肢と注意点を3つ思いついたので書いておく。 どの実装を選んでも、また結果整合性をやめたとしても、次を守っていると扱いやすい。

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

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

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

リポジトリやドメインサービスを集約に渡す設計にしてはいけない。 ユースケースで同一オブジェクトを使い回せる場合もインフラへの再取得などの余計な処理が多発しがちになる。 goodExample(), badExample() として擬似コードを書いておく。

 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
}

集約で表現しづらい制約はトランザクションを受け入れる

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

  • CQRSを行い 、横断的な処理を1つの集約にまとめる
    • イベントソーシングを使う
    • トランザクションを活用する
      • リポジトリの永続化処理の中でトランザクションを使う
      • アプリケーションの処理全体をトランザクションで保証する
  • 単純な集約を維持して CQRSを行わない
    • トランザクションを活用する
      • リポジトリの永続化処理の中でトランザクションを使う
      • アプリケーションの処理全体をトランザクションで保証する

CQRSを使わずにイベントソーシングを使うのは局所的には意味がないので除外した。 導出データ(検索インデックス)の更新を行いたいときは必要になる。

ここでトランザクションを受け入れるって主張をありと考えたのは下の通りになる。

イベントソーシングを活用するといくつか問題は解決するが、 トランザクションの再実装に近いことをすることになり書き込み時に拒否する実装を作るのが難しくなる。

これは次のどれかが満たされないと選ぶメリットが少ない。

  • 書き込みのスループットが大事
    • 書き込み時の制約を緩くできる (謝罪などでビジネス的な対処プランが作れている)
    • 書き込み後に拒否したり整合性を回復することが許される
  • 多数のサービスやデータ基盤が互いに更新通知を受け取って調整される必要がある

書き込みのスループットが大事でなくて多数のサービスやデータ基盤に更新通知が不要ならば、 (不完全と指摘されてはいるものの)トランザクションをサポートするデータベースを利用するのが楽なので選ぶのがいい。

なので最初に候補にあげるべきは、CQRSを活用しつつトランザクションを活用するパターンになる。 そして、トランザクションの範囲はユースケース全体となる。 トランザクション範囲をリポジトリ内部に閉じ込めると、トランザクション外での参照が有効か再び確認しないとならない。 これはかなり無駄な処理が増える。

やや気持ち悪いがユースケースはミドルウェアの機能に依存しているので仕方ないと諦めると単純に書ける。 それが嫌な場合はユースケースもインタフェースを定義して、トランザクションを共有するリポジトリ群のファクトリを受け取れば綺麗に書ける。 少しクリーンアーキテクチャにとても近くなる。

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未定義時は同一性は値の一致で定義する」は選ぶとバグの温床になるのであまり勧められない。 「発行したUUIDを永続化の際に識別子として利用する」という方針が安全で現実的で扱いやすいと考えている。

IDにはUUIDが一般的だけどv1からv5まで色々あるしSnowflakeなど幾つか代替実装もあるので、 何を使うかは大事な選択肢として残っている。

感想

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

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

comments powered by Disqus