golang のレイヤ構造において、他のコードに影響なくインフラレイヤのデータソース実装を差し替えることは可能か?

最近、golang のレイヤ構造において、他のコードに影響なくインフラレイヤのデータソース実装を差し替えることは可能か? という質問を受けた。

回答時間が限られている中で質問を受けたので、
「現実的には難しい」という雑な回答しかできなかった。

さすがに雑すぎるなと思ったので、
自分なりの回答をちゃんと残そうと思う。

影響を受ける対象となるコードは?

golang のレイヤ構造において、他のコードに影響なくインフラレイヤのデータソース実装を差し替えることは可能か?
というところの 他のコード とは具体的にどこになるのか?

これはレイヤアーキテクチャの依存関係上、
UIレイヤとアプリケーションレイヤが該当する。

ドメインレイヤも影響を受ける可能性はあるが、
ドメインレイヤとインフラレイヤに DIP を適用することで回避することができる。

ちなみに、
DIP はあくまで ドメインレイヤが影響を受けないようにするだけ なので、
DIP を適用する = インフラレイヤの差し替えが可能
というわけではない。
インフラレイヤに依存するUIレイヤとアプリケーションレイヤは影響を受ける。

今回はアプリケーションレイヤに注目して考えてみる。
アプリケーションレイヤへの考え方は、そのままUIレイヤに適用できるはず。

以下はアプリケーションレイヤの実装で、
AddUser() では User を保存するのに Repository を利用している。

pakcage application

func AddUser(user User) error {

	dbConnection := NewDBConnection()

	dbConnection.Begin()

	userRepository := NewUserRepository(dbConnection)

	if err := userRepository.Add(user); err != nil {
		dbConnection.Rollback()
		return error
	}

	dbConnection.Commit()

	return nil
}



UserRepository に関しては、
ドメインレイヤに interface を置き、

package domain

type UserRepository interface {
	AddUser(user User) error
}



インフラレイヤに実装を置く。

package infra

type UserRepository struct {
	conn DBConnection
}

func NewUserRepository(conn DBConnection) domain.UserRepository {
	return &UserRepository { conn }
}

func (u *UserRepository) AddUser(user domain.User) error {
	return u.conn.Insert(user)
}



今回はこの実装をベースにインフラレイヤのデータソース差し替えについて考えていく。

MySQL -> PostgresSQL への差し替え

先程紹介した実装が MySQL であるとして、
他のコードに影響なく PostgresSQL に差し替えることはできるだろうか?

結論から言うと、可能だと思う。

なぜかというと、
golangsql パッケージは、具体的な RDB に依存しないように作られているから。

先程紹介したアプリケーションレイヤ実装は擬似コードになっているが、
sql パッケージは利用するSQLドライバを差し替えるだけで MySQL -> PostgresSQL への差し替えができる。

ORM も sql パッケージに準拠しているはずなので、
問題なく差し替えられるはず。

もちろん、SQLドライバの import 文など細かい修正はあるだろうが、
基本インフラレイヤに閉じた修正になるので、
アプリケーションレイヤに影響を与えることはないはず。

ということで、
他の RDB への差し替えはレイヤ構造に関係なく可能だったりする。


MySQL -> WebAPI への差し替え

ここからが本番。
MySQL を WebAPI に差し替えることはできるだろうか?

結論から言うと、実装方法によっては可能 ということになる。

MySQL -> PostgresSQL への差し替えは sql パッケージが互換性を担保してくれていたが、
MySQL -> WebAPI は担保してくれないので、
自前で頑張る必要がある。

今回の実装例だと、
NewDBConnection() のように RDB に依存しているコードが存在する。
RDB に依存しているコードが存在する実装だと、 WebAPI への差し替えは無理。

pakcage application

func AddUser(user User) error {

	//RDBに依存している
	dbConnection := NewDBConnection()

	dbConnection.Begin()

	userRepository := NewUserRepository(dbConnection)

	if err := userRepository.Add(user); err != nil {
		dbConnection.Rollback()
		return error
	}

	dbConnection.Commit()

	return nil
}



どうすれば差し替えられるだろうか?

インフラレイヤにDB依存のコードをまるっと実装してしまう

パッと思いつくのは
インフラレイヤにDB依存のコードをまるっと実装してしまうアプローチ。

アプリケーションレイヤから
DB接続やトランザクションのコードを消してしまい、

pakcage application

func AddUser(user User) error {

	userRepository := NewUserRepository()

	if err := userRepository.Add(user); err != nil {
		return error
	}

	return nil
}



インフラレイヤ にそれらの処理を実装してしまう。

package infra

type UserRepository struct {
	conn DBConnection
}

func NewUserRepository() domain.UserRepository {
}

func (u *UserRepository) AddUser(user domain.User) error {
	u.conn := NewDBConnection()

	u.conn.Begin()

	if err := DBConnection.Insert(user); err != nil {
		u.conn.Rollback()
		return err
	}

	u.conn.Commit()
}



こうすれば、
MySQL -> WebAPI という差し替えでも、
アプリケーションレイヤに修正は不要。
インフラレイヤ内の修正だけ済む。

これは結構万能で、
WebAPI だけではなく、
redis や物理ファイルなどもいけるはず。

PaaS を利用する場合は、
PaaS 独自のオブジェクトを引き回す必要があるかもしれない。

ただ、この実装だとトランザクションが問題になる。

以下のように User と Task を同時に保存する場合、
User と Task にトランザクションをかけることができない。

pakcage application

func AddUser(user User, task Task) error {

	userRepository := NewUserRepository()
	if err := userRepository.Add(user); err != nil {
		return error
	}

	taskRepository := NewTaskRepository()
	if err := taskRepository.Add(task); err != nil {
		return error
	}

	return nil
}

User と Task は結果整合性になるので、
Task の保存が失敗した場合のリカバリ実装が必要になる。

また、アプリケーションレイヤでトランザクション管理していないので、
オブジェクトの整合性がどのスコープで保たれているのか?
を明示できてないのも気になるところ。

User と Task にトランザクションをかけるという観点では、
以下のように User, Task を一緒に保存するような Repository を作れば解決するが、

pakcage application

func AddUser(user User, task Task) error {

	userRepository := NewUserRepository()
	if err := userRepository.Add(user, task); err != nil {
		return error
	}

	return nil
}



しかし、以下の2点により、個人的には積極的に使おうとは思わない。

1. Repository 実装(インフラレイヤ)に User, Task を利用するロジックが流出する可能性がある
流出しないように書けばいいのだが、
チーム開発とかだと流出する可能性が高くなる懸念があるので、
現実的ではないかなと思う
アプリケーションレイヤ内で扱うオブジェクトが増えれば増えるほど、
引数が増えていき、
それに伴ってロジックが流出する可能が高くなるというのも懸念としてある。

2. User, Task を引数に取るインターフェースが不自然
これは完全に個人の好みだが、
Repository は単一の集約を CRUD するものなので、違和感がある。


DDDの場合

ここで、DDDの場合についても言及しておこうと思う。

DDDには
単一のトランザクションでは単一の集約のみ変更可能
というルールがあるので、
先程の User と Task をトランザクションで扱う場合は、
以下のように User が Task を持つようなモデリングになったり、

type User struct {
	Name string
	Tasks []*Task
}



User, Task を含む別の struct を定義して、
それらを保存する Repository を実装することになると思う。
*もちろん、ちゃんと意味のある struct が見つかった場合

type SomeThing struct {
	User User
	Tasks []*Task
}



そもそも、これらの集約のモデリングが難しい という別の問題もあるが、
DDDにおいては、
インフラレイヤにDB依存のコードをまるっと実装してしまうアプローチは悪くない気がしてくる。

と思ったが、
アプリケーションレイヤでトランザクション管理できていないし、
ドメインイベントのイベントストアを扱おうとすると、
このアプローチもしっくりこない。

イベントストアは集約更新と同じローカルトランザクションで管理し、
集約更新とイベント発行に整合性を持たせる必要があるので、
Repository の中でトランザクションを完結させてしてしまうと、
Repository の中でドメインイベントを発行する実装にする必要がある。
*ここは実装方法によって変わるので、「必要がある」とは限らないけど・・・。

しかし、
ドメインイベントは集約の振る舞い(メソッド)を通して発行されるものなので、
Repository の中でドメインイベントを発行する実装も基本的にNG。
Repository はドメインイベントを発行する責務を持たない。
*ただし、集約の振る舞いを通して発行されないドメインイベントは、ドメインイベント自体を集約として扱うので、Repository 内で発行してもOK。

独自の接続オブジェクトを作る

独自の接続オブジェクトを作るというのも、選択肢としてはある気がする。

以下の DBConnection を、

pakcage application

func AddUser(user User) error {

	dbConnection := NewDBConnection()

	dbConnection.Begin()

	userRepository := NewUserRepository(dbConnection)

	if err := userRepository.Add(user); err != nil {
		dbConnection.Rollback()
		return error
	}

	dbConnection.Commit()

	return nil
}



以下のように独自で作った AppConnection に変更する。

pakcage application

func AddUser(user User) error {

	appConnection := NewAppConnection()

	appConnection.Begin()

	userRepository := NewUserRepository(appConnection)

	if err := userRepository.Add(user); err != nil {
		appConnection.Rollback()
		return error
	}

	appConnection.Commit()

	return nil
}



AppConnection の実装は以下。

type AppConnection struct {
	conn DBConnection
}

func NewAppConnection() *AppConnection {
	conn := NewDBConnection()
	return &AppConnection {conn}
}

func (a *AppConnection) Begin() {
	//省略
}
func (a *AppConnection) Commit() {
	//省略
}
func (a *AppConnection) Rollback() {
	//省略
}

このようにDB接続を AppConnection で隠蔽することによって、
アプリケーションレイヤはDB接続に依存することがなくなる。

MySQL -> WebAPI に変更したいときは、以下のように AppConnection を修正する。

type AppConnection struct {
	webAPI WebAPI
}

func NewAppConnection() *AppConnection {
	webAPI := NewWebAPII()
	return &AppConnection {webAPI}
}

func (a *AppConnection) Begin() {
	//WebAPI にはトランザクションがないので、空実装にする。
	return
}
func (a *AppConnection) Commit() {
	//WebAPI にはトランザクションがないので、空実装にする。
	return
}
func (a *AppConnection) Rollback() {
	//WebAPI にはトランザクションがないので、空実装にする。
	return
}



考えるまでもなく、この実装には問題がある。

まず、WebAPI にはトランザクションがない可能性が高い。
WebAPI 以外のデータソースでもトランザクションが存在しないものは多い。

トランザクションが存在しないのに、
AppConnection はトランザクションを利用するための Begin(), Commit(), Rollback() を持っているので、
それらは空実装に変更する必要がある。

アプリケーションレイヤのコードを読むと、
さもトランザクションを利用しているように見えるが、
実際は利用していないということになる。
罠でしかない。

さらに、一部の Repository は WebAPI で、
他の Repository は RDB の場合、
AppConnection は利用できない。

おそらく、
以下のように NewRDBAppConnection() と NewRDBAppConnection() のように、
接続を生成する関数を複数作って、
アプリケーションレイヤで使い分けるような仕組みが必要になる。

type AppConnection struct {
	webAPI WebAPI
	db DBConnection
}

func NewAppConnection() *AppConnection {
	webAPI := NewWebAPII()
	return &AppConnection {
		WebAPI: webAPI,
	}
}

func NewAppConnection() *AppConnection {
	db := NewDBConnection()
	return &AppConnection {
		db: db,
	}
}



アプリケーションレイヤで使い分けるということは、
実装差し替えによって影響が出てしまうということなので、
この実装も微妙。

ちなみに、
こういったDBのトランザクションをラップする独自オブジェクトを作成する実装方法はあまり好きではない。

好きではないというか、
それなりの要件がない限り、
接続オブジェクトのファクトリを作れば事足りると思うので、
実装して嬉しくなるイメージがない。

自分も昔はDBのトランザクションをラップする独自オブジェクトを量産するタイプの人間だったけど、
こういった独自オブジェクトは DBManager, TransactionController のような
パッと見でヤバそうな命名がされている傾向にある気がする。

DDD本 & IDDD本のサンプルはどうなっているか?

他にもデータソースを差し替えれる実装はあるかもしれないが、
そもそも DDD本 & IDDD本 はデータソースの差し替えを担保してるのか?

サンプルも今回の実装例と同じようにデータソースとして RDB を利用している。

ただし、
java + spring ということもあり、
アノテーションRDBトランザクションを管理できるようになっている。
明示的なコミット、ロールバックのコードは不要だし、
ドメインイベントの発行も単一のトランザクションスコープで管理できている。

RDB を WebAPI にするとどうなるだろうか?

RDBトランザクションは効かなくなるが、
アプリケーションレイヤに明示的なコミット、ロールバックのコードが存在しないので、
トランザクションアノテーションを削除すれば修正完了な気がする。

サンプルでアプリケーションの修正が必要と思われるパターンとしては、
アノテーショントランザクションを制御できないデータソースに差し替える
というのが該当するのかな。

どんなデータソースにも差し替え可能というわけではないはず。

自分は java よく分からないので、
ほぼ勘で考察しただけだが、
javaAOP によるサポートがあるので、
golang ほど苦労しない印象を受ける。

差し替える必要はあるのか?

ということで、
自分が考えた結果、
アプリケーションレイヤに影響なく差し替えるのは難しいかなと思った。

自分はどうしているのかというと、
インフラレイヤの差し替えは考慮して実装していない。
差し替えが発生した場合、アプリケーションレイヤの修正も発生するのは仕方ないと思っている。

差し替え対象によって考慮する要素が替わってくるし、
差し替えが発生するかどうかも分からないので、
必要になった際にそれなりの工数をかけるのがシンプルな気がする。
もちろん、直近で想定されるのであれば、それなりの仕組みを実装するのがいいかもしれないが・・・。

ただ、ドメインレイヤに影響を与えることは避けたいので、
DIPを利用し、
ドメインレイヤに置かれている interface は特定の技術に依存しないように気をつけている。

具体的には、
以下のように interface の引数が RDB などの特定技術に依存していると、
間接的にインフラレイヤの修正に引きづられることになるので、

package domain

type UserRepository interface {
	// DBConnection は RDB に依存している
	GetByID(conn DBConnection, id int) (User, error)
}



以下のように依存しないようにしている。

package domain

type UserRepository interface {
	GetByID(id int) (User, error)
}



この場合、DBConnection は interface を実装する struct が持つことになる。

package infra

type UserRepository struct {
	conn DBConnection
}

func (u *UserRepository) GetByID(id int) (User, error) {
}

まとめ

今回はインフラレイヤの差し替えの中で一番面倒であろう、
データソース実装の差し替えについて考えた。
トランザクションを考慮する必要があるので、面倒かなーと。

データソース実装の差し替え以外の差し替えは、
アプリケーションレイヤに影響がない実装にすることが可能なケースが多いと思う。
ex. 暗号化、メール送信

長々と書いたが、
データソース実装の差し替えは色々面倒なので、
DIPドメインレイヤだけ守っていればいーかなと思っている。