Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

【戦術的DDD】なぜトランザクションをユースケース層で張るのか

概要

こんにちは。サーバーサイドエンジニアの窪田です。 これまでの戦術的DDDについて以下のような記事で紹介してきました。

戦術的DDDをGoで実現する【entity編】 - Yappli Tech Blog

戦術的DDDをGoで実現する【Value Object編】 - Yappli Tech Blog

Deep Moduleという観点から戦術的DDDのRepositoryの設計を考えてみた - Yappli Tech Blog

今回は戦術的DDDにおけるトランザクションの扱いについて注目します。 トランザクションは一見インフラ層の関心ごとなのでインフラ層で完結するように思えますが、DDDの本にある例では、ユースケース層で張っているソースコードの例が紹介されています。 なぜ、そのような設計になるのかを考えていきます。

DDDとトランザクションの関係

DDDとトランザクションは実は深い関係にあります。 『実践ドメイン駆動設計』 10章「集約」で述べられているように集約はトランザクションの範囲によって定義されています。

同じ集約についてはトランザクション整合性(強い整合性)をとり、別集約とは結果整合性(弱い整合性)を取るような設計をするように書かれています。

例えば、ユーザーがアイテムを複数所持するというドメインを考えます。 まず、以下のドメインモデル図のように、ユーザー集約とアイテム集約が存在しているようなモデリングをしたとします。(点線が集約境界を示します)。 この時実装は以下のようになります。 user集約とitem集約が存在するのでそれぞれのrepositoryが定義されることになります。 本筋とは関係ないですが、『実践ドメイン駆動設計』に書かれている通り、別集約の紐付きは参照(ID)をインスタンスが保持することで表します。

// user entity
type User struct {
    ID       int
    UserName string
    ItemIDs  []int
}

// item entity
type Item struct {
    ID       int
    ItemName string
}

// user repository
type UserRepository interface {
    Get()
    Save()
}

// item repository
type ItemRepository interface {
    Get()
    Save()
}

一方、user entity, item entityが同じ集約であるならば

実装は以下のようになります。

// user entity
type User struct {
    ID       int
    UserName string
    Items []*Item
}

// item entity
type Item struct {
    ID       int
    ItemName string
}

// user repository
type UserRepository interface {
    Get()
    Save()
}

user集約はuser entityが集約ルートになり、user entity, item entityは同じトランザクションでUserRepositoryによって永続化されます。

なぜユースケース層でトランザクションを張るのか

一見repository内でトランザクションを張れば良いように見える

前述した実装例を見ると、集約自体がトランザクション範囲を示しているのでそれぞれのrepositoryでトランザクションを張れば良いように見えます。 sqlのクライアントライブラリであるsqlxをヤプリでは使っているので、これを例にします。

type userRepositoryImpl struct {
    db *sqlx.DB
}

func NewUserRepository(db *sqlx.DB) UserRepository {
    return &userRepositoryImpl{
        db: db,
    }
}

func (u *userRepositoryImpl) Save(ctx context.Context, e *entity.User) error {
    tx, err := u.db.BeginTxx(ctx, nil)
    _, err = tx.Exec("UPDATE users SET name = ? WHERE id = ?;", e.Name, e.ID)
    if err != nil {
        tx.Rollback()
        return err
    }
    err = tx.Commit()
    if err != nil {
        return err
    }
    return nil
}

のようにrepositoryの実装を定義すると、永続化時にSaveメソッド内でトランザクションを張ることができます。 useCase層からは

e := entity.NewUser()
userRepository.Save(ctx, e)

と呼べば良さそうです。(よくない場合があるのを後述します)。

repositoryでトランザクションの扱いを完結すると困ること

上で紹介した方法のままだと実は取得->更新の一連の流れを1個のuseCaseで行う場合に困ることになります。

// repositoryのインスタンス化
var userRepository = NewUserRepository(db)

// userの名前変更のuseCase
func ChangeName(ctx context.Context, userID int, newName string) error {
    // user entityのインスタンスを再構築
    u, err := userRepository.Get(ctx, userID)
    if err != nil {
        return err
    }
    // ドメインモデルで定義した振る舞いの実行
    err = u.ChangeName(newName)
    if err != nil {
        return err
    }

     // 永続化
    return userRepository.Save(ctx, e)
}

以下のようなSQLが発行されます。(id等は例です)。

SELECT * FROM users WHERE id = 1;

START TRANSACTION;
UPDATE users SET name = "newName" WHERE id = 1;
COMMIT;

これで問題ない場合もありますが、プロダクトによっては頻繁にユーザーネームの変更が行われる場合があります。 その場合、

START TRANSACTION;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
UPDATE users SET name = "newName" WHERE id = 1;
COMMIT;

のようにSQLを発行して行ロックをかける必要が出てくるかもしれません。 この時、先ほどのSaveメソッドの中でトランザクションを張る実装では実現できません。

ユースケース層でトランザクションを張る設計例

useCaseでトランザクションを張る一例を示します。 『実践ドメイン駆動設計』『ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本』等では、@Transactionalアノテーションをメソッドに当てる例が示されています。 しかし、Goではそんなことはできないので、自前でトランザクションを開く必要があります。

domain層で以下のようにインターフェイスを定義し、

// domain層 interface
type TransactionRepository interface {
    Transaction(ctx context.Context, f func(tx *sqlx.Tx) error) error
}

infrastructure層で以下のようにインターフェイスを満たす実装をします。

type transactionRepositoryImpl struct {
    db *sqlx.DB
}

func NewTransactionRepository(db *sqlx.DB) TransactionRepository {
    return &transactionRepositoryImpl{
        db: db,
    }
}

func (t *transactionRepositoryImpl) Transaction(ctx context.Context, f func(tx *sqlx.Tx) error) error {
    tx, err := t.db.BeginTxx(ctx, nil)
    if err != nil {
        return err
    }

    err = f(tx)
    if err != nil {
        tx.Rollback()
        return err
    }

    err = tx.Commit()
    if err != nil {
        return err
    }
    return nil
}

これによって、useCaseは以下のようにかけます。

// repositoryのインスタンス化
var userRepository = NewUserRepository(db)
var transactionRepository = NewTransactionRepository(db)

func ChangeName(ctx context.Context, userID int, newName string) error {
    err := transactionRepository.Transaction(ctx, func(tx *sqlx.Tx) error {
        // repositoryに外からtransactionを与える
        u, err := userRepository.Get(ctx, tx, userID)
        if err != nil {
            return err
        }

        err = u.ChangeName(newName)
        if err != nil {
            return err
        }
        // repositoryに外からtransactionを与える
        return userRepository.Save(ctx, tx, e)
    })
    if err != nil {
        return err
    }
    return nil
}

useCase層でtransactionを張る様子がわかると思います。 この場合、repositoryの各メソッドにtransaction構造体を渡すことになります。 (これはこれでテストが書きやすいというメリットがあったり、インターフェイスが複雑になるというデメリットがあったりしますがここでは深掘りません)。 何はともあれ実現できました。DDDの本に書いてある形に近づきました。

DBのクライアントライブラリがドメイン層に登場するという課題

Goの実装でDDDの本に書いてあるようなuseCase層でトランザクションを張る方法を示しましたが、課題もあります。

// domain層 interface
type TransactionRepository interface {
    Transaction(ctx context.Context, f func(tx *sqlx.Tx) error) error
}

というinterfaceの書き方を見るとわかりますが、domain層がsqlxパッケージに依存しています。 sqlxはDBのクライアントライブラリなので、本来domainに関係なさそうなものがdomain層に侵食してくることになります。 例えば、sqlxの使用をやめ、他のライブラリに変える場合もこのinterfaceも変更することになりdomain層に影響が出ます。 本で紹介されているようなKotlinの@Transactionlのような仕組みも本質的には同じなのですが、仕組み上、気にならなかったり誤魔化されたりしています。

この課題に対しては

  • 外部ライブラリにはそもそも依存している前提でプロダクトは成り立っているので気にしない
  • うまくinterfaceを作り、sqlxの存在を隠蔽する

が思いつきます。 後者については、結局sqlxをラップしたinterfaceを定義する必要がどこかではあるので、個人的には外部ライブラリ依存については許容して良いのではないかと思っています。

まとめ

  • DDDとトランザクションの関係について確認しました。
  • UseCase層でトランザクションを張る理由を示しました。
  • 戦術的DDDの実装ではインフラのトランザクションの機構に依存した記述になる部分も出てくることを確認しました。

実はドメイン駆動と言いながら、集約境界についてはインフラの都合が大きく関わってきて、切り離せないのが味噌であることを感じられる例でした。 ヤプリのエンジニアは日々戦術的DDDを実現する上でGoならではの悩みについて考えていたりします。

この辺りに興味があれば、是非一度カジュアル面談でお話できればと思います!

open.talentio.com