🚀

Goのアーキテクチャはどうすればいいのか

2024/01/02に公開

初めまして。
BtoBマーケの領域でプロダクト開発をしているエンジニアです。
仕事ではGoやGoogle Cloudを使っています。

Goでアプリケーションを作る際のアーキテクチャについて感じたことを書いていきます。

Clean Architectureで愚直にDDDしてみる

ここでは、Clean Architectureを題材に考えていきます。
Clean Architectureを意識してプロダクト開発をしているチームも多いのではないでしょうか。

私が普段仕事で書いているGoのアプリケーションも、Clean Architectureを意識したDDDで構成されています。

目に穴が開くほど見た図だと思いますが、一応Clean Architectureの構成図を貼っておきます。

個人的には、Clean Architectureは概念だと思っていて、この構成を守ってさえいれば、具体的にどう実装するかは自由だと考えています。

さて、このClean Architectureを意識して愚直にDDDをすると大まかに以下のような構成になるかと思います。

yourapp/
├─ cmd/
   ├─ app/
      ├─ main.go
├─ internal/
   ├─ handler/
      ├─ user_handler.go
   ├─ application/
      ├─ user_service.go
   ├─ domain
      ├─ repository/
          ├─ user_repository.go
      ├─ user.go
   ├─ infrastructure/
      ├─ user.go

それぞれのパッケージは以下のような形の実装になっています。

  • handler(user_handler.go)
package handler

import"yourapp/internal/application"
)

// application層のinterfaceをプロパティに定義
type UserHandler struct {
  userService application.UserService
}
  • application(user_service.go)
package application

import"context"
  "yourapp/internal/repository"
)

// application層の抽象化
type UserService interface {
  GetByKey(ctx context.Context, userKey string) (*User, error)
}

// repository層のinterfaceをプロパティに定義
type userService struct {
  userRepository repository.UserRepository
}

// 具体的な実装
func (u *userService) GetByKey(ctx context.Context, userKey string) (*User, error) {
  // 適切な実装
}

// application層から上位層に返す型をapplication層で定義
type User struct {
  Key     string
  Name    string
  Address string
}
  • repository(user_repository.go)
package repository

import"context"
  "yourapp/internal/domain"
)

// infrastructureの抽象化をrepositoryで定義
type UserRepository interface {
  GetByKey(ctx context.Context, userKey string) (*domain.User, error)
}
  • domain(user.go)
package domain

// ドメイン定義
type User struct {
  Key     string
  Name    string
  Address string
}

infrastructureパッケージはrepositoryの実装になるため省略します。

パッケージの粒度や種類はいくつかあると思いますが、最低限のレイヤーを構成するとこういった形になるのかなと考えています。
(repositoryはdomainの中でいいのか??modelsを定義しないでいいのか??など色々ツッコミどころはあるかと思います)

私が携わっているプロダクトも大まかにこのような形の構成を取っているのですが、実際に開発を進めていく中で見えてきた構成の問題点について考えていきたいと思います。

ビジネスロジックに対する値の受け渡しに弱い

まず初めに、application層に対する引数と返り値の受け渡しについて考えます。

先ほどの例では、単にuserKeyという一つの引数を渡すだけのメソッドでしたので、特に煩雑ではありませんでした。しかし、引数が多くなっていくと何かしらの型でパラメータをWrapし、その型を直接渡してあげたほうが明確になる場面も多くなります。

例えば、application層のメソッドに構造体を引数として与えるように変更してみます。

  • application(user_service.go)
package application

import"context"
  "yourapp/internal/repository"
)

type UserService interface {
  // userKeyの代わりにGetParameterの構造体を定義
  GetByKey(ctx context.Context, parameter GetParameter) (*User, error)
}

type userService struct {
  userRepository repository.UserRepository
}

// userKeyの代わりにGetParameterの構造体を定義
func (u *userService) GetByKey(ctx context.Context, parameter GetParameter) (*User, error) {
  // 適切な実装
}

// application層の中にパラメータ構造体を定義
type GetParameter {
  Key     string
  Address string
}

type User struct {
  Key     string
  Name    string
  Address string
}

application層の同じファイルの中にGetParameterという構造体を作成し、それを引数として定義してみました。

一見すると良さそうに思えますが、二つの観点で疑問が残ります。

application層に記述してもいいのか??

今回は一つのファイルの一つのメソッドに関してのみのパラメータでしたが、これを複数ファイルかつ複数メソッドにスケールしていくと、同じようなパラメータ構造体が増殖していきます。

もちろん、パラメータで使用する構造体は、同じビジネスロジックのファイルに記述しないといけないといったチームの方針や、Linterを作成すれば問題ないかと思います。

しかし、application層での構造体が無限に増殖していくことに変わりはありません。(個人的には気持ち悪さを覚えます)

また、引数だけでなく返り値についても同じことが言えると思います。
application層の例では、application層に定義したUserという構造体を使って返り値を定義していますが、こちらに関しても何かしらのプラクティスが必要になります。

値を受け渡す用のパッケージを作成するべきか??

ビジネスロジックに対して、値をやり取りするためのパッケージを作成するというのも考えられます。
例えば、application層に対してparamsのようなパッケージを構築することができます。

├─ application/
      ├─ params/
          ├─ user_params.go
      ├─ user_service.go

このparamsパッケージ内で以下のような構造体を作成して、それを使用することができます。

  • params(user_params.go)
package params

type GetParameter {
  Key     string
  Address string
}
  • application(user_service.go)
package application

import"context"
  "yourapp/internal/repository"
  "yourapp/internal/application/params"
)

type UserService interface {
  // paramsパッケージのGetParameterの構造体を定義
  GetByKey(ctx context.Context, parameter params.GetParameter) (*User, error)
}

// 省略

このようにすれば、application層で無限に構造体を増殖させる必要はなくなり、先ほどよりも少し明確になります。

しかし、代わりに同じような構成のファイルや記述が増える可能性が高くなります。

例えば、今回user_service.goに対応するパラメータを定義したファイルをuser_params.goとしましたが、同様のロジックで開発を進めると対応ファイルが無限に増殖していきます。

更に、返り値用のパッケージ(例えばdtoパッケージ)も作成してしまうと、ビジネスロジックに対応するファイルが2つとなるため、よりファイルが増殖していきます。(ファイル間の対応付けを探すのも大変になります)

これらの議論はapplication層だけでなく、各層で考えなければいけない観点になりますが、例えばrepository層は基本的にドメインを渡してドメインを受け取るといった責務ですし、handler層は基本的にrequestとresponseが構築できていれば秩序が乱れることはない(grpcだとそもそもprotoファイルが自動で作成される)ため、パラメータ間の受け渡しはビジネスロジックで特に顕著になる問題であると考えられます。

repositoryがSOLID原則に反している

次に、このような構造の元でのrepositoryの扱いについて考えていきます。

今回の例では、user_serviceに対してのみrepositoryを使用している状況ですが、repository層のinterfaceは色々なビジネスロジックで使用される可能性があります。

例えば以下のように、ブログ記事に関するビジネスロジックにおいてもuser_repositoryを使用するかもしれません。

├─ application/
      ├─ user_service.go
      ├─ article_service.go
  • application(article_service.go)
package application

import"context"
  "yourapp/internal/repository"
)

type ArticleService interface {
  GetByKey(ctx context.Context, articleKey string) (*Article, error)
}

type articleService struct {
  articleRepository repository.ArticleRepository
  // ArticleロジックでもUserReposirotyを使用する
  userRepository    repository.UserRepository
}

// 省略

この状況において、user_serviceの要件でuser_repositoryに新しいメソッドが追加された場合どうなるでしょうか。

  • repository(user_repository.go)
package repository

import"context"
  "yourapp/internal/domain"
)

type UserRepository interface {
  GetByKey(ctx context.Context, userKey string) (*domain.User, error)
  // ユーザーを作成するメソッドを追加
  Create(ctx context.Context, *domain.User) error
}

この場合、article_serviceではuser_repositoryCreateメソッドを使用しないのにも関わらず、知らず知らずのうちにCreateメソッドとの間に依存関係が発生していると考えることができます。

もちろん、具体的な実装が伴っていたり、gomockなどのモックがしっかり更新されていれば直接的な影響はありませんが、自分自身に関心のないメソッドに依存してしまっていることに変わりはありません。

また、このまま開発を続けていくと、repositoryに定義しているinterfaceが巨大になっていき、抽象度が低くなっていくのも問題です。

これはSOLID原則のI(インターフェース分離の原則)に反していると考察できます。

ビジネスロジックごとにパッケージ化する

ここまでいくつかの問題点を考察してきましたが、一つの解決策として「ビジネスロジックをパッケージ化すればいいのでは?」という考えに至りました。

yourapp/
├─ cmd/
   ├─ app/
      ├─ main.go
├─ internal/
   ├─ handler/
      ├─ user_handler.go
   ├─ application/
      ├─ user/
          ├─ repository.go
          ├─ service.go
      ├─ article/
          ├─ repository.go
          ├─ service.go
   ├─ domain
      ├─ user.go
   ├─ infrastructure/
      ├─ user.go

最初にお見せしたディレクトリ構造からrepositoryを削除し、代わりにビジネスロジックごとにパッケージ化してみました。ビジネスロジックパッケージ内のservice.goはロジック実装を、repository.goには、このビジネスロジックで使用するrepository(interface)を定義します。

このような構成にすると、先ほどの問題がどのように解決できそうか考察していきます。

ビジネスロジックに対するパラメータが明確になる

最初の構成では、ビジネスロジックに対するパラメータの受け渡しにあまり秩序がありませんでしたが、ビジネスロジックをパッケージ化することでそれらが明確になります。

例えばビジネスロジックに対するパラメータを定義したい場合、以下のように新しいファイルを作成するだけで明確な役割を持たせることができます。

├─ application/
   ├─ user/
       ├─ params.go(外部パッケージとのやりとり用)
       ├─ repository.go
       ├─ service.go

トータルの記述量はあまり変わらないものの、新しく作成したparams.goに、userロジックに関するパラメータや設定処理などを記述すれば、かなり秩序だった構成になると考えられます。

また、仮にパラメータなどに変更が入った場合も、基本的に全てビジネスロジックの変更に集約できる点もメリットかと思われます。

repositoryをビジネスロジックの用途に合わせて定義できる

ビジネスロジックをパッケージ化することの最大のメリットは、先ほどのrepositoryの問題点を解決できることだと考えています。

ビジネスロジックを個別にパッケージ化することで、例えば以下のような形でロジックごとの使用用途に合わせてrepositoryを定義できます。

  • userロジック(repository.go)
package user

import (
  "context"
  "yourapp/internal/domain"
)

type userRepository interface {
  GetByKey(ctx context.Context, userKey string) (*domain.User, error)
  // userロジックではCreateを使用する
  Create(ctx context.Context, *domain.User) error
}
  • article(repository.go)
package article

import (
  "context"
  "yourapp/internal/domain"
)

type articleRepository interface {
  GetByKey(ctx context.Context, articleKey string) (*domain.Article, error)
}

type userRepository interface {
  // articleロジックではCreateは使用しない
  GetByKey(ctx context.Context, userKey string) (*domain.User, error)
}

こうすることで、ビジネスロジックに必要なrepositoryだけを定義できるため抽象度が上がり、interfaceのサイズも小さく抑えることができます。

これは、私が本記事を書くきっかけとなった100 Go MistakesInterface on the producer side(#6)(インターフェースは生産者側に書くべきではない)に従った構成にもなっています。

最初のrepositoryパッケージは、まさに生産者(パッケージを提供する側)に書かれていましたが、それを消費者側(クライアント側)に記述する形となっています。

また、interfaceはapplication層でも提供していますが、handler層とapplication層はほぼほぼ一対一の関係性になることが多いため、application層でinterfaceを提供しても(生産者側で提供しても)、この場合問題になることはありません。

もし、handler層で複数のビジネスロジックを使用したい場合には、application層と同様にhandler層も細かくパッケージ化するのがいいのかもしれません。

その他メリット&デメリット

Goでテストを記述する際には、gomockなどのモックを使用することが多いかと思いますが、モックコードがビジネスロジックごとに整理される点もメリットかなと感じています。そうすることで、ビジネスロジックに対する変更を全てそのパッケージ内で完結させることができるので、変更差分も多少見やすくなるかと思います。

一方で、パッケージが増殖してしまう点や、パッケージの責務が小さすぎるというデメリットもあります。

ビジネスロジックごとにパッケージ化すると、同じようなファイル構成のパッケージが無限に増殖していくため、逆に管理し辛い状況が発生してしまうかもしれません。

また、ビジネスロジックで一つのパッケージを作成する最初の構成に比べて、ビジネスロジックごとにパッケージ化してしまうと圧倒的に責務の範囲が狭くなってしまいます。

例えば一つの機能(メソッド)しか持たないようなビジネスロジックを作成する場合でも、わざわざパッケージ化しないといけないので、それはそれで問題があるようにも思えます。

もしかすると、ビジネスロジックやそのドメインに関する機能単位でパッケージ化し、handlerからdomainに渡るような通気一貫した構成の方がいいのかもしれません。

まとめ

今回紹介したアイディアは、色々なサイトや本を参考にした私個人の意見であり、これが正解ではありません。
愚直な構成でGoプロダクトを作っていくと、結構辛いことが多いなぁと感じているので、何かしらの工夫や、チーム内でのルールは必要になってくるのかなと考えています。

Goでどのようなアーキテクチャにしようか悩んでいる方々の参考になれば幸いです。

Discussion