モノリス解体に向けたパッケージ構成

CTOのishkawaです。

この記事は10X アドベントカレンダー2023の3日目の記事です。

先日、サーバーサイドのメンバーを中心として、コードをどのように分割管理していくか話すオフサイトを実施しました。オフラインで1日中話していたこともあり、話題は色々な方向に進んだのですが、その中でもモノリス解体にトピックを絞ってシェアしたいと思います(他の話は他のメンバーが書いてくれるはず!)。

前提

10Xには4つの開発チームがあります(お買い物チーム、お会計チーム、お届けチーム、マスターデータチーム)。今年の4月にチーム分割が始まり、コードやデータのオーナーシップも各チームにアサインしてきました。

この組織移行によってオーナーシップ分割は進んでいるものの、依然としてモノリスのパッケージ(Dartのパッケージ)は大きいままで、ほとんどのコードがそこにある状態でした。領域ごとのディレクトリ分割は進んでいるものの、パッケージまで分かれる筋道が見えていませんでした。

課題

正直、ディレクトリ分割/オーナーシップ分割が進んでいけば、モノリスパッケージでもまだやっていけなくはないかなあという状況ではありました。しかし、以下のような課題があるのは事実なので、先手を打ってモノリスを解体する段取りを進めることにしました。

  • 業務領域を跨いた処理に対する制限が弱く、意図しない結合が生まれやすい。
  • 開発中のエディタによる解析、テスト実行、コード生成に掛かる時間が伸びている。
  • 名前空間が大きいため、クラスや関数に素朴な名前が使いづらい。
  • 依存パッケージやユーティリティが肥大化しやすい。

1つ目の課題が特に重要なので、ここではそれについて説明します。

業務領域を跨いだ処理に対する制限

Stailerにはネットスーパーの幅広い業務を取り扱うシステムが含まれています。注文管理、決済、店舗でのピッキング、配達といったものです。

各種業務はある程度の独立性がありますが、互いに関連しているものもあります。例えば、お客様からの注文が入っても、店舗でのピッキングすることができなければ欠品となるため、お客様の注文管理と店舗のピッキング管理の2つの業務は関連していると言えます。

この関連を素朴に実装する場合、注文管理でもピッキング管理でも注文データを更新することになりますが、これでは注文データは共有データとなり、管理が難しくなります。

一方、注文を取り扱うモジュールを定義し、データの変更をモジュール内に限定し、外からは指示を出すコマンドを定義すると、注文データの変更は注文モジュールに閉じることになります。

モノリスのパッケージではすべてが同じパッケージに含まれるため、前者の方式で簡単に実装できてしまいます。これは極端な例なので「そうはならんやろ」と思えるものかもしれませんが、いずれにせよ「どの機能がどの機能を利用して良いのか」の統制はコードベースが大きくなるほど難しくなります。ArchUnitやLintなどの方法で統制をかけることもできますが、業務領域レベルの大元の分割ではパッケージの分離の方が有効と考えました。

パッケージが分かれている場合、そもそも注文データへのアクセスのインターフェースも公開しない限りは使用できないため、自然と後者の方式を取ることになります。

我々のアプローチ

以下のようなパッケージ群を構成することに決めました。

  • stailer_app: mainのパッケージ。アプリ向けgRPCサーバーやcronジョブなど。
  • stailer_module: 業務を扱うモジュールのパッケージ。注文管理やピッキング管理など。
  • stailer_common: Stailerで汎用的に使うパッケージ。税額計算など。
  • general: Stailerに依存しない一般的なパッケージ。Firestoreクライアントなど。

元々 stailer_appstailer_module のすべてが1つのパッケージとなっていった所から、各種業務ロジックを stailer_module に切り出していくことと、エントリポイントであるstailer_app が別々のパッケージとして切り出していくことが主な決定です。

stailer_moduleは他のモジュールに依存してもOKで、公開インターフェースを通じて領域跨ぎの業務を完遂する想定です。なお、モジュールの循環依存は許容しないものとしています。

パッケージの公開インターフェース

Dartのパッケージの公開インターフェースは、以下のように決まります。

  • lib/src以下の実装ファイルはデフォルトでは公開されない。
  • lib/*.dartで実装ファイルをexportすると、当該ファイルのインターフェースが公開される。

ファイルの構造を図に起こすと、以下のようになります。

package_root
├── pubspec.yaml
└── lib
    ├── some_export.dart <-- lib/src以下のファイルを参照してexportする。
    └── src
        └── ...          <-- lib/src以下に実装ファイルを入れる。

前述の通り、ある業務を扱うモジュールの内部実装は他の業務を扱うモジュールからは不可視としようとしています。そのため、パッケージがexportするものは以下の3種類と決めました。

  • コマンド: モジュールの変更を加えるインターフェース。stailer_moduleから使用される。
  • クエリ: モジュールのデータを取得するインターフェース。stailer_moduleから使用される。
  • gRPCサービス: gRPCサービス。stailer_appから使用される。

exportするものを絞ることにより、モジュール間のコミュニケーションは意図したもののみとなり、不適切な結合を生みにくい構造となりました。

実現への筋道

先日まさに「この形を目指すぞ!」ということになったので、実現はまだこれからです(上手くいかないこともあるかも?)。実現に向けては、以下のようなステップを踏むことを想定しています。

  1. モノリス内にモジュールディレクトリをつくり、関連業務のコードをまとめる。
  2. モジュールディレクトリを跨ぐ処理の呼び出しに制限をかけ、疎にしていく。
  3. モノリス内のモジュールディレクトリへの依存がないモジュールディレクトリをパッケージ化する。

図にすると以下のようなイメージです。まずはモノリスパッケージからスタートします。

次に、モノリスパッケージ内にモジュールディレクトリを業務ごとに作って、分類していきます。

モジュールCはモノリス内へのモジュールへの依存がないため、パッケージにします。

モジュールAとモジュールBもモノリス内への依存がなくなったので、パッケージにします。

結果として、モノリスにひとまとまりになっていたものがパッケージ分割されました。

終わりに

今回のパッケージ分割ではパッケージという境界はできるものの、実体としてDartのメソッド呼び出しに過ぎません。裏を返せば、この方式は各モジュールが同一言語(Dart)で実装されていることや、モジュールがアプリケーションにバンドル可能という前提に依存しています。

将来的にはこの前提を取り払い、各モジュールの直接呼び出しからメッセージベースの連携に変えたり、はたまた各モジュールをサービス化したりするのかもしれません。いずれにせよ、今進めているような分離は必要なので、まずはこれをやり切ろうというのが我々の現在地です。

こうした取り組みは続けていくので、いつかまたこのブログで状況を報告できればと思います。


10X アドベントカレンダー2023はクリスマスの12月25日まで続きます。

明日はtakimoさんによる記事です!お楽しみに!