Goでクリーンアーキテクチャをやる
こんにちは、0x3fbです。今回は自分が所属するBreak AIの起業支援アプリであるgiantsのバックエンドを、Goのクリーンアーキテクチャで書き直しました。
本記事では、その時にやったこと・考えていたことなどをざっくりと、実際に作ったコードを交えながらまとめたいと思います。
経緯
開発スタート時に構築していたSveltekitの簡易的なバックエンドがごちゃごちゃしてしまい、限界を感じたというのがことの発端となります。
開発速度を優先するために、別でバックエンドを用意するということをせずSvelteKitのみでフロントエンドとバックエンドを構築していました。
しかし、lib配下のDBを触るコードでSvelteで生やしたAPIを呼び出して、そこからまたlibのコードを呼び出すみたいなとんでもコードが完成したり、バグを見つけづらかったりなどいろいろな不都合が発生。
その結果、逆に開発速度に影響を及ぼし始めてしまい、「これ以上機能を増やすのであればバックエンドを用意するべきだよね。」という話になり、Goでクリーンアーキテクチャを採用したバックエンドを用意することになりました。
プロジェクト構成
必要部分だけ抜き出したプロジェクト構成としてはこんな感じです。
Root
┝─ app
│ ┝ domain
│ ┝ infra
│ ┝ presentation
│ └ usecase
│
└ cmd
┝ server
│ └ api.go ← DI
└ main.go ← 起動用
/app以下にメインのコードを書いていて、API起動自体とDIはcmd内で行っている形です。
Domain層
Domain層は主にコアなビジネスロジックやビジネスルールを設計する層です。
このプロジェクトでは
- objects
- repository
- service
の3つで構成しています。
domain
┝ repository
┝ service
└ objects
object
object内では主にdomain structureの定義と、getter/setterの実装を行っています。
objects
┝ user
│ ┝ user.go
│ └ id.go
┝ post
....
コードとしてはこんな感じとなっています。
package user
type User struct {
id ID
email string
name string
image string
emailVerified *time.Time
}
func (user User) ID() ID { return user.id }
func (user User) Email() string { return user.email }
...
type ID []byte
func NewID() (ID, error) {...}
func NewIDWithHint(id string) (ID, error) { ... }
func (id ID) String() (string, error) { ... }
...
1つのobjectのディレクトリ内で、IDとstructure本体を分けている理由としては、IDだけを独立して置くことで、IDをStringに変換したいタイミングでuser.IDのメソッドとして呼び出せたり、引数として指定するときもuser.IDで指定できるため、IDだけ切り出して独立させています。
repository
/repositoryでは、DBやAPIと通信してデータを保存するinterfaceのみ記載しています
特筆すべきことは特になく、CRUD+αのinterfaceを定義しているだけです
service
ビジネスロジックのinterfaceを記載しています。
S3にデータを保存したり、認証に使うJWTやOauth2.0の認証URLの生成をするinterfaceなどを置いています。
type Auth interface {
GetAuthURL(ctx context.Context, provider string) (string, error)
AuthenticateUser(ctx context.Context, provider, code string) (*user.User, *account.Account, *string, *string, error)
GenerateJWT(u *user.User) (string, error)
RefreshJWT(ctx context.Context, refreshToken string) (string, error)
ValidateToken(ctx context.Context, tokenString string) error
}
type Pitch interface {
UploadPitchPDF(ctx context.Context, pitchFileName *string, pitchID pitch.ID, pdf *multipart.FileHeader) (*pitch.Pitch, error)
}
service層に役割を持たせないように、なるべく細かく小さく切り分けて、usecase層でうまくつなぎ合わせるようにしています。
Infrastructure層
Infrastructure層(以下infra層)では、主にDomain層で作成したRepository/Service Interfaceの実装を行っていて、
- Client
- DTO
- RepositoryImpl
- ServiceImpl
の4ディレクトリで構成されています。
infra
┝ client
┝ dto
┝ repositoryimpl
└ serviceimpl
Client
Client内では、DB・S3・authそれぞれのClientを生成を行ったりしています。
type DB struct {
Read *sqlx.DB
Write *sqlx.DB
}
func New(e *config.Value) (*DB, error) { ... }
func (db *DB) Close() { ... }
...
DTO
その名の通りdto置き場です。 メタタグとしてDB:を書くことで、sqlxの返り値や引数としてstructureを指定するときに、メタタグの中の名前のカラムと紐づけてくれます。
type User struct {
ID []byte `db:"id"`
Email string `db:"email"`
Name string `db:"name"`
Image sql.NullString `db:"image"`
EmailVerified *time.Time `db:"email_verified"`
}
func (dto User) ConvertToEntity() (*user.User, error) {
image := ""
if dto.Image.Valid {
image = dto.Image.String
}
return user.NewWithHint(
dto.ID,
dto.Email,
dto.Name,
image,
dto.EmailVerified,
)
}
RepositoryImpl / ServiceImpl
RepositoryImplとServiceImpl内ではDomain層で作ったInterfaceに沿って実実装をしています。
type userRepos struct {
db *db.DB
}
func NewUser(db *db.DB) repository.User {
return userRepos{
db: db,
}
}
func (r userRepos) Create(ctx context.Context, entity *user.User) error { ... }
func (r userRepos) FindByID(ctx context.Context, id user.ID) (*user.User, error) { ... }
func (r userRepos) Update(ctx context.Context, entity *user.User) error { ... }
func (r userRepos) Upsert(ctx context.Context, entity *user.User) (*user.User, error) { ... }
func (r userRepos) Delete(ctx context.Context, id user.ID) error { .. }
interfaceとの紐づけを行うときは、なんらかの関数(ここで言うNewUser関数)の返り値にInterfaceを指定して、中でファイル内のstructをreturnすることで可能です。
usecase(Application)層
usecase層(Application層とも呼んだり)は、Infrastructure層で実装したInterfaceをつなげ合わせるのが主な役割です。
usecase層の中では
- input
- interactor
- output
の3ディレクトリで構成しています。
usecase
┝ input
┝ interactor
└ output
Input / output
inputでは、controller→usecaseに渡すためのstructを作成。outputではusecaseからの返り値として渡すstructを作成しています。
また、inputはGET以外のメソッドのbodyの型として、outputはレスポンス時の型としても活用しています
package input
type FindUser struct {
UserID string `json:"userId"`
}
type CreateUser struct {
Email string `json:"email"`
Name string `json:"name"`
Image string `json:"image"`
}
...
dto同様、Frameworkで上手いこと解釈してくれるように、jsonタグを付けています。
interactor
interactorでは、usecaseの実装を行っています。
type User interface {
Find(ctx context.Context, input input.FindUser) (*output.FindUser, error)
Create(ctx context.Context, input input.CreateUser) (*output.CreateUser, error)
Update(ctx context.Context, input input.UpdateUser) (*output.UpdateUser, error)
Delete(ctx context.Context, input input.DeleteUser) error
}
type userInteractor struct {
userService service.User
userRepo repository.User
}
func NewUser(
userService service.User,
userRepo repository.User,
) User {
return &userInteractor{
userService: userService,
userRepo: userRepo,
}
}
func (u userInteractor) Find(ctx context.Context, input input.FindUser) (*output.FindUser, error) { ... }
func (u userInteractor) Create(ctx context.Context, input input.CreateUser) (*output.CreateUser, error) { ... }
func (u userInteractor) Update(ctx context.Context, input input.UpdateUser) (*output.UpdateUser, error) { ... }
func (u userInteractor) Delete(ctx context.Context, input input.DeleteUser) error { ... }
最初にinterfaceを定義し、その後にInteractorのstructureを定義し、その後に実装を書いています。
Presentation層
presentation層では、controllerやvalidator、middlewareなど外側に一番近い層としての役割を果たしてもらっています。
presentation層は
- controller
- middleware
- response
の3ディレクトリで構成しています。
presentation
┝ controller
┝ middleware
└ response
controller
controllerではリクエストの受け取り・解釈・validationなどのネットワークに近い部分を担当しています。
controllerディレクトリの内部でvalidatorディレクトリを置き、その中でvalidatorを作成しています。
また、本プロジェクトではswaggoというgodocからswaggerを生成するモジュールを利用しており、Controllerの関数にはgodocでswaggoの記述をしています。
type User interface {
Get(c echo.Context) error
Delete(c echo.Context) error
Update(c echo.Context) error
FindById(c echo.Context) error
}
type userController struct {
userInteractor interactor.User
}
func NewUser(userInteractor interactor.User) User { ... }
// Get godoc
//
// @Summary ユーザーを取得する
// @Description ユーザーを取得する
// @Tags users
// @Accept json
// @Produce json
// @Security Bearer
// @Success 200 {object} output.FindUser
// @Failure 400,404,500 {object} response.ErrorResponse
// @Router /users/me [get]
func (u userController) Get(c echo.Context) error { ... }
// Delete godoc
//
// @Summary ユーザーを削除する
// @Description ユーザーを削除する
// @Tags users
// @Accept json
// @Produce json
// @Security Bearer
// @Param body body input.DeleteUser true "ユーザー情報"
// @Success 200
// @Failure 400,404,500 {object} response.ErrorResponse
// @Router /users/me [delete]
func (u userController) Delete(c echo.Context) error { ... }
godocの型指定でbodyをusecase/inputやusecase/outputを直接指定し、swaggerの型を指定しています。
swaggoを利用してswaggerを生成することで、swaggerを書く労力を短縮しつつswaggerの恩恵を受けられる(フロント側で自動生成したりができる)ため、かなり嬉しいです。
middleware
middlewareではlogやauth、traceなどAPIを置くうえで基本的なmiddlewareに付いて記述しています。
response
responseでは、controllerで使うresponseメソッドの実装、エラーの定義などを行っています。非
type Error struct {
Code int
Message string
}
var (
ErrBadRequest = Error{Code: http.StatusBadRequest, Message: "bad_request"}
...
)
type (
ErrorResponse struct { ... }
Reason struct { ... }
)
func OnError(c echo.Context, msg string, err error) error { ... }
func OK(c echo.Context, i interface{}) error { ... }
...
このようにすることで、controller内のレスポンス処理を簡潔にわかりやすくしています。
DI
DIはcmd/server/api.goにて行っています。
DI単体でファイルを用意することも考えたのですが、今回自分たちが取ったやり方と単体で用意することのどちらでも対して運用時に変わらなかったり、もしやばくても変えることができる点などからこの方法を取りました。
最後に
最後まで見ていただきありがとうございました。
クリーンアーキテクチャ自体、やや敷居が高いようにも感じますが、それほど難しいものでもなく、開発効率もかなり上がるため、もし導入しようか悩んでいる場合はぜひ導入してみてください!
Break AI内部でもまだまだ試行錯誤中なので、また調整したり完璧なクリーンアーキテクチャではないかもしれませんが、実際の開発例として参考にしていただければ幸いです。
参考書籍・参考記事
Discussion