🦾

モジュラーモノリスのモジュール間連携

2024/12/02に公開

はじめに

この記事ではモジュラーモノリスを採用した際に悩んだ、アーキテクチャとモジュール間連携について紹介します。

モジュラーモノリスを採用するに至った技術選定の経緯は別の記事 苦しんでたどり着いたモジュラーモノリス に記載したので、そちらをご覧ください。こちらでは、モジュラーモノリスの具体的な実装の話をします。

その中でも、モジュール間の連携の方法についてはいろいろ工夫したところなので、そこにフォーカスして解説していければと思います。

この記事におけるモジュラーモノリスの前提

モジュラーモノリスの定義については、いろんな記事で論じられています。前提の認識がズレると何の話をしてるか見えにくくなるので、この記事での簡単な定義づけをします。

特徴 モノリス モジュラーモノリス(共有DB) モジュラーモノリス(分離DB) マイクロサービス
コードベース 単一のコードベース モジュール分割された単一コードベース モジュール分割された単一コードベース 完全に分離されたコードベース
技術スタック 単一 モジュール内で一部変更可 モジュール内で変更可 サービスごとに自由
通信方式 直接メソッド呼び出し プロセス内API/イベント プロセス内API/イベント プロセス間API/イベント
デプロイ単位 一括デプロイ 一括デプロイ 一括デプロイ 個別デプロイ
スケーリング アプリケーション全体 アプリケーション全体 アプリケーション全体(DBは個別可) サービス単位
データベース 単一DB 単一DB モジュールごとの独立DB サービスごとの独立DB
トランザクション 単一トランザクション 単一トランザクション 分散トランザクション 分散トランザクション
データ整合性 強い整合性 強い整合性 結果整合性 結果整合性
保守性 低い(大規模時) 中程度 高い 高い
運用の複雑さ 低い 中程度 高い 非常に複雑
モニタリング シンプル やや複雑 複雑 非常に複雑
インフラ管理コスト 低い 低い 中程度 高い
障害の影響範囲 全体に影響 全体に影響 全体に影響(DB障害は局所的) 局所的
チーム構成 1チーム 1チーム〜モジュール単位のチーム 1チーム〜モジュール単位のチーム 1チーム〜サービス単位のチーム
開発の独立性 低い 中程度 高い 非常に高い
初期開発速度 速い 中程度 中程度 遅い
適したプロジェクト規模 小~中規模 小~大規模 中~大規模 大規模

主観満載でとても大雑把な分類ですが、当記事ではこの前提で進めていきます。

アーキテクチャ説明

私たちが作ったのはモジュラーモノリス(共有DB)です。1つの共有DBを扱い、APIサーバーとして外部公開用のインターフェースを持ってます。ディレクトリ構成に踏み込むと「レイヤードアーキテクチャ + モジュラーモノリス(共有DB)」と表現した方がわかりやすいかもしれません。

まずはモジュールの構成を把握していただくために、レイヤーごとの責務を説明します。アーキテクチャは次の図のようになっています。

実装上はインターフェースを定義して抽象と具象をレイヤーごとに区別して管理し、依存性逆転してます。ですが、それらは理解されてる方が多いと思うので、簡易的に処理の流れを矢印で表現しました。

それぞれ簡単に紹介します。

アプリケーション層

ビジネスロジックを担うレイヤーです。
DDDやクリーンアーキテクチャではアプリケーション層とドメイン層で分かれて説明されていますが、ここでは簡略化のためにひとまとめにアプリケーション層に置いてます。

私たちのチームでも、事業の根幹になるコアドメインは戦略的DDDにしっかり取り組んで、設計と実装のループでモデルを磨いています。

ただ、全てのドメインでそれをやる時間はないので、コアドメイン以外のところでは、EntityとDBのモデルがほぼ同じような実装になっています。ディレクトリ構成だけ戦術的DDDに従った構成にしつつ、ビジネスロジックをUsecaseに実装するようなイメージです。

これがDDDで言われている、「コアドメインに注力する」を再現する形なのかなと思ってます。

この記事ではDDDについて話したいわけではなく、あくまでもモジュラーモノリスにおけるモジュール間の連携について紹介したいので、ここでは便宜上アプリケーション層としてひとまとめに表現します。

Scenario

聞き馴染みのない概念かと思います。

一言で表現すると、Usecaseを束ねる存在です。

Scenarioがこの記事の一番面白いところになるので、後ほどしっかり説明します。

Entity

DDDのEntityです。
IDを持って永続化されるものです。コアドメインではIDを持たずに値オブジェクトとして扱うものもあります。コアドメイン以外のところでは値オブジェクトは作らず、単にEntityの1つの要素として持たせることで、余計な設計の時間を取らないようにしてます。

Usecase

アプリケーション固有のロジックです。
業務ユースケースを持つものなので、詳しい説明は不要かと思います。

Repository

Entityを永続化するものです。
DBへのマッピングもRepositoryで行います。私たちはORMはRepositoryの中に留めて、外部にDBの詳細が出て行かないようにしてます。Repositoryのinput/outputにはEntityを扱うようにし、永続化の詳細は隠してます。

QueryService

Repositoryの単純なCRUD操作ではカバーできない検索を担当します。

ここは少しオリジナルな思想を入れているところになりまして、QueryServiceのレスポンスはIDだけにしてます。DDD関連の記事では、ドメインモデルを組み合わせたデータの表現をするためにQueryServiceを使う例を見かけますが、私たちはそういう使い方はしてません。
あくまでも検索条件を扱うためだけに使用しています。こうすることでRepositoryでデータを取得する時にはIDだけを引数に受け取る形になるので、IDを使った永続化という責務に絞ることができています。

またアプリケーションの一覧画面を表示するときに必要になるページネーションのロジックもこのQueryServiceで担う形にしています。

Adapter

外部サービスとの接続を担当します。
これはヘキサゴナルアーキテクチャの概念を取り入れてます。DDDのRepositoryの概念を拡張して、Repositoryで外部サービスに繋ぐことを許容することも選択肢にはあります。ただちょっとRepositoryの役割がぶれてしまうかなという考えから、私たちはAdapterを採用することにしました。実際やってることはインターフェースを定義して、そこに依存注入しているのでRepositoryと似たようなものです。
ポート&アダプターとして別名を割り当てているだけと認識してください。

プレゼンテーション層

APIリクエストを受け付けるところです。

Handler

APIリクエストを受け付けるところです。
リクエストで受け取った値を変換して、ScenarioやUsecaseに渡します。

私たちのアプリケーションでは、Protocol Buffers[1] を使って、APIを定義しています。Connect[2] も使っているため、HTTPとgRPCのリクエストを受け付けられるようになってます。

ここも面白いところなのですが、本題から外れるので他の記事を調べてみてください。

ここまでで簡単なアーキテクチャの説明は終えて、次からScenarioをからめてモジュール間連携の話をしていきます。

モジュール間の連携

一般的なモジュール間結合の問題点

一般的にDDDやマイクロサービスの文脈では、コンテキストの間には明確な境界があり、コンテキストを跨いだトランザクションを管理するためには、結果整合性をとる仕組みが提案されています。分散トランザクションでは不具合の原因になる可能性があるので、TCCパターンやSagaパターンなどで結果整合性を担保しましょうというアイデアです。最初に分類したパターンだとモジュラーモノリス(分離DB)です。

これは理想ベースではベストな方針だと思います。

ですが、現実としては補償トランザクションやロールバックの仕組みを作るために工数を割くことができず、運用でカバーすることが多々あるのではないでしょうか。一部のリソースが潤沢にある企業では結果整合性を担保する仕組みが整っていることでしょう。ただ、多くの企業でエンジニアは不足していて、ビジネス成功のために全速力で機能開発しています。そのため、技術的に理想的なトランザクションのためにかける時間は余っていないのです。

マイクロサービスでも、こうした悩みを抱えていることが多いのではないでしょうか。その辛みを経験してきたエンジニアたちが、やっぱりマイクロサービスやめて、モジュラーモノリスにしようという考えになってきたのが今のトレンドだと思います。

そのトレンドの中でも、DBやトランザクションの切り口で明確な答えを打ち出してるものは少なく、概念としてモジュールを分けてモノリスで作るのが良いと言ってるものが多い認識です。

グローバルなトランザクションについて触れていないため、結果としてマイクロサービス時代と同様に、結果整合性を担保する仕組みが整わないまま、運用でカバーしているところが多い気がしてます。

私たちは、ここの問題に真正面から取り組んで単一トランザクションの利点を最大限に活用するために、モジュラーモノリス(共有DB)を採用することにしました。

グローバルトランザクションを管理するScenario

先に説明したように、私たちのソフトウェアではアプリケーション層がモジュールごとに分離されています。それらをつなぎ合わせているのがScenarioです。

現場で役立つシステム設計の原則[3] では次のように記載されています。

基本的なサービスクラスを組み合わせた複合サービスを提供するのがシナリオクラスです

これを参考にして、私たちのチームでは、Scenarioという概念を作ることにしました。

モジュールごとに分かれているUsecaseを、業務の視点で必要とする機能単位としてScenarioで取りまとめます。このScenarioで、グローバルなトランザクションを管理します。

モジュールをまたがってCreate/Update系の処理が必要な際には、Scenarioの中でトランザクションをはり、その中で手続き的なコードを書きます。分散トランザクションを意識することなく、普通のDBのトランザクションで管理することができます。後続のモジュールでエラーが発生したら、ロールバックしてAPIリクエスト自体がエラーになります。

システムや組織が成長して大きくなった時には、1つのDBにしたことが技術的負債として痛みを生むことがあるかもしれません。ですが、それは成長したものだけが味わうことのできる痛みであり、成長する前に分散トランザクションを完璧に作ってビジネスが頓挫していたら元も子もないわけです。
プロジェクト初期のスピードを優先するための意思決定として、技術的負債を作ってるような感覚があります。未来に借りを作って、今に便益をもたらしているのは、まさに負債って感じがします。

先のアーキテクチャ選定の記事でも書いた通りに、ソフトウェアアーキテクチャはトレードオフがすべてなんです。未来と今を天秤にかけて、今に重きを置いた形です。

モノリスとモジュラーモノリス(共有DB)の違い

一見するとScenarioを取り入れたモジュラーモノリス(共有DB)は、単純なモノリスのUsecaseと同じではないかと感じた人もいると思います。ですが、私は別物だと認識してます。

モノリスにおけるUsecaseは、その下にあるEntityやRepositoryなどの要素を組み合わせて使うものであって、直接メソッドを呼び出すものです。Entityのメソッドも呼び出せるので、ドメインの境界に関係なく、なんでも処理できます。

それに対してモジュラーモノリス(共有DB)のScenarioは、プロセス内APIとして公開されているモジュールのインターフェースに対して依存していて、モジュールの詳細は知ることができません。EntityはもちろんRepositoryも呼び出せないため、モノリスのUsecaseように好き勝手に操作することはできません。できるのは業務ロジックの前後の順番を整えることと、インターフェースに定義された値を用いた簡易的な条件分岐だけです。
モジュール内のUsecaseでは、モノリスのUsecaseと同様にEntityやRepositoryなどの要素を組み合わせて使います。こうすることで、境界づけられたコンテキストの中でモジュールに閉じた開発に注力することができ、モジュールの独立性を高めることができます。

具体的なコード

私たちのチームではGoを採用しているので、Goのコードで紹介します。トランザクションの仕組みだけは言語依存のところがあると思いますが、そこ以外は単なる処理の流れが書かれているだけなので、難しくないと思い込んで読んでみてください。

モジュール間を連携するScenarioの実装

ここでは例として、利用者(User)モジュールと契約(Contract)モジュールにまたがる処理を書きます。

package create_user

import (
	"context"

	"github.com/fumiyakk/modular-monolith-sample/internal/server/lib/unit_of_work"
	"github.com/fumiyakk/modular-monolith-sample/internal/server/module/contract"
	"github.com/fumiyakk/modular-monolith-sample/internal/server/module/user"
	"github.com/google/uuid"
)

type Scenario interface {
	CreateUser(context.Context, string) (uuid.UUID, uuid.UUID, error)
}

type scenario struct {
	uow             unit_of_work.UnitOfWork
	userUsecase     user.CreateUserUsecase
	contractUsecase contract.CreateContractUsecase
}

func New(
	uow unit_of_work.UnitOfWork,
	userUsecase user.CreateUserUsecase,
	contractUsecase contract.CreateContractUsecase,
) Scenario {
	return &scenario{
		uow:             uow,
		userUsecase:     userUsecase,
		contractUsecase: contractUsecase,
	}
}

func (s *scenario) CreateUser(ctx context.Context, name string) (uuid.UUID, uuid.UUID, error) {
	var userID, contractID uuid.UUID

	# トランザクションの管理
	err := s.uow.WithinTransaction(ctx, func(ctx context.Context) error {
		# Userモジュールでの処理
		id, err := s.userUsecase.CreateUser(ctx, name)
		if err != nil {
			return err
		}
		userID = id

		# Contractモジュールでの処理
		contractID, err = s.contractUsecase.CreateContract(ctx, userID)
		if err != nil {
			return err
		}

		return nil
	})

	if err != nil {
		return uuid.Nil, uuid.Nil, err
	}

	return userID, contractID, nil
}

トランザクションの処理は、利用するDBに合わせて作ってやれば良いです。
ここで伝えたいのは、単にScenarioでトランザクションを張って、その中でモジュールの呼び出しをシーケンシャルに行っているということです。蓋を開けたらとても簡単な実装ですね。

概念としてScenarioを作成しただけであり、やってることは普通のトランザクション管理です。

おわりに

モジュラーモノリスのモジュール間連携としてScenarioを使う実装を紹介しました。

モジュラーモノリス(分離DB)を採用して、モジュール間をマイクロサービスのように強い境界とし、結果生合成を保つような仕組みを作ろうとすると、実装コストが膨らんで大変になります。短期間でスピード重視の開発が求められるような状況では、モジュラーモノリス(共有DB)を採用して、割り切ったモジュール間連携、トランザクション管理をする方針も選択肢に入れて検討してみて欲しいです。

サンプルコードはこちらに置いてあります。
https://github.com/fumiyakk/modular-monolith-sample

connectを使ったサーバーとクライアントも実装して、手元で動かして確認できるようにしてあります。

ぜひ開発にお役立てください!

脚注
  1. Protocol Buffers https://protobuf.dev/ ↩︎

  2. Connect https://connectrpc.com/ ↩︎

  3. 現場で役立つシステム設計の原則 https://gihyo.jp/book/2017/978-4-7741-9087-7 ↩︎

Finatext Tech Blog

Discussion