Go+GraphQLによるアーキテクチャ設計を考えてみる
前置き
個人開発をするにあたり、Go+GraphQL構成におけるアーキテクチャ設計についてあれこれ考える機会があったので、備忘として残そうと思いました。
※前提として私のアーキテクチャ設計に対する理解度についてはあまり自信がないため、間違ったことを言っていたり、実装に誤りがあった場合はご容赦ください。
これまで受託開発でいくつかの案件を経験してきたのですが、どの案件でもDDDやクリーンアーキテクチャっぽい構成を採用しているケースが多かったので、個人開発する際にもその辺りの知見を活かしたいなーと思いました。
また、最近はGraphQLやgRPCを使ったスキーマ駆動開発の案件に参画する機会も増えており、せっかくなのでこれらの技術も用いてアーキテクチャを組んでみようと思いました。
構成(全体図)
ディレクトリ構成は下記になります。
構成はざっくり、Go+GraphQL+DDD+なんちゃってクリーンアーキテクチャといった感じです。
例として、ユーザー取得API(GraphQLなので厳密にはquery)の実装を記載しています。
app/
├ graphql/
│ ├ models/
│ │ └ models_gen.go
│ ├ resolver/
│ │ ├ container/
│ │ │ ├ container.go
│ │ │ ├ resolver.go
│ │ ├ resolver.go
│ │ └ user_query.resolvers.go
│ ├ schema/
│ │ ├ models/
│ │ │ └ user.graphql
│ │ ├ mutations/
│ │ │ └ user_mutation.graphql
│ │ ├ queries/
│ │ │ ├ user_query.graphql
│ │ │ └ common.graphql
│ │ └ generated.go
├ internal/
│ ├ application/
│ │ └ services/
│ │ └ user_service.go
│ ├ domain/
│ │ ├ models/
│ │ │ └ user.go
│ │ ├ repositories/
│ │ └ user_repository.go
│ ├ infrastructure/
│ ├ dao/
│ │ └ user_dao.go
│ └ repositoryimpl/
│ └ user_repository_impl.go
GraphQLモデル・Domainモデル・DAOモデルと、3つのモデルが存在します。
ここが一番悩んだところで、正直個人開発でやる程度ならいずれかのモデルを省略してもよいのでは…?とも思いつつ、それぞれの責務を分けておいた方が後々楽になることを信じてこの構成にしました。
各自のモデルを疎結合にすることで、DB設計やスキーマ設計に変更が出た際の影響範囲が小さく済みます。過去携わってきた案件では破壊的な仕様変更に遭遇するケースが度々あり(本来あってほしくはないのですが…)、その際、今回のように責務ごとにモデルをきっちりと分けていたことである程度の被害で収まった経験もあることから、実装コストが増えてしまうことについては許容しています。
各層ごとの詳細
graphql層
├ graphql/
│ ├ models/
│ │ └ models_gen.go
│ ├ resolver/
│ │ ├ container/
│ │ │ ├ container.go
│ │ │ ├ resolver.go
│ │ ├ resolver.go
│ │ └ user_query.resolvers.go
│ ├ schema/
│ │ ├ models/
│ │ │ └ user.graphql
│ │ ├ mutations/
│ │ │ └ user_mutation.graphql
│ │ ├ queries/
│ │ │ ├ user_query.graphql
│ │ │ └ common.graphql
│ │ └ generated.go
graphql層では、schemaの中にそれぞれmodel,mutation,queryを定義していきます。
それらを元にgqlgenを実行し、Goのモジュールとして生成します。
model
から生成された構造体がmodels/model_gen.go
の中に生成され、こちらをGraphQL
のモデルとして扱っていきます。
resolverはcontrollerのような扱いで、mutationやqueryで定義したエンドポイントの関数が生成されます。
// User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context, uid string) (*gqlmodel.User, error) {
user, err := r.container.GetUserService().GetUser(uid)
if err != nil {
return nil, err
}
userModel := &gqlmodel.User{
UID: user.Uid,
Name: user.Name,
Email: user.Email,
}
return userModel, nil
}
その中でservice層のインスタンスを持つcontainerを呼び出し、必要な情報を取得・更新し、結果を返却します。
基本的に後ほど紹介するinternal層の中ではドメインモデルを引き回し、最終的にresolverの中でGraphQLモデルに変換してレスポンスを返却する形になります。
type Container struct {
once sync.Once
db *gorm.DB
movieRepo repositories.MovieRepository
userRepo repositories.UserRepository
movieService *services.MovieService
userService *services.UserService
}
var (
instance *Container
once sync.Once
)
func NewContainer(db *gorm.DB) *Container {
once.Do(func() {
instance = &Container{
db: db,
}
instance.initialize()
})
return instance
}
func (c *Container) initialize() {
c.once.Do(func() {
c.userRepo = repositoryImpl.NewUserRepositoryImpl(c.db)
c.userService = services.NewUserService(c.userRepo)
})
}
func (c *Container) GetUserService() *services.UserService {
return c.userService
}
なおcontainer
の中身はこんな感じで、各サービスクラスやリポジトリクラスのインスタンス化をここで行い、再利用性を確保します。
once
を利用することで一度だけNewContainer
が実行されるようにし、シングルトンパターンを実現しています。
initialize()
の中で全てのサービスを初期化する即時初期化の形式を取っていますが、ここでちゃんと遅延初期化するように実装すべきか悩んでいます。
type Resolver struct {
container *container.Container
}
func NewResolver(container *container.Container) *Resolver {
return &Resolver{
container: container,
}
}
また、gqlgen実行時に生成されるresolver.go
(GraphQLリゾルバの親構造体)の中でcontainer
を注入し、各リゾルバの中でコンテナ経由で依存関係を利用できるようにしています。
internal層
internalディレクトリの中に、application, domain, infrastructure層がそれぞれ存在します。
├ internal/
│ ├ application/
│ │ └ services/
│ │ └ user_service.go
│ ├ domain/
│ │ ├ models/
│ │ │ └ user.go
│ │ ├ repositories/
│ │ └ user_repository.go
│ ├ infrastructure/
│ ├ dao/
│ │ └ user_dao.go
│ └ repositoryimpl/
│ └ user_repository_impl.go
依存関係については、下記のイメージです。
Application 層
↓ (依存)
Domain 層(ビジネスロジック、リポジトリインターフェース)
↑ (依存性注入による具体実装の提供)
Infrastructure 層(DBアクセス、外部APIとの連携)
依存方向は外側から内側へ向かい、Domain層が独立していることから、変更に強い設計になっています。
(余談ですが、serviceやmodelのディレクトリ名とパッケージ名を複数形か単数系にするかで悩みました。まあリソースの集合体ということで複数形が正しそう…?)
application層
役割:
ユースケース(アプリケーションの具体的な機能や操作)を定義する層です。
ドメイン層に依存し、アプリケーションの流れを管理します。
services/には、ユースケースを実現するための手続き的な処理が記述されます。
責務:
ドメイン層のモデルやリポジトリを使って、ユースケースを実現します。
インターフェース層(GraphQLやREST API)やインフラ層と連携する際の仲介役になります。
type UserService struct {
userRepo repositories.UserRepository
}
func NewUserService(userRepo repositories.UserRepository) *UserService {
return &UserService{
userRepo: userRepo,
}
}
func (s *UserService) GetUser(uid string) (*domain.User, error) {
return s.userRepo.GetUser(uid)
}
簡素ですが、ユーザーを取得する機能の実装例です。
初期化時に必要な依存関係の注入を行います。今回だとUserRepository(インターフェース)を持つ形になります。
現状は単純にリポジトリのメソッドを呼び出すだけですが、複雑な処理が必要になった場合はここに実装します。
domain層
役割:
エンティティやリポジトリのインターフェースを定義する層です。
アプリケーションのビジネスルールに依存し、他の層には依存しない独立性の高い層になります。
責務:
ビジネスロジックをエンティティや値オブジェクトとして表現します。
リポジトリインターフェースで、データ操作の抽象化を提供します。
type User struct {
Uid string `json:"uid"`
Name string `json:"name"`
Email string `json:"email"`
}
type UserRepository interface {
GetUser(uid string) (*domain.User, error)
}
こちらも現状は簡素ですが、models/user.go
にビジネスロジックが実装されていく形になります。
リポジトリの実装はinfrastructure層に配置します。
infrastructure層
役割:
データベース、外部API、ログ、メール送信などのインフラに依存する具体的な処理を実装する層です。
Domain層のリポジトリインターフェースの具体的な実装を提供します。
責務:
外部システムやデータストアとの連携します。
ドメイン層で定義されたリポジトリインターフェースを実装します。
type User struct {
Uid string `gorm:"column:uid"`
Name string `gorm:"column:name"`
Email string `gorm:"column:email"`
}
func (d *User) ToModel() *domain.User {
return &domain.User{
Uid: d.Uid,
Name: d.Name,
Email: d.Email,
}
}
func (d *User) ToDao(m *domain.User) *User {
return &User{
Uid: m.Uid,
Name: m.Name,
Email: m.Email,
}
}
type UserRepositoryImpl struct {
db *gorm.DB
}
func NewUserRepositoryImpl(db *gorm.DB) *UserRepositoryImpl {
return &UserRepositoryImpl{
db: db,
}
}
// userを取得する関数
func (r *UserRepositoryImpl) GetUser(uid string) (*domain.User, error) {
var userDao *dao.User
result := r.db.First(&userDao, "uid = ?", uid)
if result.Error != nil {
return nil, result.Error
}
user := userDao.ToModel()
return user, nil
}
主にDBアクセスを行う層です。DAOのモデルはDBのテーブル構造を表しています。
Get系の関数の場合、DBから取得したデータをDAOに格納し、その後Domainモデルに変換して返却します。
(Domain<=>DAOの変換処理を両方DAOに持たせているのがちょっと違和感あったり、~implってパッケージ名もイマイチかなーと思っていたり、この辺りは悩んでいる部分になります。)
まとめ
アーキテクチャ設計についてはまだまだ悩んでいて、実際にゴリゴリ実装を進める上で調整していくつもりです。なかなか一発でバシッとは決まりませんね…。
正直個人や2~3人で開発するのであればもっとシンプルな構成の方がスピードが出るのはそれはそうなのですが、個人的にシンプルすぎるとどこに何を書くかで悩むケースが多く、ある程度レイヤー毎の責務がはっきりしている方が好みです。
自分で実装する際はもちろん、レビューする際の指標になりますし、何より保守性も高く感じます。
DDDやクリーンアーキテクチャのような構成の案件をいくつか経験した後、シンプル構成の案件に参画した際は初期開発の実装スピードの速さに感動しましたが、運用開発フェーズに入ってからはどこに何を書いたかがわかりづらく、依存関係の管理などにも苦労した経験があり…。
と言った点から、初期開発のスピード感がある程度犠牲になるのは仕方ないのかなあと思っています。
(まあだからと言ってあまりに複雑な構成にしてしまうと、それはそれで誰もルールを理解できず、どこまでいっても実装スピードが出ず遅延しまくり…といった経験もあるため難しいのですが😅)
とはいえ個人開発でやるにはちょっと複雑すぎる気もするので、上手いこと調整していければと思っています🚀
Discussion