🏎️

TypeScript・モジュラーモノリスによる型安全なWebサービス開発

2023/01/18に公開3

こんにちは。SALESCORE株式会社CTOの成澤です。
祝・Publication機能のオープンβリリース🎉🎉 ということで、SALESCOREのテックブログを発信し始めます!

テックブログの一発目ということで、2022年で一番開発体験が変わったTurborepoによるモノレポ・モジュラーモノリスによる開発について紹介します。
今後もTypeScriptでのWebサービス開発について記事を出していく予定なので、気になる話題などあればコメントいただけるととても嬉しいです🙋‍♀️

モジュラーモノリスという選択肢

ソフトウェア開発における重要な要素の1つは抽象化です。

抽象化をあえて噛み砕いて、平坦な言葉で言うならば 「適切なグルーピング」 と呼んでも良いでしょう。抽象化とは、ものごとをグルーピングして、適切な名前を与えることです。

  • 100行の処理の羅列は分かりづらいが、10行ずつグルーピングして、関数名を与えると分かりやすくなる
  • 100個の関数が書き連ねられたファイルは読み解きづらいが、関数をクラス・名前空間・ファイルで分けると分かりやすい
  • 100個のファイルが置かれたディレクトリは読み解きづらいが、適切な粒度でディレクトリ分けされると見やすい

適切なグルーピングは、読み解きやすく、また疎結合な設計を生み出します。
大規模なソースコードでも、適切にグルーピングされていれば読み解くのは比較的容易です。

とはいえ、上記の例のように適切に関数をグルーピングしてファイルに分割しても、1000個のファイルになったら結局分かりづらくなります。
これを分かりやすくするには「1つ上のレイヤーでのグルーピング」が必要で、1000個のファイルはディレクトリに適切にグルーピングされるべきでしょう。

しかし、この方針にも限界があります。抽象化のレイヤーが「関数」「ファイル」「ディレクトリ」の3パターンしかないとき、1億行のソースコードを見通しよく扱うのはかなり難しいでしょう。あるレイヤーで見通しよく扱えるグループの数は限られています。
更に極端な例で言えば、「1ファイルのみで開発する(=グルーピングの手段が関数のみ)」という縛りで大規模なソフトウェア開発をするとしたら、我々は発狂するでしょう。

そのため、我々はグルーピングに精を出すだけではなく、より多くの粒度のグルーピングの選択肢を持つべきであると考えます。
その選択肢の一つとして、モジュール化は有効な手段です。
複数のディレクトリをモジュールという形でグルーピングすることで、また1つ多くの粒度のグルーピングを得ることができます。

ソフトウェア開発に銀の弾丸はありません。ソースコードをとにかく細かく関数に分割すれば良いというものではありません。それと同じく、常にモジュラーモノリスが有効なわけではないですが、しかし選択肢の一つとして常に持つべき手段であり、必要に応じて使うべき手段であると考えます。

「ディレクトリで区切る」のと「モジュールで区切る」のはどう違うのか?大して変わらないのでは?と思う人もいるかもしれませんが、それを同じだと言うのは「1ファイルに1000行書くのも、100行を複数ファイル分割するのも、本質的には一緒である」と言うのと同じようなものです。

ソフトウェア開発は、一定の自由を捨てて制約を受け入れることによりむしろ効率的な開発が出来るようになるという側面がありますが「モジュールの外からは、モジュールがexportしている関数しか参照できない」という制約を持ち込むことで、思考の幅が狭まり、見通しの良い開発ができるようになります。

モジュラーモノリスの実際

弊社SALESCOREで開発しているWebサービスについて、実際のモジュール構成を紹介します。

Turborepoを使ったモノレポ構成、言語はTypeScriptで統一しており、各モジュールがそれぞれ1つの package.json を持つ形となっています。

apps
├── backend
│   ├── cli
│   ├── web
│   └── worker
│   └── tracker
└── frontend
    ├── admin-client
    └── client
packages
├── backend
│   ├── auth
│   ├── common
│   ├── domain
│   ├── elt
│   ├── infra
│   ├── job
│   ├── query
│   └── tracker
├── common
│   ├── common
│   ├── core
│   └── features
└── dev
    └── eslint-config

apps
アプリケーション。現状、バックエンドアプリケーションが4つ、フロントエンドが2つ。

  • web メインのAPIサーバー。GraphQLサーバーとしてプレゼンテーション層の実装のみを記述し、永続化その他のロジックはそれ以下のレイヤーに記述する
    • …という理想を持ちつつ、実際のところ簡単な処理は web で記述している(後述)
  • worker 非同期ジョブサーバー
  • cli migrationスクリプトやREPLなど
  • tracker トラッキング関連のWebサーバー
  • client ユーザー向けNext.jsアプリケーション
  • admin-client 管理者向けNext.jsアプリケーション

packages
各種ライブラリ。ライブラリはアプリケーションや他ライブラリから参照される形を想定し、単体で実行はされない。

packages/common
フロントエンドからもバックエンドからも参照されるライブラリ群。全てpureな関数として実装されている。

  • common 全てのモジュールから参照される共通の処理( groupBy sortBy とかの実装)
  • core ビジネスロジック。 core は社内でのプロジェクト名称。最も改修量が多いモジュール
  • features ビジネスロジック。 core より機能ベースなもの。

型情報はzodを使って記述しており、これをprismaやGraphQLのレイヤーで使い、外界からの入力をvalidationしています。

諸々と異なる点はありますが、DIを行いつつUnion Typeを多用するような開発スタイルは、以下のスライドの後半に近いかも。参考までにリンクを貼っておきます。
https://speakerdeck.com/naoya/typescript-niyoru-graphql-batukuendokai-fa-75b3dab7-90a8-4169-a4dc-d1e7410b9dbd

packages/bakcend
バックエンドのモジュール

  • infra prismaなどRDBMSとの通信を行うレイヤー
  • domain リポジトリとかサービスとか諸々が入っている、一番雑多なモジュール
  • tracker トラッキング関連の実装。
    • 個別のDBを持っている(クライアントはprisma)
    • apps/tracker がメインの呼び出し元だが、 apps/web からも読み込み系でアクセスする。このように複数サービス・複数DBの対応も柔軟かつ適切にできる
  • それ以外のモジュール: 依存を持たないpureな関数でできたモジュールが多い。一定のまとまり毎にモジュールに切り出している。
    • 例えば auth は認証関連の処理。ソースコードの量はそこまで多くないが、認証に関するロジックが集約されていて見通しが良い。

現状、フロントエンド周りはモジュール分割していません。
以前はしていたのですが、フロントエンドのモジュール構成でしっくりくるものがなく、Turborepoへの移行作業の過程で一旦取りやめました。現状は構想があるので、モジュール化したいと考えています。

※以前はTypeScript Project Referencesを使ってモジュラーモノリスライクな開発をしていました。この辺は以下の記事をご参考にどうぞ。
https://zenn.dev/katsumanarisawa/articles/58103deb4f12b4
https://zenn.dev/katsumanarisawa/articles/3e053fe3627b5b

モノレポ・モジュラーモノリスの何がよかったか

モノレポ・モジュラーモノリスでの開発を一年弱続けたので、実際の感想を書いてみます。

凝集度の高さ

機能Aに関する実装が、バックエンド〜フロントエンドの各所に散らばっているような形だと、全体像がとても掴みづらいですが、これが1つのモジュールに詰め込まれていると、モジュールの中身を見るだけで実装の全体像が把握できて便利です。

また「ロジックをフロントエンドに書くか、バックエンドに書くか」という思考ではなく、「モジュールにロジックを書き、フロントエンド・バックエンドから必要に応じて呼び出す」という思考になります。すなわち 「どこに書くか」で迷うことがなくなり「書くべき場所に書く」という意識になります。

特に複雑なアプリケーションにおいて、これが想像以上の効果を発揮していて、余計なことを考えずにドメインロジックの実装に集中することができるため開発体験が良いです。また完成後は、作ったモジュールを組み合わせて実装するような体験となり「小さいモジュールを組み合わせて大きなものを作る」というUNIX哲学に近いものを感じられます。

テスタブルな設計、DI、副作用のない関数

重要なビジネスロジックをモジュールに切り出して、副作用のない関数として実装し、必要があれば永続化のロジックをDIするような設計にすると、テストが書きやすくなります。

かつ、このやり方の良いところは、重要なロジックだけを切り出して、単純な処理はプレゼンテーション層にベタ書きするみたいな書き方ができることです。

よくDDDの文脈で過剰で冗長な設計を目にすることがありますが、「SELECTした結果を返すだけ」のような処理をプレゼンテーション層以外で書く意義は薄いと感じます。GraphQLのResolverで return db.user.findMany()とか書けばいいだけの処理を、わざわざ他のレイヤーを用意して冗長に書く意義は薄いでしょう。もちろん冗長に書く意義があるならやればいいと思いますが、ないならやるべきではありません。

とはいえ、複雑な処理については、しっかりとレイヤーを分けた処理やDIなどのテクニックを使って書きたくなります。
少し複雑なだけの処理はプレゼンテーション層の下の層とかで書けば良いのですが、様々な機能の複雑なロジックが大量に入ってくると見通しが悪くなってきます。サービスとかユースケースが大量に乱立して、それぞれから呼ばれる共通関数とかまで作られると結構地獄ですね。
これを、ある程度複雑な処理はもはやモジュールに分割してしまうと見通しが良くなります。

これらは厳密にはモノリスでも達成可能なのですが、モジュールという区切りがあるとより意識しやすく、達成しやすいことだと感じます。モジュラーモノリスで開発していると「一定の量のロジックはモジュールに区切る」という力学が働きやすいですね。

実装の詳細をカプセル化できる

exportする関数を指定できるのも便利です。
大きく複雑なTypeScriptプロジェクトをモノリスで運用していると、同名の関数が違う文脈でexportされて自自動補完の候補を汚染したり、それらに違う名前をつけるために長めの命名規則(例えば generateFooIfBarWithBaz みたいな)を付けたくなったりしますが、あまり開発者体験がよくありません。

ソースコードがモジュールにより分割されていると、exportしない関数についてはモジュール内に閉じた命名ができるので関数名を短く簡潔につけやすく、エディタの自動補完で提示される関数は他モジュールがexportしている関数のみなので、不要な候補を見る必要がなく頭に優しいです。

とはいえ、いちいち package.jsonindex.ts を書くのは面倒でもあります。この辺はRustがイケてますね。

リファクタリング

共通モジュールの型や関数をIDEで変更すると、フロントエンド・バックエンドの全てのコードが一発で書きかわって気持ち良く、気軽にリファクタリングが行えます。

気軽にリファクタリングができるのは、期待以上の役目を果たしています。ドメイン知識が不十分な初期開発時に実装した残念なモデル名を、早期にイケてる名前に書き換えると、思考がクリアになり更にドメインモデルが洗練されていく気がします。洗練されたコードが洗練されたドメインモデルを産む、ということですね。これは複雑なドメインに向き合っていると特にそう感じます。

リファクタリングが楽にできるのは今どきの言語とエディタでは当たり前ですが、フロントエンドまで一気通貫でできるのはTypeScriptで統一することの大きなメリットです。

モノレポ・モジュラーモノリスの何が悪かったか

ビルド、デプロイ関連

いくつかの原因で明確に体験は悪くなっています。ないし、改善のために時間が取られました。

ビルド時間
モノリスなTypeScriptプロジェクトとビルド時間を比較すると、Turborepoによるビルドはキャッシュが効けば早いが、キャッシュが効かないと遅い。そして一番変更が多いモジュールは core のドメインロジックで、大半のモジュールがここに依存しているので、結局ほぼ全てキャッシュが効かなくて遅い。
lintについても同じく。

デプロイ
yarn install するとバックエンド・フロントエンド全てのnpmパッケージがインストールされてしまう。
例えばバックエンドのDockerイメージを作る文脈でも余計なパッケージが大量に入るので、 yarn install が遅く、またDockerイメージが重くなる。(pnpmだと回避できたりするのだろうか?)

導入難易度の高さ

世の中にまだあまり知見がないので、初心者にはややとっつきづらいですね。
こちらに解説をまとめたので、良かったらどうぞ!

https://zenn.dev/katsumanarisawa/articles/b98ba340a218af

モノリス vs モジュラーモノリス vs マイクロサービス

モジュラーモノリスは「モノリスよりも分割されていて、マイクロサービスほど分割されていない」ようなアーキテクチャで「ちょうど良い」ものだと感じます。マイクロサービスだとサービス分割したときの不可逆性が強いが、モジュラーモノリスだと気軽にモジュールを作れて良いですね。
この辺の詳細な解説・考察は他の記事に任せて、ここでは感想程度に留めておきます。

冒頭から述べているとおり、モジュラーモノリスは銀の弾丸ではありません。事業や組織体制によっては、モノリスやマイクロサービスが良いこともあるでしょう。マイクロサービスが抱える負をモジュラーモノリスが全て解決するようなものではありません。

とはいえ、有効な選択肢の1つとして常に持っておくべきです。一定規模になることが見込まれるソースコードは、早くからモジュール化しておくと、その後自然とモジュールが増えていく(ないし、必要ないなら増えない)力学が働いて良いと思っています。

SALESCOREテックブログ

Discussion

鷹勇鷹勇

面白い記事でした
ありがとうございます。

当方、似たような技術スタックでディレクトリの構成について悩んでおりまして
”設計”とか、”アーキテクチャ”なんて書かれた本を開くと
モジュールという言葉が当たり前のように出てきます。

Katumaさんはどのようにこのモジュールという単位を使われていますか?

「ディレクトリで区切る」のと「モジュールで区切る」のはどう違うのか?大して変わらないのでは?と思う人もいるかもしれませんが、それを同じだと言うのは「1ファイルに1000行書くのも、100行を複数ファイル分割するのも、本質的には一緒である」と言うのと同じようなものです。
ソフトウェア開発は、一定の自由を捨てて制約を受け入れることによりむしろ効率的な開発が出来るようになるという側面がありますが「モジュールの外からは、モジュールがexportしている関数しか参照できない」という制約を持ち込むことで、思考の幅が狭まり、見通しの良い開発ができるようになります。

いわゆる、カプセル化や隠蔽化のためにモジュールという単位を使われているように思います。
Java などは細かなメソッドやクラスなどの公開設定(public,proteted,private など)ができるのですが
JSではそういった細かな設定ができず、”モジュール”という単位がどういう単位なのか?”ファイル”とはどう違うのだろうか?という疑問をもっています。
ご回答いただけると大変嬉しく思います。

鷹勇鷹勇

GraphQLのResolverで return db.user.findMany()とか書けばいいだけの処理を、わざわざ他のレイヤーを用意して冗長に書く意義は薄いでしょう。

誤解でしたらすいません。
これだと、この単純なリゾルバーにはテストを書かない、書く意味が薄いということですかね?
それとも、実行時にprisma client をDIしてるってことですかね?

Katsuma NarisawaKatsuma Narisawa

コメントありがとうございます!

Katumaさんはどのようにこのモジュールという単位を使われていますか?

一般的な意味では、モジュールは「プログラムのまとまり」くらいの意味しかない認識でいます。
その意味での「まとまり」が指すものは自由だと思いますが、JavaScriptにおけるそれはYarn(NPM) Workspaceにより定義されるworkspaceの単位がふさわしいと考えています。
公開設定については、package.jsonでexportするファイルを設定できます。
(厳密には、exportしてないファイルも参照できちゃうのですが、これはeslintで防いでいます)
この記事における定義はこちらですね。

狭義の意味でJavaScriptにおいては、Moduleは require() できる単位、すなわちファイルを指しているように見えます。(この辺、詳しくないので間違っていたらすみません)
https://nodejs.org/api/packages.html#modules-loaders

これだと、この単純なリゾルバーにはテストを書かない、書く意味が薄いということですかね?

もちろんサービスの規模やその他条件によりますが、自分だったらテストを書かないかなと思いますし、書く意味は薄いと感じています!