UseCaseの凝集度を高める Goのpackage戦略 / Go packaging strategy to increase use case cohesion
UseCaseの凝集度を高める Goのpackage戦略 2023年09月20日 株式会社ZOZO ブランドソリューション開発本部 バックエンド部 FAANSバックエンドブロック 田村 誠基Copyright © ZOZO, Inc.1
View Slide
© ZOZO, Inc.株式会社ZOZO ブランドソリューション開発本部 バックエンド部 FAANSバックエンドブロック 田村 誠基 2020年4月 入社 自社ECの運用保守業務に従事し、その後FAANSのバックエンドエンジニアとしてAPI開発をしています。 好きなエディタは、Neovimです! 最近、M2 ProのMacBookに変え、PCが爆速になった。 2
© ZOZO, Inc.3Agenda● ショップスタッフの販売サポートツール「 FAANS」とは● UseCaseの変更前実装● 解決のアプローチ● DIツール wire の紹介● まとめ
© ZOZO, Inc.4ショップスタッフの販売サポートツール「FAANS」とは● WEAR, ZOZOTOWN, Yahoo!ショッピング, ブランド様の自社ECへのコーディネート投稿や成果の確認など、ショップスタッフの運用に必要な機能を搭載した業務支援ツール● お客様がZOZOTOWN上でブランド実店舗の在庫取り置きを希望した際に、ショップスタッフが FAANS上での簡単操作で取り置き対応を完結できる機能など存在● FAANSバックエンドでは、APIの実装をGo言語で開発
© ZOZO, Inc.5クリーンアーキテクチャを採用している● 今回は、UseCase部分の実装のお話「Cloud FirestoreからPostgreSQLへ移行したお話」
© ZOZO, Inc.UseCaseの変更前実装 〜 ディレクトリ構成 〜 6
© ZOZO, Inc.7UseCaseディレクトリ構成● usecaseディレクトリ直下にファイルを置いている○ 例: usecase/shop.go● usecaseディレクトリには90ファイル近く存在していて巨大└── usecase├── aa.go├── bb.go├── cc.go└── shop.go
© ZOZO, Inc.UseCaseの変更前実装① 〜 1UseCaseに複数メソッドあることで、依存関係が増えている 〜 8
© ZOZO, Inc.91UseCaseに複数メソッド持っている 1/2type ShopUseCase interface {Create(context.Context, *ShopCreateInput) (*ShopOutput, error)Get(context.Context, *ShopGetInput) (*ShopOutput, error)Update(context.Context, *ShopUpdateInput) (*ShopOutput, error)Delete(context.Context, *ShopDeleteInput) (error)}type shopUseCase struct {aRepo repository.AReopsitorybRepo repository.BRepositorycRepo repository.CRepository}● ShopユースケースにCreate, Getのように複数メソッドを持たせている● 必要なデータを受け渡しを行う dtoのInputはメソッドごとに定義している
© ZOZO, Inc.101UseCaseに複数メソッド持っている 1/2● この実装でのメリットは、ユースケースを追加する場合は、メソッドを生やすだけで可能ということで、サクッと実装することができる● 短期的に見るとそれで良いかもしれないが、中長期的に見ると依存関係が分かりづらくなっていきメンテナンス性が悪くなる
© ZOZO, Inc.111UseCaseに複数メソッドあることで、依存関係が増加 1/2type ShopUseCase interface {Create(context.Context, *ShopCreateInput) (*ShopCreateOutput, error)Get(context.Context, *ShopGetInput) (*ShopGetOutput, error)Update(context.Context, *ShopUpdateInput) (*ShopUpdateOutput, error)Delete(context.Context, *ShopDeleteInput) (error)BulkCreate(context.Context, *ShopBulkCreateInput) ([]*ShopOutput, error)}type shopUseCase struct {aRepo repository.AReopsitorybRepo repository.BRepositorycRepo repository.CRepositorydRepo repository.DRepository}依存が増える場合がある(既存の改修により増える場合も)新規メソッドが追加
© ZOZO, Inc.121UseCaseに複数メソッドあることで、依存関係が増加 2/2● UseCaseに1メソッド追加されるごとに、依存関係が増える可能性がある○ ユースケースが大きくなればなるほど、最大公約数的に増えてしまう● それにより、テストコードを書く際にモックすべきクラスがどれか分からなくなってくる○ テストコード書くのが大変になり面倒になる
© ZOZO, Inc.13試しに改善: 1UseCase1メソッドにするtype ShopGetUseCase interface {Get(context.Context, *ShopGetInput) (*ShopOutput, error)}type shopGetUseCase struct {aRepo repository.AReopsitory}● 無駄な依存が一切なくなり、最低限な依存のみとなり、変更による影響が最小化される● テスト書く際には、モックすべきレポジトリが一目瞭然となり書きやすい
© ZOZO, Inc.14「1UseCase複数メソッド」と「1UseCase1メソッド」● 依存が最小限となっており、 1UseCase1メソッドの方が分かりやすい
© ZOZO, Inc.UseCaseの変更前実装② 15〜 似たような命名のstructがあり、見通しが悪い 〜
© ZOZO, Inc.16似たような命名のstructがあり、見通しが悪い 1/2type ShopUpdateInput struct {Name string}type ShopDeleteInput struct {Name string}...● ControllerからUseCaseを呼び出す際に利用する、 DTOのInputをUseCase(メソッドごと)に作成しているので、どんどん見通しが悪くなるし、命名が被ることも考慮する必要がある
© ZOZO, Inc.17似たような命名のstructがあり、見通しが悪い 2/2func convertShopModelToShopOutput(m shopModel) ShopOutput {return ShopOutput{}}● 他にもDTO変換処理をusecaseパッケージ内に記載しているので、被らないようにするために名前が煩雑になっている
© ZOZO, Inc.これらの実装を整理すると 18
© ZOZO, Inc.19凝集度が低いと感じる● 1ユースケースに複数メソッドあることで、依存関係が増えて、変更に弱いユースケースになっている● UseCaseというパッケージが大きくて修正しづらい場面がある○ DTOのInputや変換処理などの命名が被らないように気を付ける必要がある
© ZOZO, Inc.解決のアプローチ 20
© ZOZO, Inc.21解決の方針● 1UseCase1メソッドにして依存関係を最小限にしたい● Inputや関数などの見通しの悪さや命名の煩わしさから解放されたい● 開発案件も進める必要もあり、現状の実装を大きく変えずに、軽量に実施できる方法にしたい
© ZOZO, Inc.22パッケージ分割のアプローチ● createShop、getShopなどは、完全に独立した実装が可能● 命名の煩わしさをなくすために、思い切ってusecase/shop/create/usecase.goのようにパッケージを分割● Goのお作法的には細かく切らない方が良いと思うが、 FAANSにおいてはこの方針の方が開発しやすいのではということで新規APIに関しては、模索しつつお試し実装をしてみた└── usecase├── company│ └── list│ └── usecase.go└── shop├── create│ └── usecase.go├── delete│ └── usecase.go├── get│ └── usecase.go└── update└── usecase.go
© ZOZO, Inc.23パッケージの命名について● パッケージの切り方の命名● 基本的には、usecase/{ドメインモデル名}/{操作名}/usecase.goのルール● 迷ったら都度相談○ Create → create○ Read → get○ Update → update○ Delete → delete○ リスト取得 → list
© ZOZO, Inc.新しい方針の実装 24
© ZOZO, Inc.25実際の実装 1/5// usecase/shop/create/usecase.gotype UseCase interface {Run(context.Context, *Input) (*Output, error)}type usecase struct {aRepo repository.AReopsitory}type Input struct {Name string}一律UseCaseと命名呼び出しメソッドはRunで統一必要な依存のみ一律Inputと命名
© ZOZO, Inc.26実際の実装(usecase)2/5// usecase/shop/create/usecase.gotype UseCase interface {Run(context.Context, *Input) (*Output, error)}● usecaseからの分割で、shopのcreateのUseCaseだと分かる○ UseCaseの命名で問題ない● 1ユースケース1メソッドなので、Runで問題ない○ 呼び出し側は「shop作成のユースケースを Runする」で扱いやすい○ 「shop作成のユースケースを Createする」だと煩雑に見える
© ZOZO, Inc.27実際の実装(input命名)3/5// usecase/shop/create/usecase.gotype Input struct {Name string}● ユースケースごとにパッケージが閉じているので、 Inputの命名で問題ない
© ZOZO, Inc.28実際の実装(関数命名)4/5// usecase/shop/create/usecase.gofunc convertShopModelToOutput(m shopModel) Output {return Output{}}● 関数もconvertShopModelToOutputとしても、他ユースケースと関数名が被ることはなくなるので、気に することが減った
© ZOZO, Inc.29実際の実装(controllerからの呼び出し)5/5import (createShopUseCase "hoge/usecase/shop/create")func (c *ShopController) Create(...) {shop, err := c.createShopUseCase.Run(ctx, createShopUseCase.Input{})}● 呼び出す際には、どの UseCaseの操作かを把握しやすいように package名にエイリアスをつける● createShopUseCase.Input, createShopUseCase.Outputのようにアクセス可能○ パッケージが別れていることにより、 .Input, .Ouputで分かりやすい
© ZOZO, Inc.30package戦略の効果● 1つのユースケースを実行する独立した UseCaseモジュールとなり、凝集度が高くなり、責務や関心事が一箇所にまとまった● 命名がシンプルになった○ ShopCreateInput → Input とだけ命名で済むようになった○ 被らないように関数名をどうするか?に対して考える必要がほとんど無くなった● 既存の実装の書き方と大きく変わらなかったため軽量に変更できるし、コスト対効果が大きかった
© ZOZO, Inc.31実際細かく分割を試してみて思ったこと pros● pros: packageが分割されているので責務が分かり易くなった● pros: usecaseに何気なく置いていた共通処理は、本当に usecaseで良かったのかと考える機会が増えた○ 細かくpackage分割されたことにより、複数 usecaseで利用するような共通処理は、とりあえずusecaseに書いておくみたいなことが気軽にできなくなった。それにより、本当は usecaseではなく別のところに書くべきだと気づきを得ることがある● pros: packageを分割により、関数名など命名に迷いがなくなった○ 前はShopUseCase, ShopCreateInputとかで、場合よってはもっと長い命名だったのでシンプル
© ZOZO, Inc.32実際細かく分割を試してみて思ったこと cons● cons: エイリアスをつけてimportしないと分かりづらいので、それは面倒だった○ 前はエイリアス付けず、usecase.ShopUseCaseのようにアクセスできていた● cons: 命名は確かにシンプルになったが、そのためとはいえ分割しすぎたかも?っと感じた○ usecase/shop/までで十分で、usecase/shop/get.goが正しい分割だったかもしれないと感じてきた○ usecase/shop/まで切ったとしても多くても10ユースケースが含まれるかどうかなので、命名の衝突はほぼ無さそうだし、shopUseCaseの関心事として十分わかりやすい構成だなと思った○ usecaseが巨大になってしまったのは、正しく分割できていなかったことが原因であり、モジュラモノリスを採用してshop, companyのような粒度で垂直分割してたら、それぞれが巨大になることはなさそうだし、綺麗に分割できていたと思う○ 今後モジュラモノリスを採用するとなった場合にも、usecase/shop/までの分割だったらそのまま移行しやすそうだなと感じた
© ZOZO, Inc.33今後について● 前の実装からは、問題点も解決されて良くなって、開発し易くなった● しかし、この分割の粒度が本当に正しかったかどうかは、自分の中でも答えは出ていないので、今一度検討したいところではある
© ZOZO, Inc.DIツール wire の紹介 34
© ZOZO, Inc.35DIツール wire● FAANSでは、DIツールのwireを利用● 今回の修正でpackageが細かく分割されることにより、 DI関連のコードが冗長にならないようにしたい● providerがどこに存在するかを把握しやすくするため、 packageごとにprovidorグループを作成することで意識することを減らせる
© ZOZO, Inc.36DIツール wire ファイル構成 1/5● packageごとにproviderグループを作るために、それぞれ wire.goを持つようにしている└── usecase├── shop│ ├── create│ │ ├── usecase.go│ │ └── wire.go│ ├── get│ │ ├── usecase.go│ │ └── wire.go│ └── wire.go└── wire.go
© ZOZO, Inc.37DIツール wire 2/5// usecase/shop/create/wire.govar WireSet = wire.NewSet(NewUsecase,)● それぞれのpackageでproviderグループを作成
© ZOZO, Inc.38DIツール wire 3/5// usecase/shop/wire.goimport ("github.com/google/wire""hoge/usecase/shop/create""hoge/usecase/shop/get")var WireSet = wire.NewSet(create.WireSet,get.WireSet,)サブモジュールのWireSetをまとめる
© ZOZO, Inc.39DIツール wire 4/5// usecase/wire.goimport ("github.com/google/wire""hoge/usecase/company""hoge/usecase/shop")var WireSet = wire.NewSet(company.WireSet,shop.WireSet,)サブモジュールのWireSetをまとめる
© ZOZO, Inc.40DIツール wire 5/5wire.Build(controller.NewShopController,usecase.WireSet,)● injector側で、usecase.WireSet指定することで、DIしてくれる● これにより、packageが細かく分割されたとしても usecaseのWireSetだけ意識するだけで済むようになる
© ZOZO, Inc.まとめ 41
© ZOZO, Inc.42まとめ● UseCaseの凝集度を高め開発し易くするために、細かくパッケージ分割を行って問題点は解決された● 試してみてよかったところもあれば、気になるところも出てきた● それを踏まえて今後どういう構成が良いか考え、より開発しやすい構成を模索していく