LoginSignup
111

More than 3 years have passed since last update.

【Go】厳密なClean Architectureとその妥協案

Last updated at Posted at 2021-03-07

Clean Architectureとは

Clean Architecture(クリーンアーキテクチャ)とは,レイヤに内側と外側の関係性を持たせるアーキテクチャである.
「外側のレイヤは内側のレイヤだけに依存する」というルールを守ることによって,アプリケーションから技術を分離することが目的である.

厳密なClean Architecture

CleanArchitecture.jpg
上の図で提案されているレイヤ構造をもとにしてpackage構成に落とし込んだものが以下の図である.
スクリーンショット 2021-03-06 18.16.36.png
ここで,実線は依存,点線は実装を表している.
このpackage構成について,サンプルコード付きの解説をここでしているので,もし興味があれば読んでください.

しかし,このpackage構成では次のような問題がある.
1. データの整合性を保つために複数モデルを扱うような処理はどこに置くのか?
2. ドメインモデルに持たせるべきではない処理はどこに置くのか?
それぞれ,詳しく説明していく.

データの整合性を保つために複数モデルを扱うような処理

ゲームでのアイテム購入などがその一例である.この場合,
・ユーザがもつコイン消費 (usersテーブル)
・ユーザがもつアイテム更新 (users_to_itemsテーブル)
はトランザクション処理としてまとまっている必要がある.(まとまっていなければ,「コインを消費したがアイテムが増えていない」のような状態になる可能性がある.)
このようなデータの整合性を保つために複数モデルを扱うような処理を置くべきpackageが存在しないのである.

ドメインモデルに持たせるべきではない処理

あるアイテムをユーザが持っているかの判定などがその一例である.この場合,
・あるアイテムをDBから取得する
・取得できたかどうかを判別し,結果を返す
のように,repository+αの処理をする必要がある.
このようなドメインモデルに持たせるべきではない処理を置くべきpackageが存在しないのである.

妥協案v1

上で述べたような問題を解消するために,今までusecaseレイヤにあったrepositoryentityレイヤに移し,modelrepositoryを操作するserviceを追加する.
これによって,adapterレイヤからentityレイヤに,usecaseレイヤを飛ばした依存関係ができてしまうが,そこは妥協する.
 スクリーンショット 2021-03-07 0.02.08.png
このようなpackage構成にすることで,「データの整合性を保つために複数モデルを扱うような処理」や「ドメインモデルに持たせるべきではない処理」を置くべきserviceが誕生するのである.

トランザクション処理の実装

このpackage構成の場合,トランザクション処理は次のように実装すればよい.
1. トランザクションオブジェクトを生成する
2. トランザクションオブジェクトをContextに入れて,DB操作がまとまった関数を実行する
3. 各DB操作は,Contextからトランザクションオブジェクトを取得し,実行する
3. errornilであればcommitnilでなければrollbackする

先ほど具体例として出した「ユーザのコイン消費とアイテム更新」の実装を,コードを用いて説明していく.
まず,repositoryに,トランザクション用のinterfaceを作る.

entity/repository/transaction.go
package repository

type TxRepository interface {
    DoInTx(ctx context.Context, f func(ctx context.Context) (interface{}, error)) (interface{}, error)
}

そして,このinterfaceをgatewayで実装する.

adapter/gateway/transaction.go
package gateway

type TxRepository struct {
    conn   *sql.DB
}

// GetDBConn はTxRepositoryが保持しているConnectionを返します.
func (tr *TxRepository) GetDBConn() *sql.DB {
    return tr.conn
}

// NewTxRepository は*sql.DBを受け取り,TxRepositoryを返します.
func NewTxRepository(conn *sql.DB) repository.TxRepository {
    return &TxRepository{
        conn:   conn,
    }
}

// DoInTx は,トランザクションオブジェクトを生成し,contextに入れて,次の関数を実行し,errorに応じて適切にrollbackやcommitを行います.
func (tr *TxRepository) DoInTx(ctx context.Context, f func(ctx context.Context) (interface{}, error)) (interface{}, error) {
    // txを生成する
    conn := tr.GetDBConn()
    tx, err := conn.Begin()
    if err != nil {
        return nil, fmt.Errorf("begin transaction: %w", err)
    }
    // txをcontextに入れて次の関数を実行する
    // SetTxのような関数は存在していないので,各自実装する必要あり
    ctx = context.SetTx(ctx, tx)
    v, err := f(ctx)
    if err != nil {
        _ = tx.Rollback()
        return v, fmt.Errorf("rollback: %w", err)
    }
    if err := tx.Commit(); err != nil {
        _ = tx.Rollback()
        return v, fmt.Errorf("failed to commit: rollback: %w", err)
    }
    return v, nil
}

その後,まとめたい処理をserviceに書き,DoInTxを用いればよい.

entity/service/user.go
package service

type UserService interface {
    UpdateCoinAndItemTx(ctx context.Context, userID string, coin int32, itemID string) error
}

type User struct {
    userRepository repository.UserRepository
    itemRepository repository.ItemRepository
    txRepository   repository.TxRepository
}

// NewUserService は,UserServiceを返します.
func NewUserService(txRepository repository.TxRepository, userRepository repository.UserRepository, itemRepository repository.ItemRepository) UserService {
    return &User{
        txRepository:   txRepository,
        userRepository: userRepository,
        itemRepository: itemRepository,
    }
}

// updateCoinAndItem は,ユーザのコイン消費とアイテム更新を行います.
func (u *User) updateCoinAndItem(userID string, coin int32, itemID string) func(ctx context.Context) (interface{}, error) {
    return func(ctx context.Context) (interface{}, error) {
        userRepository := u.userRepository
        itemRepository := u.itemRepository
        // コイン消費
        // userRepository.UpdateUserCoinByPrimaryKeyTx では,contextからtxを取得して実行する
        if err := userRepository.UpdateUserCoinByPrimaryKeyTx(ctx, userID, coin); err != nil {
            return nil, err
        }
        // アイテム更新
        // itemRepository.InsertUserItemTx では,contextからtxを取得して実行する
        if err := itemRepository.InsertUserItemTx(ctx, userID, itemID); err != nil {
            return nil, err
        }
        return nil, nil
    }
}

// UpdateUserAndItemTx は,トランザクション内で,ユーザのコイン消費とアイテム更新を行います.
func (u *User) UpdateCoinAndItemTx(ctx context.Context, userID string, coin int32, itemID string) error {
    txRepository := u.txRepository
    if _, err := txRepository.DoInTx(ctx, u.updateCoinAndItem(userID, coin, itemID)); err != nil {
        return err
    }
    return nil
}

このようにserviceに実装しておくことで,interactorではトランザクションのことを考えずに,ただ呼び出すだけで良くなる.

usecase/interactor/user.go
package interactor

type User struct {
    OutputPort  port.UserOutputPort
    UserService service.UserService
}

func NewUserInputPort(outputPort port.UserOutputPort, userService service.UserService) port.UserInputPort {
    return &User{
        OutputPort:  outputPort,
        UserService: userService,
    }
}

func (u *User) BuyItem(ctx context.Context, userID string, coin int32, itemID string) {
    op := u.OutputPort
    us := u.UserService
    if err := us.UpdateCoinAndItemTx(ctx, userID, coin, itemID); err != nil {
        // エラー用のアウトプットポートを呼び出す
        op.RenderError()
        return
    }
    // 正常用のアウトプットポートを呼び出す
    op.Render()
}

outputPortの必要性

次に,outputPortを採用するべきかということについて考えてみる.
通常のMVCなどでは,controllermodelを操作することで,DB操作やドメインロジックを実行し,その返り値を受け取り出力する
では,Clean Architectureでも,controllerがinputPortを実行し,返り値を受け取って出力してもよいのだろうか?
これを考えるために,outputPortを採用することでのメリット・デメリットを考えてみる.

outputPortのメリット

outputPortを採用することで,入力と出力が分離でき,interactorが出力を管理できるといったメリットが存在する.
例えば,interactorentityレイヤを操作した結果によって,出力したり出力しなかったりする場合を考える.もし仮に,controllerinputPortから返り値を受け取り出力を行う構成だとすれば,「出力するかしないか」の判断をcontrollerがすることになる.しかし,その責務はcontrollerが持つには多すぎるように思われる.むしろ,そのような責務はビジネスロジックを実装しているinteractorが持つべきなのではないだろうか.

outputPortのデメリット

outputPortを採用すると,outputPortの生成にhttp.Responswriter,もしくはそれに相当するものが必要になる.したがって,routingの設定部分でPortの初期化を行えず,controller内でPortの初期化を行う必要が生じてしまう.よって,
・リクエストの度にPortの初期化処理を行わなければならない
・handlerが増えた場合にも全く同じ処理を書かなければならない
というようなデメリットが生じてしまう.

妥協案v2

以上のメリット・デメリットを天秤にかけた上で,デメリットの方が大きければ次のようなpackage構成を採用すればよい.スクリーンショット 2021-03-07 0.34.32.png
また,Webアプリケーションフレームワークによっては,controllerで出力を行なわなければならず,usecaseレイヤのinteractorで出力を管理することができないものもある.そのようなフレームワークを使う場合には,上の図のようなpackage構成とならざるをえない.

まとめ

様々なpackage構成を紹介したが,アーキテクチャに正解はないので,作成するアプリや用いる技術,開発メンバーなどを考慮して,上手にpackage構成を考えていくことが大切である.

参考文献

この記事は以下の情報を参考にして執筆しました.
pospomeのサーバサイドアーキテクチャ(PDF版)
Clean Architecture で実装するときに知っておきたかったこと
The Clean Architecture
クリーンアーキテクチャのUsecaseはなぜControllerへ値を返すのではなくOutput PortとしてPresenterを呼び出すのか

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
111