NestJSによるモジュラーモノリス:マイクロサービスの代償を払わずにスケーラブルなアーキテクチャを構築する

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

午前2時のマイクロサービス事後検証(ポストモーテム)

火曜日の午前2時、私のスマホは執拗に通知を刻んでいました。PagerDutyはチェックアウトフローでの500エラーの急増を報告しており、収益が完全にストップしていました。6つの異なるリポジトリにまたがる分散ログを40分間必死に調べた結果、ようやく犯人を見つけました。Discount ServiceUser Serviceとの認証に失敗していたのです。原因はサービスメッシュ内の一時的な503エラーでした。同時実行ユーザー数が500人未満のアプリケーションのために、私たちは15ものマイクロサービスを管理していたのです。

私たちは過剰なエンジニアリング(オーバーエンジニアリング)によって、自らを袋小路に追い込んでいました。マイクロサービスの流行を追いかけた結果、シンプルなコードを、高価で脆弱なインフラと引き換えにしてしまったのです。あの夜、一つのことが明確になりました。ほとんどのチームに必要なのは、独立したデプロイ単位ではなく、強制された境界線なのだということです。この気づきが、私たちのチームをモジュラーモノリスへと立ち返らせました。

アーキテクチャを賢く選択する

なぜこのアプローチが有効なのかを理解するために、バックエンド設計のスペクトラムを見てみましょう。一方の端には、しばしば「泥だんご(Big Ball of Mud)」と呼ばれる伝統的なモノリスがあります。ここではすべてが絡み合っています。Userエンティティのプロパティを一つ変更するだけで、Invoiceジェネレーターが壊れる可能性があります。同じデータベースコンテキストとグローバルな名前空間を共有しているからです。

反対の端にあるのがマイクロサービスです。完全な隔離を実現できる一方で、重い代償を伴います。デプロイの複雑さ、ネットワーク遅延、そしてデータ整合性の悪夢に直面することになります。Sagaパターンや分散トランザクションの管理は、それ自体がフルタイムの仕事になるほどです。

モジュラーモノリスは、実用的な中間案を提供します。単一のコードベースと一つのデプロイ単位を維持しながら、厳格な論理的分離を強制します。各モジュールは自己完結しています。もし画像処理のような特定の機能に将来的に極端なスケーリングが必要になったとしても、数ヶ月ではなく、ある日の午後にそのモジュールをマイクロサービスとして切り出すことができます。境界線はすでにそこにあるからです。

メリットとデメリット:現実的な評価

私はこのアーキテクチャを、スタートアップから中堅企業まで、さまざまな本番環境で導入してきました。結果は通常安定していますが、すべての問題を解決する魔法の杖ではありません。トレードオフを検討する必要があります。

メリット

  • 効率的なデプロイ: 管理するのは1つのCI/CDパイプラインと1つのDockerイメージだけです。単一のGitHub Actionで、スタック全体を3分以内にデプロイできます。
  • 生のパフォーマンス: モジュール間はインメモリの関数呼び出しで通信します。これにより、HTTPやgRPCの内部呼び出しで一般的な20ms〜50msのオーバーヘッドが解消されます。
  • 優れた開発者体験: チームは標準的な16GB RAMのラップトップでシステム全体を実行できます。CSSのバグを直すためだけに20個のコンテナを起動する必要はもうありません。
  • 型安全性: TypeScriptコンパイラが第一防衛線として機能します。開発中にモジュールをまたぐ破壊的変更を即座にキャッチできます。

デメリット

  • 運命共同体: もしReporting Moduleメモリリークが利用可能なRAMをすべて消費してしまえば、Payment Moduleも一緒にクラッシュします。
  • リソースの競合: すべてのモジュールが同じCPUプールを共有します。アプリケーション全体をスケールさせずに、Search Moduleだけに計算リソースを追加することは容易ではありません。

NestJSで成功するための設計図

NestJSはこのパターンに適した設計になっています。そのモジュールシステムは、境界線を引くための完璧なフレームワークを提供します。しかし、標準のnest generate moduleコマンドは出発点に過ぎません。「スパゲッティ・インポート」を防ぐには、構造化されたディレクトリ戦略が必要です。

以下は、プロダクショングレードのモジュラーモノリスに最適化されたフォルダ構造です。


src/
├── modules/
│   ├── orders/
│   │   ├── domain/ (ビジネスロジック)
│   │   ├── infrastructure/ (DBスキーマ、リポジトリ)
│   │   ├── presentation/ (コントローラー)
│   │   ├── orders.module.ts
│   │   └── index.ts (パブリックAPI)
│   ├── users/
│   └── payments/
├── common/ (共有デコレータ、フィルタ)
└── main.ts

各モジュールのindex.tsファイルはゲートキーパー(門番)です。これは「パブリックAPI」として機能します。他のモジュールは、ここで明示的にエクスポートされたものだけをインポートしなければなりません。もし開発者が../users/infrastructure/user.repository.tsのようにモジュールの深部を直接参照したら、それはアーキテクチャに違反しており、技術的負債を生み出していることになります。

イベント駆動ロジックによる疎結合化

モノリスを台無しにする一番の近道は、モジュールAがモジュールBのプライベートサービスを直接呼び出すことを許可することです。これは密結合を生み出し、将来の変更を不可能にします。代わりに、モジュール間通信には内部イベントを使用しましょう。

1. イベントエミッターのインストール

NestJSは軽量なインメモリ・イベントバスを提供しています。

bash
npm install --save @nestjs/event-emitter

2. モジュールの登録

typescript
// app.module.ts
@Module({
  imports: [
    EventEmitterModule.forRoot(),
    UsersModule,
    OrdersModule,
  ],
})
export class AppModule {}

3. イベントの発行

ユーザーが登録されたとき、ウェルカムメールの送信や請求プロファイルの初期化が必要になるかもしれません。UsersServiceが他の3つのサービスに依存するのではなく、単にメッセージをブロードキャストします。

typescript
@Injectable()
export class UsersService {
  constructor(private eventEmitter: EventEmitter2) {}

  async create(dto: CreateUserDto) {
    const newUser = await this.userRepo.save(dto);
    
    this.eventEmitter.emit('user.created', {
      userId: newUser.id,
      email: newUser.email,
    });

    return newUser;
  }
}

4. イベントのハンドリング

EmailModuleはこのイベントをリスン(待機)します。UsersModuleは誰がリスンしているかを全く知る必要がなく、ロジックを疎結合でクリーンに保つことができます。

typescript
@Injectable()
export class NotificationsListener {
  @OnEvent('user.created')
  handleUserCreatedEvent(payload: UserCreatedPayload) {
    // SendGridやAmazon SESをトリガーするロジック
    console.log(`ウェルカムメールをキューに追加中: ${payload.email}`);
  }
}

境界線強制の自動化

手動のコードレビューでは、いつか見落としが発生します。長年の開発にわたってモジュール性を維持するには、NxSheriffのようなツールを使用します。これらにより、厳格な依存関係ルールを定義できます。例えば、「’Payments’モジュールは決して’Admin’モジュールからインポートしてはならない」といったルールを設定できます。

もし開発者が誤ってその境界線を越えた場合、リンターがエラーを出します。プルリクエスト(PR)はマージできません。この自動化により、チームが2人から20人に増えても、アーキテクチャをクリーンに保つことができます。

最後に

モジュラーモノリスを選択することは、怠慢ではありません。速度とメンテナンス性を優先するための実用的な決断です。このアプローチにより、素早く動きながらコードをクリーンに保つことができます。マイクロサービスの膨大なオーバーヘッドは、トラフィック(そしてDevOpsの予算)が実際にその移行を正当化するまで先送りにすればよいのです。

次のプロジェクトを始める前に、いくつのサービスを作るべきか悩むのはやめましょう。どのように境界線を定義するかに集中してください。将来の自分は、十分な睡眠が取れることに感謝するはずです。

Share: