株式会社ヘンリー エンジニアブログ

株式会社ヘンリーのエンジニアが技術情報を発信します

gRPCで分断されたモノリスを段階的にモジュラーモノリスに移行する

こんにちは。株式会社ヘンリーでエンジニアをしているagatanです。

私たちが開発する電子カルテ・医事会計システム「Henry」は、非常に巨大な単一のプロダクトです。そして、その性質上、明確なドメイン境界を見出すことが難しいという特性を持っています。この「巨大で複雑なプロダクトを、いかにして組織的に開発し続けるか」という問いに開発チームは長年向き合ってきました。

最近、この大きな問いに対する新たな一手として、かつて2つのgRPCサービスとして分割されていたバックエンドを、段階的に「1プロセスのモジュラーモノリス」へと移行させるプロジェクトが進捗しています。

今回は、その移行の過程についてお話しします。

第一歩: モノレポ化

移行への大きな第一歩目は、2年前に遡ります。当時、第一歩として踏み切った「モノレポ化」については、過去のブログでも紹介されています。

dev.henry.jp

この記事にもあるように、もともとHenryのバックエンドは general-api と receipt-api という2つのサービス、2つのリポジトリに分かれていました。general-api がユーザーからのリクエストの矢面に立ち、その裏で receipt-api が複雑な医療制度の計算を担う、という設計思想です。

しかし、事業が成長し、ドメインやプロダクトへの解像度が上がるにつれて、この当初の分割境界がもはや理想的ではないことが明らかになってきました。誤った分割境界が歪みを生み、密結合でありながら物理的に分断されているという、矛盾した状態に陥ったコードが散見される状態になっていました。

この歪みを正し、より適切な境界線を見出すための第一歩が、モノレポ化でした。 まず誤った境界を取り払い境界の見直しの試行錯誤をやりやすくする、という方針を掲げ、中期的には receipt-api を解体し general-api に一旦寄せることを目指しました。

その理想を目指すトランジションの初手として、バックエンドのコードを一旦モノレポにまとめ、サービス境界の見直しを含めた変更をやりやすくするという戦略を取りました。

これにより、関連する変更をatomicに行えるようになり、開発効率は確かに向上しました。 しかしながらこの時点では general-api と receipt-api という2つのサービスは依然として存在し、根本的な課題は手つかずのままでした。

第二歩:Gradleモジュールによる「内側からの分割」

Henryのバックエンドは、主にKotlinとGradleで構築されています。モノレポ化以前から、general-api と receipt-api はそれぞれGradleモジュールとして定義されていました。

モノレポになったことで、両サービスから依存されるような基盤的実装などを含む :shared モジュールが生まれるなど、Gradle モジュールの活用が進んできました。

dev.henry.jp

Gradle モジュールの活用が進むにつれて、徐々に :receipt-api をさらに分割する動きが出てきました。 医療事務(医事)の業務にまつわる処理なので :iji という prefix をつけ、より細かい業務単位で :iji:foo, :iji:bar のようなモジュールが徐々に切り出されていきました。

flowchart LR
  general[[:general-api]] --> :shared
  receipt[[:receipt-api]] --> :shared
  receipt --> :iji:foo
  receipt --> :iji:bar

巨大だった :receipt-api が、内側から徐々に分割されはじめました。 分割するだけでも、ビルドの並列性を高めたり、責務と依存の方向を明確に強制できるようになったりと、この時点でも大きな恩恵がありました。

そしてモジュラーモノリスへ

このように Gradle モジュールが分割されていったことで、これまで :receipt-api 配下にあった処理も :general-api から直接参照できるケースが増えてきました。

flowchart LR
  general[[:general-api]] --> :shared
  receipt[[:receipt-api]] --> :shared
  receipt --> :iji:foo
  general --> :iji:foo
  receipt --> :iji:bar

従来であれば general-api が receipt-api に gRPC 経由で処理を委譲していたものについても、general-api のプロセス内で完結することができるようになりました。 gRPC を経由しないことで、パフォーマンスの向上や安定性の向上のみならず、処理同士のインターフェースをより柔軟に Kotlin の表現力をフル活用して定義できるようになり、開発生産性も向上しています。

receipt-api は単体で見ても相当なサイズがあったため、移行は段階的に行う必要がありました。 直近まったく触っていない機能などもあり、よく変更する部分から徐々に移行を行っています。

これを段階的に行うための実践的工夫として 1. あえて protobuf 依存を切らない選択肢を持つ 2. 逃げ道を作る といった工夫をしてきました。

あえて protobuf 依存を切らない選択肢を持つ

general-api と receipt-api はもともと gRPC で分断されており、そのインターフェースは protobuf で定義されていました。

理想的な進め方としては、そもそも gRPC を廃止しようとしているわけなので、protobuf 依存も外すことが望ましいと考えています。 :iji:foo のようなモジュールには純粋なドメインロジックのみを定義し、そこに protobuf 依存が入り込むということは許容しないのが理想です。

基本的にはそのような考え方に基づいて移行を進めていったのですが、どうしても巨大すぎるドメインや技術的負債が積み重なった部分については protobuf 依存を剥がすことが非常に大変でした。 開発の極初期のころ、protobuf をドメインロジックから引き剥がす設計判断をしていなかったのもあり、古くからあるコードベースでは protobuf がドメインロジックの中核まで染み込んでいるケースがあったためです。 そういった領域については、むしろ protobuf 依存を剥がすためにも、まず無理矢理でもいいから 1 プロセスで収まるようにしてしまって、そこからリファクタリングするほうが、安全性を担保したまま移行を推進できる、と判断しました。

protobuf そのものはある意味では Plain Old なデータなので、剥がさないことの弊害も小さく、実装面としても

  • before
    • Object (general-api) → protobuf → (gRPC) → protobuf → Object (receipt-api)
  • after
    • Object (general-api) → protobuf → Object (receipt-api)

のようにネットワーク越しに呼び出すという処理が消し飛ぶだけで、コード上の字面はほとんど変えずに済みました。 (このあたりは元来 RPC フレームワークがネットワーク越しであることを隠蔽するような設計になっているので、ある意味当たり前なのですが)

この戦略を取った領域については、 :iji:foo モジュールへの切り出しも非常に軽量でした。要するにコントローラー層から先をすべてバコッとモジュールに押し込んでしまえばよいためです。 とはいえもちろんただ押し込むだけでは価値が生まれないので、その後の改善戦略とセットです。 実際にあった例としては、protobuf にプロパティを生やさないと対応できないような機能実装を行う際に段階的に protobuf を剥がす、といった活動をしています。

逃げ道を作る

ここまで、 :iji:foo のようなモジュールは、まるで単独で駆動する純粋なモジュールのような顔をしている説明をしてきました。 実際には、ほとんどの業務領域が共通的なモデル(Henry でいえば「患者」や「保険制度」など)に依存しており、ここまで単純なものではありません。

そういった「本来であればそれ単独でモジュールをなして依存グラフの根に近い位置にいるべき」であろうクラスたちについても、既存実装がモジュール分割を想定しないで作ってきたため、クラス同士の依存関係が巨大な団子をなしており、一部を切り取ることが難しくなっていました。 そういったクラスたちはだいたいどの領域の実装からも依存されており、彼らの存在が邪魔をしてしまってモジュール分割が阻害されるということが頻発していました。

そこで、 :iji:shared のような駆け込み寺モジュールをあえて用意し、大きな団子を団子のままバコッと押し込むことにしました。 もともと :receipt-api にあった団子を移動しただけなので、複雑さは微増で済み、代わりに団子の外側でキレイなモジュールを作る選択肢が圧倒的に増えました。

将来的にはこれが問題となる未来はもちろん見えているのですが、厳密にやることを重視しすぎて前に進めなくなるよりもこの方向性で前に進めてしまう方が、短期・長期どちらにとっても有益だろうと判断しました。

flowchart LR
  general[[:general-api]] --> :shared
  receipt[[:receipt-api]] --> :shared
  receipt --> :iji:foo
  general --> :iji:foo
  receipt --> :iji:bar
  :iji:foo --> :iji:shared
  :iji:bar --> :iji:shared

現時点の進捗

現在、医事領域が先行してモジュール分割を進めており、:iji:* を冠するモジュールは14個にまで増えました。 既存の2つのサービスから実装を削り取って融合させたモジュールや、全く新しい機能として生まれたモジュールが混在し、:general-api 単体で完結する処理は着実に増え続けています。

その効果は、日々の開発サイクルにも現れています。 Gradle はモジュールごとに依存関係を見て自動でタスクを並列実行してくれますが、 :iji:* モジュールにまつわるタスクについてうまく並列化できていることがわかります。(ヘンリーでは develocity を導入しています)

Gradleタスクの実行時間

dev.henry.jp

臨床領域でもモジュール分割の機運が高まってきており、このまま少しずつ receipt-api が痩せていって最後には大統一される未来を期待しています。

おわりに

本記事では、ヘンリーのバックエンドが2つのgRPCサービスから単一プロセスのモジュラーモノリスへと移行している様子を、その背景と実践的な工夫と共にご紹介しました。

巨大でドメイン境界が曖昧なプロダクトをいかに組織的に開発していくか、という問いに対して、私たちはまずモノレポ化に踏み切り、次にGradleのモジュール分割を進め、そして今、モジュラーモノリスへの道を歩んでいます。この道のりは、当初のサービス分割がもたらした歪みを解消し、より柔軟で生産性の高い開発体制を築くための挑戦です。

移行の過程では、理想論だけでは乗り越えられない現実的な壁に対する工夫も行ってきました。完璧な設計を追い求めるあまり歩みを止めるのではなく、将来の改善を見据えながらも、まずはチームが前に進むことを優先する。こうした現実的な判断の積み重ねが、大きな変革を少しずつ、しかし着実に推し進める力になっていると感じています。

この取り組みはまだ道半ばです。まだまだ2つのサービスが本当に統合され切るには遠いですし、ここまでやってきたモジュール分割がいつかまた別の問題として火を吹く日も来るかもしれません。 が、これまでモジュール分割を行ってきて、「複雑・巨大すぎて手のつけようがなさそうに思えること」も、まず少しやってみることが始めると、意外となんとかなると思えるようになるなと感じています。 ソフトウェア開発は継続的な改善の積み上げなので、今後も振り返りつつも挑戦し続けていきたいと思っています。

ヘンリーでは、このようにプロダクトの成長に合わせてアーキテクチャを柔軟に見直し、開発者体験とプロダクト価値の向上にチーム一丸となって取り組んでいます。複雑な課題に対して、泥臭く、しかし戦略的に向き合っていくことに面白さを感じる方がいらっしゃいましたら、ぜひ一度お話しさせてください。

dev.henry.jp

jobs.henry-app.jp

最後までお読みいただき、ありがとうございました。