💰

GoのDDDでドメインサービス層からインターフェースを介してトランザクションを行う

2024/07/10に公開

GoでバックエンドをDDDで実装を行った。

DDDで悩むのは内側であるドメインサービス層から外側のインフラ層に当たるレポジトリに対してどのようにトランザクションを行うか、ということである。

何度も思いだしたい有名な図

オニオンアーキテクチャ図
出典:ドメイン駆動 + オニオンアーキテクチャ概略

UnitOfWorkパターンなど様々なソリューションがあるが、Goだとポインターの存在とContextによって、TransactionのInterfaceを作り、DIでサービス層からうまくトランザクション操作することが出来たので、共有したいと思う。
(GoやDDDの知見がある方でもし下記に指摘する箇所があればアドバイス頂ければと思う。)

前書き

Javaの前提の言葉だがDDDの実装はPOJO(Plan Old Java Object)であるべき、という考えがあり、基本ライブラリに依存しないことが良いとされる。

そういった意味ではGoでのDDD実装は、基本バックエンドに必要なものが標準ライブラリとして備わっている点において心強かった。

どのように実装したか

まずITransactionというInterfaceは下記の通り実装した

package repository

import (
	"context"
	"database/sql"
)

type ITransaction interface {
	Begin(ctx context.Context) error
	Commit() error
	Rollback()
	Tran() *sql.Tx
	Context() *context.Context
}

database/sqlも有難いことに標準ライブラリであり、ORMパッケージライブラリは、結局こちらを操作するものなので、上記のような書き方が可能であった。

ORMパッケージはSQL boilerを使ったが(これ自体は非常に良かった)実装は下記のようにした。

package repository

import (
	"context"
	"database/sql"

	"server/core/infra/repository"
	"server/infrastructure/logger"
)

var _ repository.ITransaction = &Transaction{}

type Transaction struct {
	db  *sql.DB
	ctx *context.Context
	Tx  *sql.Tx
}

func NewTransaction() *Transaction {
	db := InitDB()

	return &Transaction{
		db: db,
	}
}

func (r *Transaction) Begin(ctx context.Context) error {
	r.ctx = &ctx
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		logger.Errorf("begin transaction error: %v", err)
		return err
	}
	r.Tx = tx
	return nil
}

func (r *Transaction) Commit() error {
	err := r.Tx.Commit()
	if err != nil {
		logger.Errorf("commit error: %v", err)
		return err
	}
	return nil
}

func (r *Transaction) Rollback() {
	err := r.Tx.Rollback()
	if err != nil {
		logger.Errorf("rollback error: %v", err)
	}
}

func (r *Transaction) Exec(query string, args ...interface{}) (sql.Result, error) {
	return r.Tx.ExecContext(*r.ctx, query, args...)
}

func (r *Transaction) Tran() *sql.Tx {
	return r.Tx
}

func (r *Transaction) Context() *context.Context {
	return r.ctx
}

蛇足だがInitDBはシングルトンを使い、このように実装した

InitDB
package repository

import (
	"database/sql"
	"fmt"
	"sync"

	"server/infrastructure/env"
	"server/infrastructure/logger"

	_ "github.com/lib/pq"
)

var (
	Conn *sql.DB
	once sync.Once
)

func InitDB() *sql.DB {
	var err error

	if Conn != nil {
		return Conn
	}
	once.Do(func() {
		user := env.GetEnv(env.PsqlUser)
		password := env.GetEnv(env.PsqlPass)
		host := env.GetEnv(env.PsqlHost)
		port := env.GetEnv(env.PsqlPort)
		database := env.GetEnv(env.PsqlDbname)

		Conn, err = sql.Open("postgres",
			fmt.Sprintf("user=%s password=%s host=%s port=%s dbname=%s sslmode=disable", user, password, host, port, database))
		if err != nil {
			logger.Fatalf("OpenError: %v", err)
			panic("DB couldn't be Opened!")
		}

		if err = Conn.Ping(); err != nil {
			logger.Fatalf("PingError: %v", err)

			logger.Warn("DB couldn't be Connected!")
		}
	})

	return Conn
}

``` 

これにより例えばこのようにユースケース(ドメインサービス層)でトランザクションをDIすることができた。

<small>ちなみにGo的にはContextを全ての関数の引数に渡す考えもあると思うが、DDD的にはあくまでもコントローラーからコンテストを受け取るのではなく、必要であればドメイン/ドメインサービス層から発するべき、という考えの元そのようにしなかった。</small>

下記はユーザーにアタッチされたクーポンを使用するユースケース処理の一例。

package user

import (
	"context"

	"server/core/entity"
	"server/core/errors"
	queryservice "server/core/infra/queryService"
	"server/core/infra/repository"

	"github.com/google/uuid"
)

type UserAttachedCouponUsecase struct {
	usercouponRepository repository.IUserCouponRepository
	usercouponQuery      queryservice.IUserCouponQueryService
	transaction          repository.ITransaction
}

func NewUserAttachedCouponUsecase(usercouponRepository repository.IUserCouponRepository, usercouponQuery queryservice.IUserCouponQueryService,
	transaction repository.ITransaction,
) *UserAttachedCouponUsecase {
	return &UserAttachedCouponUsecase{
		usercouponRepository: usercouponRepository,
		usercouponQuery:      usercouponQuery,
		transaction:          transaction,
	}
}

func (u *UserAttachedCouponUsecase) UseMyCoupon(AuthUserID uuid.UUID, couponID uuid.UUID) *errors.DomainError {
	coupon, err := u.usercouponQuery.GetByID(AuthUserID, couponID)
	if err != nil {
		return errors.NewDomainError(errors.QueryError, err.Error())
	}
	if coupon == nil {
		return errors.NewDomainError(errors.QueryDataNotFoundError, "該当のクーポンIDが見つかりません。")
	}
	if AuthUserID != coupon.UserID {
		return errors.NewDomainError(errors.InvalidParameter, "ユーザー自身のクーポンではありません。")
	}
	if coupon.Status != entity.CouponIssued {
		return errors.NewDomainError(errors.UnPemitedOperation, "発行済ステータスのクーポンではありません。")
	}

	if coupon.UsedAt != nil {
		return errors.NewDomainError(errors.UnPemitedOperation, "クーポンはすでに使用済みです。")
	}

	usedCoupon := entity.UseUserAttachedCoupon(
		AuthUserID,
		coupon.Coupon,
	)
	ctx := context.Background()
	err = u.transaction.Begin(ctx)
	if err != nil {
		u.transaction.Rollback()
		return errors.NewDomainError(errors.RepositoryError, err.Error())
	}

	err = u.usercouponRepository.Save(u.transaction, usedCoupon)
	if err != nil {
		u.transaction.Rollback()
		return errors.NewDomainError(errors.RepositoryError, err.Error())
	}
	err = u.transaction.Commit()
	if err != nil {
		return errors.NewDomainError(errors.RepositoryError, err.Error())
	}

	return nil
}

上記は一例で無論もっと複雑な処理もドメインサービス層から行ったが、無事途中でエラーが出てもデータの一貫性も担保された処理を行うことができた。

Discussion