GoでトランザクションをRepositoryから隠してみた

複数のRepositoryをまたぐ処理を書くたびに、トランザクションをどこで扱うのがいいのか悩んでいました。Repositoryに*sql.Txを渡すとDBの実装が上の層まで見えてしまうし、UseCaseから隠しすぎると、今度はどこまでが一つのトランザクションなのか読み取りにくい。

今回、トランザクションの扱いを整理するために、architecture-sampleというサンプルプロジェクトを自分で作って実装してみました。その結果、自分の中でこの悩みがかなりクリアになりました。まだ検討の余地はありますが、「この形なら使えそう」と思えたので、仕組みと気づきをまとめます。

境界はUseCaseで決める

このサンプルでは、Application層に次のようなTxManagerがあります。

type TxManager interface {
    Do(ctx context.Context, fn func(ctx context.Context) error) error
}

RegisterUserWithInitialOrderやRegisterOrderは、Doのcallback内で複数のRepository操作を行います。つまり「どの操作をまとめて成功・失敗させるか」は、業務処理を組み立てているUseCaseが決めています。

return txManager.Do(ctx, func(ctx context.Context) error {
    if err := userRepository.Save(ctx, user); err != nil {
        return err
    }
    return orderRepository.Save(ctx, order)
})

この形だと、トランザクションの範囲がUseCaseを読めば分かります。一方で、RepositoryのAPIはctxとモデルを受け取るだけです。*sql.Txを引数に取らないので、Application層にSQL実装の型を持ち込まずに済みます。

SQLの切り替えはInfrastructure側に寄せる

Infrastructure層のsqlTxManager.Doはdb.BeginTxでトランザクションを開始し、context.WithValueで*sql.Txをcallback用のContextに格納します。callbackがエラーを返したらRollbackし、成功したらCommitします。

Repository側ではdb_executor.goのgetDB(ctx, db)がContextからtxmanager.ExtractTx(ctx)を探します。見つかれば*sql.Tx、なければ通常の*sql.DBを返し、共通のDBExecutor経由でSQLを実行します。Repositoryから見ると、呼び出し元がトランザクション内かどうかをAPI上で区別する必要はありません。

この分担が気に入りました。境界はApplication層、CommitとRollbackはtransaction manager、SQL実行先の選択はInfrastructure層。Repositoryに*sql.Txを意識させずに、UseCaseには業務上のまとまりを残せています。

「意識しない」にも限界はある

もちろん、Repositoryがトランザクションを完全に知らないわけではありません。Contextにトランザクションが入るという暗黙の規約があり、Repository実装はtxmanagerパッケージにも依存しています。

特に気をつけたいのは、UseCaseがtransaction managerから渡されたContextをRepositoryへ渡し忘れた場合です。getDBはトランザクションが見つからないと通常の*sql.DBへフォールバックするため、更新の一部だけがトランザクションの外で実行される可能性があります。Repositoryへcontext.Contextを渡すこと自体は型付き引数で定義されていますが、そのContextにトランザクションが格納されているかどうかは型で区別できず、コンパイル時には保証されません。テストやレビューでこの規約を守れているか確認する必要があります。

また、nested transactionをどう扱うか、goroutineを使う処理に同じContextをどう渡すかも、必要になった時点で方針を決めたいところです。Contextが依存を運ぶ便利な仕組みだからといって、何でも詰め込んでよいわけではありません。

もう一点、サンプルの簡易実装ではcallbackがpanicした場合にRollbackを保証するdeferがありません。エラーを返す通常経路のCommit/Rollbackとは別に、panic時の後始末も考えておく必要があります。小さなサンプルとしては分かりやすいですが、そのまま完成形だとは考えない方がよさそうです。

テストで境界を確かめる

sqlite_repository_test.goの統合テストでは、ユーザーと注文を保存したあとに意図的にエラーを返し、両テーブルの件数が0になることを確認しています。transaction managerにもCommit、Rollback、ExtractTxのテストがあります。2026-09-26時点でgo test ./...も成功しており、少なくともこの構成では複数RepositoryをまたぐRollbackをテストで確かめられます。

こういうテストがあると、「Context経由で本当に同じトランザクションを使えているのか」という、コードだけでは追いにくい部分を確認できて安心です。

もっといい方法はある?

要件次第だと思います。小規模な構成なら、今回のようにUseCaseからTxManager.Doを呼ぶ形は実用的です。RepositoryのAPIはシンプルなままで、トランザクション境界も追いやすい。自分が悩んでいた「Repositoryに*sql.Txを渡すべきか」という点には、かなり納得できる答えになりました。

暗黙性を減らす選択肢としては、Application層のUnit of Workやtransaction runnerが、トランザクションに紐づくRepository群をcallbackに渡す形があります。たとえばDo(ctx, func(ctx, repos) error)のようにすれば、callback内で使うRepositoryが同じ作業単位に属することをAPIで表現できます。

あるいは、トランザクション開始時にtx-boundなRepositoryやExecutorを明示的に作って渡す方法もあります。Contextへの依存は見えやすくなりますが、生成や受け渡しのAPIは少し複雑になります。Unit of WorkもRepository群の型や責務をどう設計するかが増えるので、どちらも万能ではありません。

今まで曖昧だったトランザクションの置き場所が、今回の実装を読んでだいぶクリアになりました。まずはこの形を使いつつ、暗黙のContext規約が負担になってきたら、次はUnit of Workを試してみたいです。Clean Architectureに唯一の正解があるというより、境界の分かりやすさとAPIの複雑さを、プロジェクトに合わせて選ぶ話なのだと思います。