トランザクションを考慮した実装について考える
はじめに
アプリケーションレイヤーでトランザクションを考慮した実装をどのようにすればいいのか悩んでいる人が多いことに気がつきました。オニオンアーキテクチャ等でアプリケーションコードを関心ごとのレイヤーに分離するときに、トランザクションを開始するためのDBとのコネクションの作成をどのレイヤーで実施するのか悩んでいる人が多いそうです。
本記事ではDDD+オニオンアーキテクチャ+Repositoryパターンを使う前提で、私がよく使うトランザクションを考慮した実装について説明しようと思います。
トランザクションを考慮した実装
私はトランザクションを開始するためのDBとのコネクションの作成はUsecase層で実施します。
私がよく書く実装ではDDDでいうEntityを定義します。そしてRepositoryではEntityのCRUDのみ行うように実装し、Repositoryをトランザクション境界にします。そのため、複数のRepositoryを跨いだトランザクション処理を実施するにはRepositoryのメソッドを実行する場所であるUsecase層でトランザクションを開始するためのDBのコネクションを作成します。
例えば、toB向けのSaaSのアプリケーションを作っているとします。
企業からの申し込みに対して企業というEntityとユーザーというEntityを作成したいので、Usecase層を以下のように書きます。
usecase/company.go
func (c *Company) RegisterCompany(ctx context.Context, ...) error {
tx, err := c.db.Begin()
if err != nil {
return ...
}
defer func() {
// トランザクション処理が失敗した場合、DBをロールバックしてエラーを返す
...
}
newCompany, err := domain.NewCompanyToCreate(...)
if err != nil {
return ...
}
err = c.companyRepository.CreateCompanyWithTx(ctx, tx, newCompany)
if err != nil {
return ...
}
newUser, err := domain.NewUserToCreate(...)
if err != nil {
return ...
}
err = c.userRepository.CreateUserWithTx(ctx, tx, newUser)
if err != nil {
return ...
}
err = tx.Commit()
if err != nil {
return ...
}
return nil
}
DBとのコネクションはUsecase層で作成し、Repositoryのメソッドの引数でDBとのコネクションを受け取ります。Usecase層がInfra層に依存しているという批判を受けることもありますが、私は実装のシンプルさと可読性の高さを優先して「DBのコネクションについてはUsecase層がInfra層に依存してもよい」というチームルールを作ってレイヤーの依存関係を特別に無視します。
アーキテクチャが破綻する実装パターン
関心ごとにレイヤーを分離するルールを厳密に守ろうとして無理やり実装するコードを見かけます。無理やり実装するパターンをいくつか紹介します。
ユースケースごとにRepositoryを実装してしまう
DBのコネクションをInfra層だけで扱おうとして、1つのRepositoryでトランザクションを完結させるように実装するパターンです。一番よく見かけます。
企業からの申し込みを実装するなら、以下のように企業というEntityとユーザーというEntityを作成するRepositoryを作成し、CreateCompanyAndUserメソッド内でDBとのコネクションを作成します。
repository/company.go
type Company interface {
CreateCompanyAndUser(ctx context.Context, company *domain.Company, user *domain.User) error
}
infra/company.go
func (c *Company) CreateCompanyAndUser(ctx context.Context, company *domain.Company, user *domain.User) error {
tx, err := c.db.Begin()
if err != nil {
return ...
}
...
}
1つのRepositoryのみでトランザクションを完結させる実装自体が悪いわけではないですが、Usecase層とInfra層にユースケースのロジックが分かれてしまったり、Infra層にユースケースのロジックが集中してUsecase層がスカスカになってしまう可能性が高いです。開発者にとってはInfra層とUsecase層に何を書くべきかが曖昧になってしまうと思っています。
Entityを肥大化させてしまう
次は、1つのRepositoryのみでトランザクションを完結させるためにEntityを肥大化させてしまうパターンです。
以下のように、本来UserとCompanyに分けられるEntityを1つにまとめます。1つのRepositoryでトランザクション処理が完結するので、Repository内でDBとのコネクションを作成すればよいです。一方で、まとめたEntityについていわゆる「神クラス」ができてしまい、構造体のフィールドや振る舞いを変更するときの影響範囲が大きくなるのでコードの変更がしづらくなります。
domain/user.go
type User struct {
ID int
Name string
...
Company *Company
}
repository/user.go
type User interface {
CreateUser(ctx context.Context, user *domain.User) error
}
暗黙的にDBのコネクションを管理する
最後は、Repositoryで暗黙的にDBとのコネクションを作成するパターンです。
以下のように、Repositoryのメソッドの引数のcontext.ContextにDBとのコネクションを持たせておきます。context.ContextにDBのコネクションが無い場合、Repository内でDBとのコネクションを作成してcontext.Contextにセットします。
この実装であればUsecase層で明示的にトランザクション処理を開始するためのDBとのコネクションを作成しなくてもよいです。DBとのコネクションを作成していなければ、Infra層で暗黙的に作成してcontext.Contextを経由してUsecase層に返してくれます。
かつて私が所属していたチームで似たような実装がされていました。この実装によりアーキテクチャは崩壊しなかったのですが、冒頭に私が書いたコードよりかは仕組みが複雑なので、アーキテクチャのルールを守るためだけに実装するのはコスパが悪いと思っています。
infra/user.go
func (c *Company) CreateUser(ctx context.Context, user *domain.User) error {
tx := ctx.GetTxFromContext()
if tx := nil {
tx = c.db.Begin()
}
...
ctx.SetTxToContext(tx)
return nil
}
一般的なアーキテクチャのルールを厳密に守らなくてもよい理由
トランザクションを考慮した実装をどうすればいいか悩む理由は、一般的なアーキテクチャのルールを過度に守ろうとするからです。
一般的なアーキテクチャのルールを守りたい理由は色々あります。例えば、一般的なアーキテクチャのルールは開発者の共通認識になっています。開発者の共通認識を守ってコードを書くと、コードの可読性を上がったり、どこに何を書けばいいかがわかりやすいです。
誰がコードを書くかによって一般的なアーキテクチャのルールをどれくらい守るべきかが変わります。私は本業で自社プロダクトの開発をしています。社内でプロダクトを開発するときは一般的なアーキテクチャのルールを厳密に守る必要はないです。社内でプロダクトを開発するときは、OSSのように不特定多数の開発者が開発に参加するわけではなく、社内の特定のチームメンバーしかコードを管理しません。ローカルルールを作っても開発者全員にローカルルールを認識してもらいやすいので、コードを管理しやすくするために一般的なアーキテクチャを無視したローカルルールを作ることは問題ないと思ってます。
しかし、一般的なアーキテクチャのルールを無視して大量にローカルルールを作ると開発者がルールを把握しながらコードを書くことが大変なので、ローカルルールは適度に作ると良いと思います。
まとめ
トランザクションを考慮した実装について、私がよくする実装と実装が破綻するパターンについて紹介しました。私の意見が正解というわけではないので、プロダクトやアーキテクチャごとにより良いトランザクションを考慮した実装を考えてみてください。
Discussion