sano11o1

Golangでトランザクションを抽象化してビジネスロジック層の中で扱う

公開日:

はじめに

ビジネスロジック層の中でトランザクションを使いたい、でもビジネスロジック層にデータベース,トランザクション等の特定の永続化層のコードを書きたくない、そんなことはないでしょうか。
トランザクションを抽象化してビジネスロジック層の中で扱うコードを、Golang, ORマッパーのGORMを使って紹介します。
サンプルコードは以下のリポジトリに置いてます。
https://github.com/sano11o1/go-transaction

本投稿は Go (Golang): Clean Architecture & Repositories vs Transactions の実装を参考にしています。


想定するファイル構成

ビジネスロジックを置く層をUsecase層と呼びビジネスロジックはここにまとめることとします。
Usecase層はプロジェクトによってはService層とも呼ばれていたりします。
Service層はApplicationService、DomainServiceに分かれている実装もありますが、説明をシンプルにするため、今回はUsecase層とします。

├── entity
│   └── user.go
├── go.mod
├── go.sum
├── main.go
├── repository
│   └── user_repository.go
└── usecase
    └── register_user_usecase.go



Repository層はInterfaceとImplementに別れています。Implementで実際にデータベースにアクセスする処理を書きます。

package repository

# repository/user_repository.go

import (
  "github.com/sano11o1/go-transaction/entity"
  "gorm.io/gorm"
)

type IUserRepository interface {
  AddUser(entity.User) error
}

type UserRepositoryImpl struct {
  db *gorm.DB
}

func NewUserRepositoryImpl(db *gorm.DB) IUserRepository {
  return &UserRepositoryImpl{
    db: db,
  }
}

func (r *UserRepositoryImpl) AddUser(user entity.User) error {
  return r.db.Create(&user).Error
}



Usecase層の中でrepsitory層のInterfaceに定義されたメソッドを呼び出すことで、Usecaseが特定の永続化層を意識する必要しなくても良い構成になっています。

package usecase

# usecase/register_user_usecase.go

import (
  "time"
  "github.com/google/uuid"
  "github.com/sano11o1/go-transaction/entity"
  "github.com/sano11o1/go-transaction/repository"
)

type RegisterUserUsecase struct {
  baseRepository repository.IBaseRepository
  userRepository repository.IUserRepository
}

func NewRegisterUserUsecase(userRepository repository.IUserRepository) *RegisterUserUsecase {
  return &RegisterUserUsecase{
    userRepository: userRepository,
  }
}

func (u *RegisterUserUsecase) Execute(user entity.User) error {
  if err := u.userRepository.AddUser(entity.User{
    Name: "sano11o1",
  }); err != nil {
    return err
  }
  return nil
)



データの保存先がデータベースからインメモリに変わっても、Usecase層に変更を加えなくても良いのです。

最後にmain.goです。データベースの構造体、RepositryのImplementを外から注入します。

package main


import (
  "fmt"


  "github.com/sano11o1/go-transaction/entity"
  "github.com/sano11o1/go-transaction/repository"
  "github.com/sano11o1/go-transaction/usecase"
  "gorm.io/driver/postgres"
  "gorm.io/gorm"
)


func main() {
  db, err := initDB()
  if err != nil {
    panic(err)
  }
  userRepository := repository.NewUserRepositoryImpl(db)
  registerUserUsecase := usecase.NewRegisterUserUsecase(userRepository)
  newUser := entity.User{
    Name: "sano11o1",
  }
  if err := registerUserUsecase.Execute(newUser); err != nil {
    fmt.Println("failed to register user", err.Error())
    panic(err)
  }
}


func initDB() (*gorm.DB, error) {
  host := "127.0.0.1"
  port := "5431"
  user := "postgres"
  dbName := "postgres"
  password := "passw0rd"
  dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Tokyo", host, user, password, dbName, port)
  var err error
  db, err := gorm.Open(postgres.Open(dsn))
  if err != nil {
    fmt.Println("failed to connect database", err.Error())
    return nil, err
  }
  return db, nil
}


まあ、よくある構成でしょうか。


この記事で書かないこと

そもそもUsecase層にトランザクションを扱わず、Repository層に書けば良いのではないか?という意見もあると思います。
私自身どの層でトランザクションで扱うべきかさまざまな実装、本を読んで勉強中です。
自分の中でどこに置くべきかは結論は出ていないため、どこでトランザクションを扱うべきかについては書きません。
もしもビジネスロジックの中でトランザクションを扱うなら、どのように実装するかのサンプルとして参考にしていただけたらと思います。

トランザクションをUsecase層で扱う例

ユーザーを作成するUsecaseで、データベースと外部サービスの両方にユーザーを保存するケースを例に考えてみます。
最終的に、自社データベースと外部サービスの両方にデータがあるか、もしくは両方ともにデータがないことのどちらかに定まることを期待します。

APIを叩いた後にInsertする、DBにInsertした後にAPIを叩く、2通りの実装パターンが考えられます。
厳密に両方にデータが作られることを保証したい場合、前者は不適格でしょう。

func (u *RegisterUserUsecase) Execute(user entity.User) error {
  // 外部サービスにUserを作成
  req, err := http.NewRequest("POST", "https://example.com/user", nil)
  if err != nil {
    return err
  }
  req.Header.Set("Content-Type", "application/json")
  client := new(http.Client)
  _, err = client.Do(req)
  if err != nil {
    return err
  }

  // DBにUserを作成
  if err := u.userRepository.AddUser(entity.User{
    Name: "sano11o1",
  }); err != nil {
    // Insertに失敗した場合外部サービスにのみデータが存在する
    return err
  }
   return nil
}


レコード作成が何らかの理由で失敗した場合、外部サービスにのみデータが存在することになります。
外部サービスにユーザー作成を取り消すエンドポイントが実装されているかもしれません。
取り消しのリクエストを送信する実装も考えられますが、この通信が成功する保証はありません。

サービスにどれほどのデータの厳密さを求めるかによりますが、データの整合性を求めるなら順番を入れ替えるべきです。

func (u *RegisterUserUsecase) Execute(user entity.User) error {
  // トランザクションの中で行いたい処理
  {
    // DBにUserを作成
    if err := u.userRepository.AddUser(entity.User{
      Name: "sano11o1",
    }); err != nil {
      return err
    }
    // 外部サービスにUserを作成
    req, err := http.NewRequest("POST", "https://example.com/user", nil)
    if err != nil {
      return err
    }
    req.Header.Set("Content-Type", "application/json")
    client := new(http.Client)
    _, err = client.Do(req)
    if err != nil {
      // 外部サービスとの通信が失敗した場合ロールバックする
      return err
    }
   return nil
  }


もしレコードのInsertに失敗した場合、APIへのPOSTは実行されません。
APIへのPOSTが失敗した場合、ロールバックすることでレコード作成を取り消します。


トランザクションを抽象化する

本題です。
Usecase層の中でトランザクション、データベースという永続化層の具象型を意識したコードは書きたくないですよね。
トランザクションを扱うコードをRepository層のImplementに実装するコードを紹介します。

package usecase

# usecase/register_user_usecase.go

import (
  "net/http"

  "github.com/sano11o1/go-transaction/entity"
  "github.com/sano11o1/go-transaction/repository"
)

type RegisterUserUsecase struct {
  baseRepository repository.IBaseRepository
}

func NewRegisterUserUsecase(baseRepository repository.IBaseRepository) *RegisterUserUsecase {
  return &RegisterUserUsecase{
    baseRepository: baseRepository,
  }
}


func (u *RegisterUserUsecase) Execute(user entity.User) error {
  atomicBlock := func(r repository.IBaseRepository) error {
    userRepo := r.GetUserRepository()
    if err := userRepo.AddUser(user); err != nil {
      return err
    }
    // 外部サービスにUserを作成
    req, err := http.NewRequest("POST", "https://example.com/user", nil)
    if err != nil {
      return err
    }
    req.Header.Set("Content-Type", "application/json")
    client := new(http.Client)
    _, err = client.Do(req)
    if err != nil {
      // 外部サービスとの通信が失敗した場合ロールバックする
      return err
    }
    return nil
  }
  err := u.baseRepository.Atmoic(atomicBlock)
  return err
}


package repository

# repository/base_repository.go

import (
  "gorm.io/gorm"
)

type IBaseRepository interface {
  Atmoic(fn func(IBaseRepository) error) error
  GetUserRepository() IUserRepository
}

type BaseRepositoryImpl struct {
  db *gorm.DB
}

func NewBaseRepositoryImpl(db *gorm.DB) IBaseRepository {
  return &BaseRepositoryImpl{
    db: db,
  }
}

func (r *BaseRepositoryImpl) Atmoic(fn func(IBaseRepository) error) error {
  // r.dbするにはmain.goでdbを初期化しBaseRepositoryImplを生成する際に渡す必要がある
  return r.db.Transaction(func(tx *gorm.DB) error {
    return fn(NewBaseRepositoryImpl(tx))
  })
}

func (r *BaseRepositoryImpl) GetUserRepository() IUserRepository {
  // NewUserRepositoryImplの引数にはBaseRepositoryImplのDBを渡す
  return NewUserRepositoryImpl(r.db)
}



新たにbase_repository.goを定義し、トランザクションで実行する関数を受け取るAtomicメソッドを定義します。

func (r *BaseRepositoryImpl) Atmoic(fn func(IBaseRepository) error) error {
}


Atomicメソッドの引数には、トランザクションで実行する処理をまとめた関数を渡します。
変数atomicBlockに代入した関数は引数にIBaseRepositoyを受け取り、errorを返す関数です。

 atomicBlock := func(r repository.IBaseRepository) error {
    userRepo := r.GetUserRepository()
    if err := userRepo.AddUser(user); err != nil {
      return err
    }
    // 外部サービスにUserを作成
  }
  err := u.baseRepository.Atmoic(atomicBlock)


あくまでatomicBlockは関数そのものの代入であり実行結果を代入しているわけではありません。
そのため引数rの実態は関数を定義した段階では確定しないことに注意してください。

Atomicメソッドの実装をみていきます。

func (r *BaseRepositoryImpl) Atmoic(fn func(IBaseRepository) error) error {
  // r.dbするにはmain.goでdbを初期化しBaseRepositoryImplを生成する際に渡す必要がある
  return r.db.Transaction(func(tx *gorm.DB) error {
    return fn(NewBaseRepositoryImpl(tx))
  })
}


引数には、fn func(r repositoy.IBaseRepository) error を受け取ります。
受け取った関数fnを呼び出しています。呼び出している箇所に注目してください。

GORMではトランザクションで実行したい処理をfunc(tx *gorm/DB) error関数のブロックに記述、db.Transactionメソッドの引数に渡します。
func(tx *gorm/DB) error関数がerrorを返した場合、ロールバックします。
参考: トランザクション
つまり、変数AtomicBlockに代入した関数がerrorを返すとロールバックされるということです。

引数にはIBaseRepositoryの実態であるBaseRepositryImplを生成し渡しています。
BaseRepositryImplの生成時にtxを渡しています。txBaseRepositryImpl構造体のフィールドdbにセットされます。
txTransactionメソッドを実行した時に確定する値で、同じtxに対しメソッドを実行することで、共通のトランザクションを共有することができます。


Atomicメソッドの引数に戻ってIBaseRepositoryの実態であるBaseRepositryImplがどのように使われるか確認します。

atomicBlock := func(r repository.IBaseRepository) error {
    userRepo := r.GetUserRepository()
    if err := userRepo.AddUser(user); err != nil {
      return err
    }


rの実態はBaseRepositryImplです。rに対してGetUserRepositoryを呼び出します。

func (r *BaseRepositoryImpl) GetUserRepository() IUserRepository {
  // NewUserRepositoryImplの引数にはBaseRepositoryImplのDBを渡す
  return NewUserRepositoryImpl(r.db)
}

すると、構造体BaseRepositryImpldbフィールドの値を元に、UserRepositoryImplが作成されます。
これによりdbフィールドにtxを持ちます。
AddUserメソッド内部ではr.dbtxとなるため、同一のトランザクションでInsertが実行されることになります。

func (r *UserRepositoryImpl) AddUser(user entity.User) error {
  return r.db.Create(&user).Error
}


最後にmain.goを紹介します。

package main

import (
  "fmt"

  "github.com/sano11o1/go-transaction/entity"
  "github.com/sano11o1/go-transaction/repository"
  "github.com/sano11o1/go-transaction/usecase"
  "gorm.io/driver/postgres"
  "gorm.io/gorm"
)

func main() {
  db, err := initDB()
  if err != nil {
    panic(err)
  }
  baseRepostiry := repository.NewBaseRepositoryImpl(db)
  registerUserUsecase := usecase.NewRegisterUserUsecase(baseRepostiry)
  newUser := entity.User{
    Name: "sano11o1",
  }
  if err := registerUserUsecase.Execute(newUser); err != nil {
    fmt.Println("failed to register user", err.Error())
    panic(err)
  }
}
// initDB は割愛


当初はNewRegisterUserUsecaseの引数にUserRepositoryImplを渡していましたが、UserRepositoryImplBaseRepository.GetUserRepositoryを経由して生成することになったため、引数からは消えています。
新たにBaseRepositoryImlpを渡しています。BaseRepositoryImplには*gorm.DBを渡必要があります。
ここで渡さないと、Atomicメソッド内部でr.db.Transactionを呼び出すことができません。

func (r *BaseRepositoryImpl) Atmoic(fn func(IBaseRepository) error) error {
  // r.dbするにはmain.goでdbを初期化しBaseRepositoryImplを生成する際に渡す必要がある
  return r.db.Transaction(func(tx *gorm.DB) error {
    return fn(NewBaseRepositoryImpl(tx))
  })
}


懸念点

  • RegisterUserUsecaseの依存するRepository層が、RegisterUserUsecaseのExexcuteメソッドの実装を見ないと把握できない。(UserRepositoryImplBaseRepository.GetUserRepositoryを経由して生成することになったため)
  • 抽象化によりロジックを読み解くのに時間がかかる。このメソッドの戻りがここにきて〜最終的にこの値がセットされて〜とコードを行ったり来たりする必要がある。


終わりに

ビジネスロジック層の中でトランザクションをいい感じに扱う方法を検討しました。
repository層のInterface, Usecase層では「GORM」を一切使用していないコードになっています。
そのため他のORマッパーに書き換える場合はrepository層のImplementを書き換えるだけで良いはずです。
同様にImplementに手を入れるだけで、他の永続化層に乗り換えることができるはずです。

しかし永続化層を乗り換えるなんてこと、今後のプログラマー人生で経験することはあるのでしょうか。
その時は本実装のように、永続化層が抽象化されビジネスロジックと分離している実装になっていて欲しいものですね。