引越し侍の新サービスのバックエンドの1年半の開発を振り返ってみました。
使っている技術と全体的なアーキテクチャについては以下の記事をご参考ください。
(2021年アドベントカレンダーの記事です)
主に話す内容は?
バックエンドというと「データ設計」「ソフトウェアアーキテクチャ」「インフラ構成」など範囲が広いですが、この記事ではGoで開発しているAPIサーバーのソフトウェアのアーキテクチャだけフォーカスします。
どんな構成になっているか
Clean Architectureに寄せたレイヤードアーキテクチャになっています。
主な役割としてはDBから単純なデータ取得、保存だけでは表現できないビジネスロジックをREST APIとして提供しています。
Frontendへは Hasura GraphQL Engineを通して、GraphQLとして提供しています。
振り返ってみて失敗だったと思ったこと
レイヤーが多すぎた
論理的なレイヤーは大きくは3つですが、サブレイヤーまで数えると6つ以上ありました。
レイヤーの分けの詳細はこちら
上から順で内部から外部のレイヤーです
-
Domain
- Model : ビジネスロジック
- Repository : データ永続化がinterfaceで定義されている
- Usecase : 機能単位でのアプリケーションロジック
- ビジネスロジックが含まれることもあります
-
Presentation
- Controller : 外部から受け取った値をDomainレイヤーに渡すための変換処理
- アプリケーションのインタフェースはこちらでは意識しません
- Handler : アプリケーションのインタフェースとそのバリデーション処理
- 例えば、REST APIで、Headerに必須で
hoge-user-name
があることの確認など
- 例えば、REST APIで、Headerに必須で
- ViewModel : レスポンスの形定義と変換処理 (goのJSONタグなどがここに入る)
- Controller : 外部から受け取った値をDomainレイヤーに渡すための変換処理
-
Infrastructure
- Data :
Domain/Repository
レイヤーで定義された機能の実装- SQLが書かれていたり外部APIを呼び出したりします。
- Server : APIサーバーの設定 (GinのmiddlewareやEndpointの設定等)
- Data :
レイヤー数が多いことによる問題点が大きく2つがありました。
- 一つの機能に付き、コードが浅く広く分布されてしまう
- 外側のレイヤーから受けた値をそのまま内側のレイヤーに渡しているだけのコードが大量発生
(特に Handler → Controller → Usecase 流れのControllerが当てはまる)
新サービスだったため、機能の修正頻度より新たな機能の追加や削除の頻度が高い特徴がありました。
なので、毎回の開発時に、修正すべきファイルやコードのライン数が多くなっていました。
(どんだけシンプルな機能であっても、必要なコードとファイルが一定以上になってしまう)
全てのレイヤーでDI1をしていた
内部のレイヤーは外に依存しないために、interfaceを利用して依存性逆転をする必要があります。
ただ、僕は深く考えずに全てのレイヤーにinterfaceを置いてDIをしていたため、冗長な(無意味な)コードが増えてしまいました。
それにより、以下のような問題点が発生しました。
- 一番外側であるHandlerにもinterface定義するコードを書く
- DIを管理しているコードが増える
- DIコンテナではなく生のコードで書いてあるので、リーダビリティが非常に悪い
- VSCodeでは
Go to Implementations
2しないと定義に飛ぶので、慣れてない人ほどコードを追うのがより面倒くさい
曖昧な命名と定義
レイヤー名はそのままGoのパッケージ名として使っています
ビジネスロジックが入る核となるレイヤーの命名を Model
として命名しました。
上位レイヤーはDomain
と命名したので、DDD3でいうドメインモデルにしていく予定でした。
初期の設計時にはDDDの知識も低く、ビジネス全体に渡るドメインの定義もできてなかったです。(実は今も変わってない・・)
結果、Model
という名前だけに釣られてしまい、DBのテーブル構成に非常に依存しているORM役割をするものになってしまい、ドメインモデルとして扱うのが難しくなってしまいました。
かと言って、完全にORM的な存在に仕切ったわけでもなく、ドメインオブジェクト的なものもあり、中途半端な状態になったのが更に問題でした。
そして、その先へ
このままだと、開発のコストと新しいメンバーの初期ハードルが高くなる一方でした。
人員の入れ替わりなどもあり、属人化が進んだことで、手を打たざるを得ない状況でした。
取り急ぎ、優先度と取り組みやすさのバランスを考えて以下の対策に取り掛かりました。
レイヤーとDIを減らす
Handler
をController
に統合して、ControllerレイヤーはInterface定義も削除しました。
その結果、ControllerレイヤーはHasuraから受け渡ってくるHeaderの値やGin Framworkにゴリゴリ依存するコードになりましたが、それが本来あるべき姿だったと思います。
ドメインモデルへシフトする土台作り
Model
をRDBのテーブルの構成と切り離すことは一気でするのは難しいので、徐々に変更するように土台を作りました。
具体的には、Infrastructure
レイヤーのdata/table
というパッケージを作成し、そこにORM役割を移しました。
しばらくの間はdomain/model
とdata/table
の役割が重複することはありますが、これからの機能追加、変更時に合わせて徐々に移して行く予定です。
本来あるべき姿のdomain/model
はRDBテーブルに依存してない形でビジネスロジックを扱えるようにします。
正しいREST APIの形にする (Hasuraへの依存を減らす)
失敗の内容には書きませんでしたが、REST APIとして正しくない状態でした。
データ取得であれ、書き込みであれ、全てがPOSTになっていました。
理由としては、Hasuraの特性上、Hasura Actionsで使えるHttp MethodがPOSTのみだったことです。
Hasuraのv2.1以降からはREST Connectorsが使えるようになっていましたが、後回しにしてきた設定と正しいRESTの形へ変更を行いました。
それによって、もしHasuraを取り除いたとしても、Backend APIが意味わからないEndpoint設計になっていることを防ぎました。
もっと話たいことがあるが・・・
先述した内容以外にも、いろいろと失敗と成功(良かったと思うこと)はあります。
が、今回は一部を簡略に書いておくまでにします。
- 失敗
- Repositoryの実装にアプリケーションロジックが乗りすぎている
- Repositoryの実装のテスト方法が難しすぎて、あんまりできていない
- Log出力とSentryでのエラー監視が冗長なコードになっている
- wireを使ってみたけど思ったようにならず挫折して導入をやめた
- 成功
- テストコードが書ける構成
- カバレッジは外側のレイヤーは低いが、内側のレイヤーに行くほど100%に近くなっている
- Usecaseのコードを読むと各機能の一通りのフローがわかる (実装の詳細まで見ずに)
- 定型的なFrameworkを使ってないので、設計力が上がる
- 勉強することになる
- 楽しい!
- プロダクトとアプリケーションに対して愛着がますます湧く
- テストコードが書ける構成
-
Dependency injection (依存性注入) ↩
-
実装部にJumpする VSCode Go Extentionの機能 ↩