#Clean Architectureとは
Clean Architecture(クリーンアーキテクチャ)とは,レイヤに内側と外側の関係性を持たせるアーキテクチャである.
「外側のレイヤは内側のレイヤだけに依存する」というルールを守ることによって,アプリケーションから技術を分離することが目的である.
#厳密なClean Architecture
上の図で提案されているレイヤ構造をもとにしてpackage構成に落とし込んだものが以下の図である.
ここで,実線は依存,点線は実装を表している.
このpackage構成について,サンプルコード付きの解説をここでしているので,もし興味があれば読んでください.
しかし,このpackage構成では次のような問題がある.
- データの整合性を保つために複数モデルを扱うような処理はどこに置くのか?
- ドメインモデルに持たせるべきではない処理はどこに置くのか?
それぞれ,詳しく説明していく.
データの整合性を保つために複数モデルを扱うような処理
ゲームでのアイテム購入などがその一例である.この場合,
・ユーザがもつコイン消費 (users
テーブル)
・ユーザがもつアイテム更新 (users_to_items
テーブル)
はトランザクション処理としてまとまっている必要がある.(まとまっていなければ,「コインを消費したがアイテムが増えていない」のような状態になる可能性がある.)
このようなデータの整合性を保つために複数モデルを扱うような処理を置くべきpackageが存在しないのである.
##ドメインモデルに持たせるべきではない処理
あるアイテムをユーザが持っているかの判定などがその一例である.この場合,
・あるアイテムをDBから取得する
・取得できたかどうかを判別し,結果を返す
のように,repository
+αの処理をする必要がある.
このようなドメインモデルに持たせるべきではない処理を置くべきpackageが存在しないのである.
#妥協案v1
上で述べたような問題を解消するために,今までusecase
レイヤにあったrepository
をentity
レイヤに移し,model
とrepository
を操作するservice
を追加する.
これによって,adapter
レイヤからentity
レイヤに,usecase
レイヤを飛ばした依存関係ができてしまうが,そこは妥協する.
このようなpackage構成にすることで,「データの整合性を保つために複数モデルを扱うような処理」や「ドメインモデルに持たせるべきではない処理」を置くべきservice
が誕生するのである.
##トランザクション処理の実装
このpackage構成の場合,トランザクション処理は次のように実装すればよい.
- トランザクションオブジェクトを生成する
- トランザクションオブジェクトを
Context
に入れて,DB操作がまとまった関数を実行する - 各DB操作は,
Context
からトランザクションオブジェクトを取得し,実行する -
error
がnil
であればcommit
,nil
でなければrollback
する
先ほど具体例として出した「ユーザのコイン消費とアイテム更新」の実装を,コードを用いて説明していく.
まず,repository
に,トランザクション用のinterface
を作る.
package repository
type TxRepository interface {
DoInTx(ctx context.Context, f func(ctx context.Context) (interface{}, error)) (interface{}, error)
}
そして,このinterfaceをgateway
で実装する.
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
を用いればよい.
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
ではトランザクションのことを考えずに,ただ呼び出すだけで良くなる.
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などでは,controller
がmodel
を操作することで,DB操作やドメインロジックを実行し,その返り値を受け取り出力する.
では,Clean Architectureでも,controller
がinputPortを実行し,返り値を受け取って出力してもよいのだろうか?
これを考えるために,outputPortを採用することでのメリット・デメリットを考えてみる.
##outputPortのメリット
outputPortを採用することで,入力と出力が分離でき,interactor
が出力を管理できるといったメリットが存在する.
例えば,interactor
がentity
レイヤを操作した結果によって,出力したり出力しなかったりする場合を考える.もし仮に,controller
がinputPort
から返り値を受け取り出力を行う構成だとすれば,「出力するかしないか」の判断をcontroller
がすることになる.しかし,その責務はcontroller
が持つには多すぎるように思われる.むしろ,そのような責務はビジネスロジックを実装しているinteractor
が持つべきなのではないだろうか.
##outputPortのデメリット
outputPortを採用すると,outputPortの生成にhttp.Responswriter
,もしくはそれに相当するものが必要になる.したがって,routingの設定部分でPortの初期化を行えず,controller
内でPortの初期化を行う必要が生じてしまう.よって,
・リクエストの度にPortの初期化処理を行わなければならない
・handlerが増えた場合にも全く同じ処理を書かなければならない
というようなデメリットが生じてしまう.
#妥協案v2
以上のメリット・デメリットを天秤にかけた上で,デメリットの方が大きければ次のようなpackage構成を採用すればよい.
また,Webアプリケーションフレームワークによっては,controller
で出力を行なわなければならず,usecase
レイヤのinteractor
で出力を管理することができないものもある.そのようなフレームワークを使う場合には,上の図のようなpackage構成とならざるをえない.
#まとめ
様々なpackage構成を紹介したが,アーキテクチャに正解はないので,作成するアプリや用いる技術,開発メンバーなどを考慮して,上手にpackage構成を考えていくことが大切である.
参考文献
この記事は以下の情報を参考にして執筆しました.
・pospomeのサーバサイドアーキテクチャ(PDF版)
・Clean Architecture で実装するときに知っておきたかったこと
・The Clean Architecture
・クリーンアーキテクチャのUsecaseはなぜControllerへ値を返すのではなくOutput PortとしてPresenterを呼び出すのか