Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

TypeScript開発にモジュラーモノリスを持ち込む

Bill One Entry*1秋山です。

本題へ入る前にお知らせです。12/23、TypeScript を活用した型安全なチーム開発をテーマにイベントを開催します。弊社社員のうち、TypeScript を日々の開発で活用しているメンバーが登壇します。ぜひお気軽にご参加ください。

sansan.connpass.com

はじめに

書籍『ソフトウェアアーキテクチャの基礎』によれば、ソフトウェアには満たすべきアーキテクチャ特性(「非機能要件」とも呼ばれるもの)があり、中でもモジュール性はすべてのソフトウェアで暗黙的に要求される特性だといいます。

モジュール性は、ほとんどのアーキテクトが気にしている暗黙のアーキテクチャ特性だ。というのも、モジュール性がうまく維持されないと、コードベースの構造が損なわれる可能性があるからだ。そのため、アーキテクトはモジュール性の確保に、高い優先度を置かなくてはならない。

『ソフトウェアアーキテクチャの基礎』 Mark Richards, Neal Ford

モジュール性を確保する目的の1つに保守性の向上があります。後ほど確認しますが、一般に保守性はシステムの成長とともに低下するため、長く運用されているシステムほど、単位時間当たりに実装できる機能数は減少する傾向にあります。

この記事では、モジュール性を高める手法としてモジュラーモノリスと呼ばれるアーキテクチャを取り上げます。前半ではモジュラーモノリスの必要性をいくつかの側面から確認し、後半では実際に私達のチームで導入した事例を紹介します。

図1. アーキテクチャ特性の一部(引用:『進化的アーキテクチャ』

モジュラーモノリスとは

書籍『モノリスからマイクロサービスへ』の定義を引用します。

モジュラーモノリスとは、単一プロセスが別々のモジュールで構成され、それぞれ独立して作業できるものの、デプロイのために結合する必要があるシステムだ。

『モノリスからマイクロサービスへ』 Sam Newman

ここでいう「プロセス」は、文字通りプログラムの実行単位としての「プロセス」と解釈するのが自然ですが、「システム」や「バイナリ」と読み替えても差し支えないでしょう。

多くのモノリシックアプリケーションは何らかのレイヤードアーキテクチャを採用しています。レイヤードアーキテクチャとは、プレゼンテーション層やインフラ層などのように、レイヤーと呼ばれる技術的関心でシステムを分割する手法を指します。MVCに代表される3層アーキテクチャのほか、クリーンアーキテクチャやオニオンアーキテクチャなども、レイヤードアーキテクチャの一種と見なせます。

図2. 典型的なレイヤードアーキテクチャの模式図

小規模なアプリケーションであれば、レイヤードアーキテクチャだけで十分に機能します。しかしコードベースのサイズが一定規模を超えると、ある変更がコードのほかの箇所に及ぼす影響の予測が難しくなり、保守性が低下します。

レイヤードアーキテクチャが、図2の通りシステムを水平分割する手法だとすれば、モジュラーモノリスはコードベースを機能によって垂直分割する手法と言えます。

図3. モジュラーモノリスアーキテクチャの模式図

これにより、レイヤードアーキテクチャで肥大化した各レイヤーを縦方向に分割できます。各モジュールはインタフェースを持っており、モジュールの外から呼び出せる関数やクラスは限定されます。その結果、コード変更による影響範囲を特定しやすくなり、保守性の向上につながります。

保守性が低いとビジネスに悪影響を与える

モジュラーモノリスがコード変更の影響範囲を狭め、保守性を向上させることは分かりました。ではなぜ保守性を高める必要があるのでしょうか。

技術的負債と開発生産性

図4. 技術的負債と開発生産性(引用:『ソフトウェアアーキテクチャメトリクス』

保守性が低い状態とは、技術的負債が溜まった状態とも言い換えられます。図4は、技術的負債が積み重なると、単位時間あたりに実装できる機能数が減少する、つまり開発生産性が低下することを表しています。一定の経験を積んだエンジニアの中には、ある機能を実装する際に、事前のリファクタリングをセットで実施している方も多いと思います。

図4では、継続的な改善を実施し、開発生産性が下がらないよう、技術的負債の量をコントロールする重要性を説いています。

技術的負債の量が増え続け、一定の閾値を超えると、もはや機能開発は不可能になり、そのソフトウェアはリプレース対象になります。

コード品質とビジネス影響

図5. コード品質と開発に必要な時間(引用:『Code Red: The Business Impact of Code Quality』

こちらのブログで話題になった2022年の論文『Code Red: The Business Impact of Code Quality – A Quantitative Study of 39 Proprietary Production Codebases』では、39個のコードベースを解析し、コード品質がどのようにビジネスインパクトを生むのか調査しています。

調査では、CodeSceneという静的解析ツールを使い、コード品質を1.0(低品質)〜10.0(高品質)で評価しています。調査の結論は次の通りです。

  • 低品質なコードは高品質なコードに比べ、発生する不具合の件数が15倍
  • 低品質なコードは高品質なコードに比べ、開発時間が平均2倍(図5)。
  • 低品質なコードは高品質なコードに比べ、開発時間が最大9倍

コード品質が高いコードは、開発生産性が高く障害発生率も低いという結果ですが、これは多くのエンジニアの経験的な感覚と一致していると思います。

モジュール分割の方針

ではどのようにモジュールを分割をすればよいのでしょうか。私たちのチームでは次の3つの方針を立て実践しました。

方針1:モジュールにDBテーブルを専有させる

モジュールはドメインでの分割がセオリーですが、私たちもそれに従いコードベース上の関数やクラスを整理しています。これにより、コードベース上の関数やクラスを変更した際の影響範囲をモジュールに閉じ込められるようになります。一方、これだけでは解決できない問題として、DBスキーマの変更があります。

例えば、2つのモジュールが1つのDBテーブルにアクセスしている場合、そのテーブル構造に変更があると、それら2つのモジュールにも修正が必要になります。そのため、チームではモジュールにDBテーブルを占有させる方針を立てました(図6)。

図6. あるDBテーブルにアクセスするモジュールは1つまで

ここではユーザーモジュールと通知モジュールを使い具体例を挙げます。

/**
 * ユーザーモジュール
 * modules/user/index.ts
 */ 
import { db } from "shared/db/client"

// 全てのユーザーを取得する
export function getActiveUsers(): Promise<User[]> {
  const allUsers = await db.users.all(); // ユーザーテーブルにアクセス
  return allUsers;
}

/**
 * 通知モジュール
 * modules/notification/index.ts
 */ 
import { db } from "shared/db/client"

// 全てのユーザーに通知を送る
export function sendToAll(message: string): Promise<void> {
  const allUsers = await db.users.all(); // 同じくユーザーテーブルにアクセス
  await Promise.all(allUsers.map((user) => notifyUser(user, message)));
}

ユーザーモジュールと通知モジュールはいずれも、DBから全ユーザーを取得しています。しかしユーザーテーブルに対し、ユーザーがアクティブかどうかを type: "activated" | "deactivated" という列挙型で管理するスキーマ変更が入った場合はどうでしょうか。次のような修正が必要になります。

/**
 * ユーザーモジュール
 * modules/user/index.ts
 */ 
import { db } from "shared/db/client"

// 全てのアクティブユーザーを取得する
export function getActiveUsers(): Promise<User[]> {
  const users = await db.users.where("type", "activated").all(); // 検索条件を追加
  return users;
}

/**
 * 通知モジュール
 * modules/notification/index.ts
 */ 
import { db } from "shared/db/client"

// 全てのアクティブユーザーに通知を送る
export function sendToAll(message: string): Promise<void> {
  const users = await db.users.where("type", "activated").all(); // 検索条件を追加
  await Promise.all(users.map((user) => notifyUser(user, message)));
}

DBスキーマの変更に対応するため、ユーザーモジュールだけではなく、通知モジュールにも変更を加える必要があります。小さなコードベースであれば、変更が必要な箇所を調べることは難しくありませんが、一定規模のコードベースでは難しくなります。そこでユーザーテーブルへのアクセスはユーザーモジュールに占有させます。

/**
 * ユーザーモジュール
 * modules/user/index.ts
 */ 
import { db } from "shared/db/client"

// 全てのアクティブユーザーを取得する
export function getActiveUsers(): Promise<User[]> {
  const users = await db.users.where("type", "activated").all();
  return users;
}

/**
 * 通知モジュール
 * modules/notification/index.ts
 */ 
// 全てのアクティブユーザーに通知を送る
export function sendToAll(message: string): Promise<void> {
  const users = await getActiveUsers(); // ユーザーモジュールの関数を使う
  await Promise.all(users.map((user) => notifyUser(user, message)));
}

あるDBテーブルへアクセスできるのは単一のモジュールに限定することで、DBスキーマの変更に強い設計になります。

もし結合度の概念に馴染みのある方であれば、複数モジュールが1つのDBテーブルにアクセスする状態は、結合度における共通結合であると思い至るかと思います。結合度は広い概念で、関数、クラス、マイクロサービスなど多様な文脈で使われます。図7は、モジュラーモノリスの文脈に限定し、モジュール間の結合度の概念モデルを定義した図です(結合度の定義には諸説あります)。

図7. モジュラーモノリスにおけるモジュール間結合の概念モデル

DBスキーマを変更した際、変更を必要とするモジュールが複数個あれば、それらのモジュールは共通結合に当たります。モジュールにDBテーブルを占有させる方針は、この共通結合を「よりマシな結合度」に下げるためのものです。

補足として、共通結合が解消されたあとの上記のコードで、2つのモジュールはメッセージ結合(引数のない呼び出し)になっています。これは最良の例ですが、現実的には制御結合やスタンプ結合などもよく現れます。

補遺:モジュラーモノリスとNoSQL

上記で「DBテーブル」と書きましたが、私たちのチームではDBにFirestoreを採用しています。そのため正確には「あるDBコレクションにアクセスできるのは単一のモジュールに限定する」という記述が正しいですが、この記事では分かりやすいようDBテーブルと表現します。MySQLにおけるテーブルとレコードは、Firestoreではコレクションとドキュメントに相当します。

NoSQLに分類されるFirestoreにはJOINの仕組みがありません。普段の開発の中で、JOINがないことによる不便さを感じるときはありますが、JOINがないという制約はモジュラーモノリスとかなり相性が良いことに気が付きました。

例えばテーブルAとテーブルBがあり、それぞれモジュールaとモジュールbが専有する構成にしたいとします。このとき、テーブルAとテーブルBをJOINするクエリがあったとき、そのクエリのJOINを解き2つのクエリに分解する必要があります。

JOINがないという制約のお陰で、このような難所に遭遇することなくモジュール化を進められているため、NoSQLを採用していて良かったと思えたのでした。

方針2:モジュール内をレイヤードアーキテクチャとして構成する

既存のコードベースは次のようにレイヤードアーキテクチャとして構成されていました。

📁 src/
    ┣ 📁 domain/ ドメイン
    ┃    ┣ 📁 models/ ドメインモデル
    ┃    ┗ 📁 services/ ドメインサービス
    ┣ 📁 services/ アプリケーションサービス
    ┗ 📁 repositories/ レポジトリ

この構成にメンバーは慣れているため、実装時に何をどこに置くのかという迷いはありません。そのため、モジュラーモノリス後の各モジュールフォルダも同様のフォルダ構成にしました。

📁 src/
    ┗ 📁 modules/ モジュールフォルダを格納
          ┣ 📁 user/ ユーザーモジュール
          ┃    ┣ 📁 domain/
          ┃    ┃     ┣ 📁 models/
          ┃    ┃     ┗ 📁 services/
          ┃    ┣ 📁 services/
          ┃    ┣ 📁 repositories/
          ┃    ┗ 📝 index.ts
          ┗ 📁 notification/ 通知モジュール
               ┣ 📁 domain/
               ┃     ┣ 📁 models/
               ┃     ┗ 📁 services/
               ┣ 📁 services/
               ┣ 📁 repositories/
               ┗ 📝 index.ts 

モノリスのうちどの処理をモジュールに括りだすかという問題は、しばしばチーム内ですり合わせをしています。しかしそれさえ決まれば、モジュールの中のファイルの置き方は従来通りの方法で済み、すり合わせが不要というメリットがありました。

各モジュールフォルダの直下には index.ts があり、このファイルから export されているものだけをモジュール外から import 可能という条件にしています。

方針3:ESLint ルールによって実現する

モノリスをモジュール分割するために使えるツールにはさまざまなものがあります。代表的なものを並べます。

  • パッケージ管理ツールの workspaces 機能
    • e.g., npm, yarn, pnpm
  • モノレポ管理ツール
    • e.g., Turborepo, Nx
  • Linter
    • e.g., ESLint, Biome

比較検討したのち、私たちのチームでは ESLint のルールでモジュール分割することにしました。私たちがモジュラーモノリスで実現したかった目標は次の2点です。

  1. モジュールが明示的に export しているものだけを import できる
  2. レイヤードアーキテクチャの各レイヤーの依存方向に制約を設ける
    (e.g. domain が services に依存してはならない)

これらはいずれも静的解析で実現できるもの、つまり Lint ツールだけで達成できるはずです。

npm workspaces などは基本的にパッケージを分割するもので、package.json をモジュールの個数分だけ管理するなど、上記2つの目標を達成するためだけであれば不要な管理コストが生まれます。また workspaces の ラッパーであるTurbrepoも同様に、目標に対して過剰なツールです。

workspaces やモノレポ管理ツールは、独立して機能するパッケージを管理する場合に適しています。一方、モジュラーモノリスはあくまで単一プロセスとして動作するシステムの構成技術であるため、静的解析を超えるツールは不要な複雑性を持ち込むと判断し見送りました。

TypeScript 開発にモジュラーモノリスを持ち込む

ここからはチームでどのようにモジュラーモノリスを導入したか、ステップに分けて紹介します。

ステップ1:単一のエイリアスを設定する

当初、モジュールには個々のエイリアスを設定する案もありました。

import { getActiveUsers } from "@user"; // ユーザーモジュール
import { sendToAll } from "@notification"; // 通知モジュール

しかしこの場合、モジュールを1つ作る度にESLint, TypeScript, Babel, Jest などのツールにエイリアスを設定する必要があります。このような設定は煩雑でヌケモレをなくすことが難しく、モジュール作成のハードルにつながることを懸念しました。

そこでエイリアスは @m/*src/modules/* に解決される)だけを追加し、モジュール作成時にそれらのツールの設定ファイルを触る必要がないようにしています。これによりモジュールを作成する場合は、フォルダを1つ作るだけで済むようになりました。

import { getActiveUsers } from "@m/user"; // ユーザーモジュール
import { sendToAll } from "@m/notification"; // 通知モジュール

ステップ2:ESLint ルールを設定する

前述の通り、モジュラーモノリスの実現にはESLintを用いることにしました。そのために下記2つのLintルールを設定しています。

  1. no-restricted-paths:モジュール内の依存関係を制約する
  2. 自作ルール:モジュール同士の依存関係を制約する

no-restricted-paths ルールは eslint-plugin-import プラグインが提供するルールの1つで、フォルダ単位で import 文に制約を設定できます。今回はモジュール内のフォルダ同士の依存関係の制約に利用しています。次のような設定を加えています。

"import/no-restricted-paths": [
  "error",
  {
    zones: [
      // domain/ から repositories/ を呼べないように
      {
        target: ["./src/modules/*/domain/**"],
        from: ["./src/modules/*/repositories/**"],
      },
      // domain/ から services/ を呼べないように
      {
        target: ["./src/modules/*/domain/**"],
        from: ["./src/modules/*/services/**"],
      },
      // 略...
    ],
  },
]

これによって、モジュール内で本来は許されない依存方向の発生を防止しています。

no-restricted-paths は便利なルールですが、モジュールフォルダの index.ts 以外のimport禁止、つまり import { ... } from "@m/<mod-name>" 以外の import を禁止する 設定はできませんでした。具体的には次のような制約を設ける必要があります。

/**
 * ユーザーモジュール内のファイル
 * src/modules/user/services/hoge.ts
 */

// ✅ 別のモジュールの index.ts から import する
import { sendToAll } from "@m/notification";

// ✅ 同じモジュールからファイルを import する
import { getActiveUsers } from "@m/user/domain/user.ts";

// ⛔️ 別のモジュールの index.ts 以外から import する
import { notification } from "@m/notification/domain/notification";

これを定式化すると、次のAND条件にマッチした場合、エラーを返すLintルールを作れば良いことになります。

  1. モジュール外からの import 呼び出しである
  2. import の from が @m/<mod-name> 以外である

これだけの条件であれば、ルールを自作することは難しくありませんでした。

なお自作ルール以外の選択肢として、uhyo氏の eslint-plugin-import-access プラグインも挙がりました。これはJSDocによるアノテートを使うことで、export文の公開範囲を設定できる便利なプラグインです。しかしアノテートによる設定は自由度が高く、今回の私たちのユースケースとしては過剰に感じられたため、採用は見送りました。上記のように、チームではフォルダ構造で制約を課しているため、新たにアノテート文を書く必要性が薄かったことも大きな理由です。

ステップ3:モジュールに切り出す

あとはモノリスなコードベースを徐々にモジュールに切り出していくだけです。チーム内で新規モジュールの作成は強く推奨されており、何らかの機能変更がある場合、その前段としてまずは関連処理をモジュール化できないかを検討します。

モジュラーモノリスの先行事例としては、巨大なモノリスとなったRailsアプリケーションを対象としたShopifyの事例が有名です。

shopify.engineering

Shopifyでは、事前にコードベースを概念(原文では"Concept")単位で分類しており、これはモジュールのリストだと読み替えられます。そしてすべてのRubyクラスに対し、概念(=モジュール)を手作業でラベル付けし、大規模なファイル移動を行ったそうです。

私たちチームでもモジュールリストの作成を試みましたが、私たちのモジュール化の方針には、「概念による分離」にプラスし、「DBの論理的な分離」という制約があります。これらを事前に考慮しながら各ファイルにモジュールをラベル付けしていく作業には、大きな工数を要すると分かったため取り止めました。その代わり、APIの全エンドポイントにモジュールをラベル付けし、それを参考にモジュールを分割しています(図8)。

図8. APIエンドポイント毎にモジュールをラベル付けしている

成果

モジュラーモノリスの効果測定の一貫として、リードタイム(ここではPRのファーストコミットの作成〜PRマージまでの時間)を計測しています。図9の横軸が時間軸、縦軸がリードタイム、グラフ上に書かれた文字が新規モジュールの名称です。星印がモジュール化を取り組み始めたタイミングです。

リードタイムは多くの変数があるため、すべてがモジュラーモノリスの効果とは言えませんが、モジュール化の取り組みから徐々にリードタイムが改善しているほか、大きなアップダウンがなくなっています。

図9. モジュラーモノリス導入前後のリードタイムの推移

また個人的な体感として、「コード上の変更」と「DBスキーマ上の変更」がモジュールに閉じているため、実装業務上の認知負荷が下がっており、開発体験が改善している感覚があります。

図4で継続的なアーキテクチャ改善の重要性について確認しました。モジュラーモノリスの仕組みが整ったことで、何らかの機能開発のタイミングで、まずはモジュール化によるリファクタリングを検討する文化が定着しつつあります。

図4. 技術的負債と開発生産性(再掲)(引用:『ソフトウェアアーキテクチャメトリクス』

おわりに

この記事の前半では、コード品質の重要性をいくつかの文献の引用で確認しました。後半では、コード品質を改善する方法としてモジュラーモノリスをチームに導入した事例を紹介しました。以下、この記事の要点をまとめます。

  • 経験的に知られていたコード品質が持つビジネスインパクトを、裏付けるような研究が行われている
  • コード品質を担保する手法としてモジュラーモノリスがある
  • モジュールを括りだすガイドラインとして次の2つを設定した
    • (A) ドメインで分離する
    • (B) DBデータを占有するように分離する
  • 各モジュールはレイヤードアーキテクチャで構成した
  • NoSQLは、モジュールにDBデータを占有させる方針と相性が良い
  • TypeScriptにおけるモジュール化には静的解析が使え、Lintルールの設定だけでも機能する

この記事の内容を 12/23 の弊社LT会でお話します。是非お気軽に起こしください。

sansan.connpass.com


記事の作成に当たり、Digitization部の 大森 夢拓, 薩田 和弘, 小田 崇之, 清水 勇祐 に助言をもらいました。

参考文献

*1:クラウド請求書受領サービス「Bill One」が提供するデータ化機能。

© Sansan, Inc.