🏄‍♂️

Goでクリーンアーキテクチャをやる

2024/08/30に公開

こんにちは、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
....

コードとしてはこんな感じとなっています。

objects/user/user.go
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 }
...
objects/user/id.go
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などを置いています。

service/auth.go
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
}
service/pitch.go
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を生成を行ったりしています。

infra/client/db/db.go
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を指定するときに、メタタグの中の名前のカラムと紐づけてくれます。

infra/dto/user.go
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に沿って実実装をしています。

infra/repositoryimpl/user.go
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はレスポンス時の型としても活用しています

usecase/input/user.go
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の実装を行っています。

usecase/interacotr/user.go
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の記述をしています。

presentation/controller/user.go
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メソッドの実装、エラーの定義などを行っています。非

presentation/response/response.go
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内部でもまだまだ試行錯誤中なので、また調整したり完璧なクリーンアーキテクチャではないかもしれませんが、実際の開発例として参考にしていただければ幸いです。

https://new-giants.breakai.ai/

参考書籍・参考記事

https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
https://www.amazon.co.jp/-/en/Robert-C-Martin/dp/4048930656
https://zenn.dev/sre_holdings/articles/a57f088e9ca07d

Discussion