最近、Goのプロジェクトで大規模なリファクタリングを行ったのでまとめる。
元々このプロジェクトでは、以下のようにフラットにGoファイルを置く構成になっていた。
- cmd/api/main.go
- user.go
- user_db.go
- user_db_test.go
- user_handler.go
- user_handler_test.go
- server.go
しばらくはこれで問題なかったが、1年ほど開発が続くと、だんだんとプログラムのファイル数が増えていき、プログラムの見通しが悪くなってきた。
また、同じパッケージに全て置いているため、循環参照の問題は発生しないが、名前が衝突して使用できなくなる問題が発生するようになった。
整理方法を検討した結果、今回はルートにドメインパッケージを置く構成を採用した。
ルートにドメインパッケージを置く構成とは
ルートにドメインパッケージを置く構成とは、 リポジトリのルートにドメインパッケージを作り、依存関係に応じたサブパッケージに実装をまとめる構成のことで、 以下のBen Johnson氏による「Standard Package Layout」の記事で紹介されているパッケージ構成を参考にしている。
記事中では、「Standard Package Layout」と紹介されているものの、 この名前だと本筋と違う議論になってしまいそうなので、 この記事では少々長いが「ルートにドメインパッケージを置く構成」と呼ぶことにする。
たとえば、MySQLを使ったバックエンドAPIを想定すると、 ルートドメインパッケージでは、以下のような構成となる。
- cmd/api/main.go
- mysql/
- user.go
- http/
- user_handler.go
- server.go
- user.go
ドメインパッケージのファイルはGitリポジトリのルートに必ず置く必要はないので、 リポジトリのルートに直接Goファイルを置きたくない場合や、Gitリポジトリ名がドメインを表す名前ではない場合は、 ルートドメイン用のディレクトリを作ってコードを置けば良い。
仮にプロダクト名がmyapp
だったとすると、ドメインパッケージがmyapp
となる。
- cmd/api/main.go
- myapp/
- mysql/
- user.go
- http/
- user_handler.go
- server.go
- user.go
myapp/user.go
は以下のように定義され、プログラムの各所からこのモデルが使われることになる。
package myapp
type User struct {
Name string
Age int
}
type UserService interface {
CreateUser(uid string) (*User, error)
GetUser(uid string) (*User, error)
UpdateUser(uid string) (*User, error)
DeleteUser(uid string) error
}
mysqlパッケージでは、myapp.UserService
インターフェースを満たすように具体的な実装を書く。
package mysql
type UserService struct {
db *sqlx.DB
}
func (s *UserService) CreateUser(uid string) (*myapp.User, error) {
// MySQLを使った具体的な実装を書く
}
httpパッケージでは、直接mysqlパッケージのmysql.UserService
は使わず、myappパッケージのインターフェースを使うようにする。
package http
type UserHandler struct {
userService myapp.UserService
}
今回は、mocksディレクトリにgomock経由でルートパッケージのモックを生成し、 サブパッケージでルートパッケージのサービスに依存する場合は、mocks以下のモックを使うようにした。
package myapp
//go:generate mockgen -destination=./mocks/user_service_mock.go -package=mocks . UserService
type UserService interface {
CreateUser(uid string) (*User, error)
GetUser(uid string) (*User, error)
UpdateUser(uid string) (*User, error)
DeleteUser(uid string) error
}
mysqlパッケージはMySQLに関連する処理に専念し、 httpパッケージはMySQLへの依存を避け、httpサーバーの機能に集中したテストを書くことができる。
- cmd/api/main.go
- myapp/
- mocks/
- user_service_mock.go
- mysql/
- user.go
- user_test.go
- http/
- user_handler.go
- user_handler_test.go
- user.go
- user_test.go
実際のプロジェクトでは以下のような構成となった。
- cmd/api/main.go
- myapp
- http (HTTPサーバー)
- grpc (gRPCサーバー)
- rdb (MySQL)
- s3 (Amazon S3)
- dydb (DynamoDB)
- iot (AWS IoT)
- ssm (AWS SSM)
- fcm (Firebase Cloud Messaging)
- ... (省略)
これらのサブパッケージでは、dockertestやLocalStackを使って可能な限り実際に近い環境でテストを行なっている。
domainというパッケージ名を使わない理由
ドメインモデルは、ルートに置かずにdomain
パッケージを作っても実際は問題ない。
- cmd/main.go
- domain/
- user.go
- user_test.go
- http/
- user_handler.go
- user_handler_test.go
- mysql/
- user.go
- user_test.go
この場合、ドメインパッケージ内で定義された型を使うには、
以下のようにdomain.XXX
の形式を用いる。
import "myapp/domain"
func main() {
user := domain.User{}
}
ルート(myapp)に置いた場合は、以下のようにmyapp.XXX
の形式となる。
import "myapp"
func main() {
user := myapp.User{}
}
このようにパッケージ名がプロダクト名になっている方が、よりこのデータ型が プロダクト全体で使われることを意識した名前づけになっていると思う。
他のプロジェクト構成との比較
クリーンアーキテクチャなど、何らかのアーキテクチャを参考にしたプロジェクトでは、 そのアーキテクチャのレイヤーに基づいて命名されたディレクトリが作られることがある。
- cmd/main.go
- domain/
- infrastructure/
- presenter/
- repository/
- usecase/
こういう名前付けは、特定のアーキテクチャの文脈や用語を理解する必要があり、馴染みのない人には分かりにくい。
上述のdomainという名前よりプロダクト名を使った方が良いという部分と同様で、 できるだけ直接的な命名をした方がプログラムが分かりやすくなると思う。
今回のサブパッケージを依存関係ごとにまとめる構成は、特別な知識がなくても、どこに何を置くべきかが分かりやすい。
- cmd/main.go
- mysql/
- http/
- user.go
まとめ
Goのプロジェクトでルートにドメインパッケージを置く構成にリファクタリングした件についてまとめた。 予備知識がなくても理解しやすく、テストしやすい設計を目指しやすい構成だと感じた。