クリーンアーキテクチャ本を読んでDIPを整理する
こんにちは、N9tE9です。
今回の記事は、クリーンアーキテクチャ本を読み進める中でアーキテクチャ側から見てDIPはどのように働いているのか、DIPを利用することでアーキテクチャ構造がどのように整理されるのかを自分なりにまとめた内容になっています。本記事のコード例はGoで書いています。
DIPについて
DIPは、Solid原則の一つである依存性逆転の原則です。本の中で依存性逆転の原則は下記のように言及されています。
ソースコードの依存関係が抽象だけを参照しているもの
つまり、依存が発生する箇所は抽象化されたデータ構造を参照するようにすべきだという考え方です。なぜ依存をさせるときは抽象化されたデータ構造を参照すべきか考えてみます。そのために、抽象ではない具象に依存させた場合について簡単な例を用いて考えてみます。
DIPに違反した場合
Applicationがデータストアの実装(DB Access)とentityに依存していて、DB Accessがentityに依存しているといった簡単なアプリケーションを考えます。その時の依存関係は以下のような図になります。
このような実装でも永続化されたデータをApplicationが取得し、その後のロジックを実行することは可能です。しかし、このような依存関係には以下のような問題点があります。
- DB Accessの実装がApplicationから見えてしまう(不要なフィールドにアクセスできてしまう)
- データの永続化の拡張性が低くなる
- DB Accessに依存する箇所のユニットテストが困難になる場合がある
これらの問題点をわかりやすくするために、依存関係の図を実装に落とし込みます。mainパッケージはApplicationの箇所に相当し、infrastructureパッケージはDB Accessの箇所に相当します。entityパッケージは、ID,Name,Mail,Addressのフィールドを持ったUser構造体を持ちます。
package main
import (
"fmt"
"example/entity"
"example/infrastructure"
)
func main() {
store := infrastructure.NewInMemoryUserStore()
setup(store)
user, _ := store.UserByID(1)
fmt.Println(user)
for _, u := range store.Users {
fmt.Println(u)
}
}
func setup(store *infrastructure.InMemoryUserStore) {
users := []*entity.User{
&entity.User{
ID: 1,
Name: "hoge",
Mail: "hoge@hoge.hoge",
Address: "hogehoge",
},
&entity.User{
ID: 2,
Name: "fuga",
Mail: "fuga@fuga.fuga",
Address: "fugafuga",
},
&entity.User{
ID: 3,
Name: "piyo",
Mail: "piyo@piyo.piyo",
Address: "piyopiyo",
},
}
for _, user := range users {
store.Create(user)
}
}
package entity
type User struct {
ID int64
Name string
Address string
Mail string
}
package infrastructure
import (
"errors"
"sync"
"example/entity"
)
type InMemoryUserStore struct {
Users map[int]*entity.User
Mux sync.RWMutex
}
func NewInMemoryUserStore() *InMemoryUserStore {
return &InMemoryUserStore{
Users: make(map[int]*entity.User),
Mux: sync.RWMutex{},
}
}
func (i *InMemoryUserStore) UserByID(id int) (*entity.User, error) {
i.Mux.RLock()
defer i.Mux.RUnlock()
user, ok := i.Users[id]
if !ok {
return nil, errors.New("failed to find user")
}
return user, nil
}
func (i *InMemoryUserStore) Create(user *entity.User) error {
i.Mux.Lock()
defer i.Mux.Unlock()
_, ok := i.Users[int(user.ID)]
if ok {
return errors.New("user already exist")
}
i.Users[int(user.ID)] = user
return nil
}
まず、Application側からDB Accessの実装が見えてしまうという点について考えます。NewInMemoryUserStore()
でInMemoryUserStore
構造体を初期化しているため、main側ではInMemoryUserStore
構造体が渡ってきます。そのため、パッケージエクスポートされたInMemoryStore
構造体のメンバ変数やメソッドなどにアクセスすることができてしまいます。上記の実装では、main側からinfrastructure側のパッケージエクスポートされたUsersの値を参照しています。
次に、データの永続化の拡張性が低くなるについて考えます。現状の実装は、プロセスのメモリにデータを保存する構造になっています。その中で、jsonなどのファイルやMySQLやPostgreSQLなどのRDBにデータを保存したいといった拡張の要件が出てきたとき、新規でjson_store.go
などをinfrastructureパッケージの中に作成して実装をしていくかと思います。新規の実装は、InMemoryUserStore
構造体と同じメンバ変数を持った構造体を定義する必要があります。なぜなら、main側でパッケージエクスポートされた変数のUsersを参照しているため、その互換性を持った実装にしなければならないからです。
最後に、DB Accessに依存する箇所のユニットテストが困難になる場合があるについて考えます。今回の実装例は、プロセスのメモリにデータを保存するため、ユニットテストの際に変数でモックしてあげればいいようになっています。ただ、これがデータをDBやファイルといったプロセスのメモリ以外の箇所に保存する場合、その度にファイルのOpen/CloseやDBサーバとの接続が発生するため、テスト自体が結合テストのようになってしまいます。
上記のように具体的な実装で依存関係を作ると、結果として拡張性やテスタビリティの低下を招きます。では、抽象に依存させることでこれらの問題は解決されるのか確認していきます。
DIPに沿った実装
上記と同じアプリケーションをDIPに沿って依存関係を考える時、Applicationがデータストアのインターフェース(DB Access Interface)とentityに依存していて、DB AccessがDB Access Interfaceを実装しているといった具合になります。その時の依存関係は以下のような図になります。DIPに違反した実装の図と比べると、DB Access InterfaceがApplicationとDB Accessの緩衝材になっており、ApplicationとDB Accessの依存関係が逆転していることがわかります。
この依存関係の図を実装に落とし込みます。infrastructureパッケージの中にデータの永続化を抽象化したデータ構造を定義し、その抽象の実装も行っています。mainではNewInmemoryUserAdapter()
を呼び出しており、infrastructureの抽象を受け取るような実装になっています。 infrastructureで定義された抽象のデータ構造を受け取るため、mainは、Users()
,UserByID()
,Create()
しか呼び出すことができないようになっています。
package main
import (
"fmt"
"github.com/lkeix/dip-sandbox/entity"
"github.com/lkeix/dip-sandbox/infrastructure"
)
func main() {
inmemoryAdapter := infrastructure.NewInmemoryUserAdapter()
setup(inmemoryAdapter)
user, _ := inmemoryAdapter.UserByID(1)
fmt.Println(user)
users := inmemoryAdapter.Users()
for _, u := range users {
fmt.Println(u)
}
}
func setup(adapter infrastructure.User) {
users := []*entity.User{
&entity.User{
ID: 1,
Name: "hoge",
Mail: "hoge@hoge.hoge",
Address: "hogehoge",
},
&entity.User{
ID: 2,
Name: "fuga",
Mail: "fuga@fuga.fuga",
Address: "fugafuga",
},
&entity.User{
ID: 3,
Name: "piyo",
Mail: "piyo@piyo.piyo",
Address: "piyopiyo",
},
}
for _, user := range users {
adapter.Create(user)
}
}
package infrastructure
import (
"errors"
"sync"
"github.com/lkeix/dip-sandbox/entity"
)
type inMemoryUserAdapter struct {
users map[int]*entity.User
Mux sync.RWMutex
}
type User interface {
Users() []*entity.User // fetch all users
UserByID(int) (*entity.User, error) // fetch user by id
Create(*entity.User) error // create new user
}
func NewInmemoryUserAdapter() User {
return &inMemoryUserAdapter{
users: make(map[int]*entity.User),
Mux: sync.RWMutex{},
}
}
func (i *inMemoryUserAdapter) Users() []*entity.User {
var users []*entity.User
i.Mux.RLock()
for _, user := range i.users {
users = append(users, user)
}
i.Mux.RUnlock()
return users
}
func (i *inMemoryUserAdapter) UserByID(id int) (*entity.User, error) {
i.Mux.RLock()
defer i.Mux.RUnlock()
user, ok := i.users[id]
if !ok {
return nil, errors.New("failed to find user")
}
return user, nil
}
func (i *inMemoryUserAdapter) Create(user *entity.User) error {
i.Mux.Lock()
defer i.Mux.Unlock()
_, ok := i.users[int(user.ID)]
if ok {
return errors.New("user already exist")
}
i.users[int(user.ID)] = user
return nil
}
では、抽象に依存させた場合にDIPに違反した実装した場合の問題点を解決しているのか確認します。Application側からDB Accessの実装が見えてしまうの問題点について、Application側からinfrastructureのDB Accessの実装であるinMemoryUserAdapter
の実装を知ることができないため、この問題点は解決していると考えられます。
データの永続化の拡張性が低くなるの問題点について、実装の実体がどのようなメンバ変数やメソッドを持っていたとしても、interfaceで提供されているメソッドを満たすように実装すればmain側でもinMemoryUserAdapter
で実装した内容と同じように利用することができます。そのため、既存の実装を意識し互換性を持たせつつ実装する必要がなくなるため、データ永続化の拡張性は保たれるかと思います。
DB Accessに依存する箇所のユニットテストが困難になる場合があるについては、テスト用にUser
インターフェースを満たす実装を持つmockした構造体をテスト時に提供することで、ユニットテストの度にファイルのOpen/CloseやDBへの接続をする必要がなくなります。このようにHumble Objectパターンを適用することで、データストアをmockすることでテスタビリティを向上できると考えます。
このように、依存関係が発生する際に抽象に依存させることで、具体的な実装で依存させるよりも拡張性やテスタビリティを担保しながら開発を行うことができるようになります。拡張性を担保できるのは、抽象化されたデータ構造は、既存の定義の変更はされにくいという特性があるからだと考えます。一方、具体的な実装は、要件変更やリファクタリングによって変更されるタイミングが多いため、拡張性やテスタビリティが制限される場合が多くなるからだと考えます。
DIPに違反しても許容される例
これまでDIPに違反することによって、拡張性やテスタビリティの低下や知る必要のないメンバ変数やメソッドにアクセスできるといった問題点がありました。一方で、DIPに違反しても許容する例がクリーンアーキテクチャの本で言及されていたので、それについても少し考えます。
DIPに違反していても許容される場合は、安定しており変更の少ない実装は許容されると記載されていました。具体的には、アプリケーションが利用するSDKや標準ライブラリ、アプリケーションにおける最重要ドメインといったものが該当するかと思います。
DIPがアーキテクチャに与える影響
ここまでDIPの具体的な実装をもとにDIPの利点などを考えてきましたが、DIPに沿った実装にすることによってアーキテクチャレベルではどのような恩恵が得られるのか考えていきます。
安定した依存関係を作る
DIPに沿った実装にすることでモジュールなどの依存性を逆転させることができます。このDIPの特性を利用することでパッケージや構造体などの安定した依存関係を構築することができます。
パッケージや構造体の安定を定量的に捉えるための安定度について、本中で詳しく書かれているのですが長くなるので本記事では割愛します。
安定依存の原則
パッケージや構造体の安定について少し考えます。DIPに違反しても許容される例でも少し述べていたように、安定したものは最重要ドメインなどが考えられるため、他のパッケージや構造体、関数などから依存されているものの、依存先でどのように利用されているかは知る必要が無いものがそれらに当たると考えられます。上記の実装例で言うとentityパッケージがそれにあたるかと思います。
本の中で安定依存の原則は、下記のように言及されています。
安定度の高い方に依存させること
パッケージ、構造体などの間で依存が発生させるときには、変更や修正が入る可能性が低い安定したものへ依存させるべきであるという考えです。DIPについての例でentityは安定したパッケージではありましたが、entityが新規で依存するパッケージや構造体が出てきた場合を考えます。頻繁に変更や修正が入るものであった場合、それに引っ張られてentityのメンバ変数やメソッドなどを変える必要が出てくる可能性が高くなることが想像できます。
アプリケーションにおいて、全て安定度の高いパッケージや構造体、関数であれば変更しにくくなっていますし、逆に安定度が低ければ、一回の変更で多くの変更を必要とするアーキテクチャになっていると考えられます。もし、安定依存の原則に反している場合があればDIPに沿って依存を逆転させることでこれを軽減することができます。
抽象がアーキテクチャ構造の境界になる
DIPに違反したアーキテクチャ構造の境界について考えてみます。DB Access側の実装の詳細がApplication側で見ることができる構造になっていて、ApplicationとDB Accessは独立したものではないため、アーキテクチャ構造の境界を引くことが難しくなっています。以下のように、仮に境界を引いたとしてもEntityとそれ以外の境界しか引けないかと思います。
一方で、DIPに沿ったアーキテクチャ構造の境界について考えてみます。ApplicationとDB Accessの間にDB Access Interfaceが存在しており、ApplicationとDB Access Interface、DB Access、Entityの3つで独立しているため、下記のようにアーキテクチャの境界を引くことができると思います。
このように、DIPを使うことでアーキテクチャの境界を明確にすることができます。
まとめ
クリーンアーキテクチャの本でSolid原則について述べられていましたが、その中でもDIPに関する話が他の原則よりも章を跨いで取り上げられていたので、自分の知識の整理としてまとめてみました。クリーンアーキテクチャの本を読んでみて、なんとなく理解していたSolid原則がアーキテクチャのコンポーネントに与えている影響などの知識が体系的につけることができたので良い経験でした。
アーキテクチャやデザインパターンなどにおける知識についてはまだまだなので、今後も設計の本やデザインパターンの本についてまとめていけたらと思います。
Discussion