1. はじめに
私が担当しているサービスのバックエンドでは モジュラーモノリス を採用しています。本記事では、採用に至った判断軸、実際の構成、運用してみての所感を整理します。
以降の説明は、例として架空の オンラインレッスンプラットフォーム を題材に進めます。クライアントとしてはユーザー向けサイト・講師向けサイト・カスタマーサポート向けサイトを想定します。会員・講師・ポイント・レッスン予約・シフト・レビューといった共通のドメイン領域を、これら複数のクライアントが共有するサービスを思い浮かべてください。
クライアントごとに独立したバックエンドを並べる構成では、各バックエンドで類似の実装が増えがちです。ドメインの中核(会員管理、ポイント、受講履歴など)は本質的に1つしかないため、修正のたびに複数のリポジトリへ改修を加える必要があります。マイクロサービスとモジュラーモノリスの両方を比較した結果、最終的にモジュラーモノリスを採用する判断に至りました。
対象読者
- 複数のクライアント・チームから利用される中規模のバックエンドを設計し直そうとしている方
- マイクロサービス化を検討しているが、本当に必要かを判断したい方
- Goの
go.work/go.modでモジュール境界をどう引くかに興味がある方
TL;DR
- 対象は「ドメインは1つ、クライアントは複数」の構成。マイクロサービスのメリットより、単一トランザクションで処理できる利点のほうが大きかった
- 「業務モジュール群」と「クライアント別バックエンド」を分離した。業務モジュールは1つのリポジトリ(以降「コアモジュール群リポジトリ」と呼ぶ)の中で
account/lesson/point/systemなどのサブモジュールに分けた。各バックエンドはそれらをGoのrequire経由で取り込む - モジュール単位で go.mod を切り、
go.workで開発時だけ束ねる。本番ビルドではバージョン付きモジュールを取り込むので、依存方向と境界が物理的に強制される - 将来マイクロサービス化したくなったら、モジュール単体を切り出しやすい構造にしておく、というのが導入時の合意事項
2. 当時の状況と課題
今回のアーキテクチャ刷新は、運用中のサービスを動かしながら行ったわけではなく、新規サービスの初期設計・実装を進めている途中で方針転換したものです。最初は従来どおり「クライアントごとに独立したバックエンドを並べる」構成で書き始めていました。しかし検討が深まるにつれて「このままリリースすると技術負債が制御できなくなる」と判断し、設計を全面的に見直しました。リリース後に修正するよりも、構築段階で構造を変えるほうが長期的に安いという見積もりです。
書き始めていた構成を具体的に示すと、次のとおりです。
- ユーザー向けサイト用バックエンド・講師向けサイト用バックエンド・カスタマーサポート向けサイト用バックエンドがそれぞれ独立したリポジトリ
- 共通DBを直接参照する
- 共通処理は社内パッケージとして切り出していたが、ビジネスロジック層は各リポジトリ内に重複
この構成では、次のような状況が起きていました。
- 同じドメインルールが複数箇所に散らばる。「ポイント消費時の残高検証ロジック」のように、ユーザー向けサイトとカスタマーサポート向けサイトの双方から呼び出される処理が両リポジトリで似て非なる実装になりがち
- 修正の波及がレビューしきれない。テーブル定義を変えると、3〜4リポジトリでマイグレーションと修正をセットで進める必要がある
- トランザクション境界が曖昧。同じテーブルを別アプリから書き込むため、整合性は実質的にアプリ側の実装規律に依存
「サービスを分ける」方向に振るか、「中身を共通化する」方向に振るかをここで決める必要がありました。
3. マイクロサービスを比較対象として置いた
最初に検討したのはマイクロサービス化です。会員・講師・ポイント・レッスンをそれぞれ独立したサービスにして、gRPC経由で通信させる構成です。
マイクロサービスのメリット(私たちのケースで)
- 責任が分割されるので各サービスのソースコードはシンプルに保てる
- 将来、特定ドメインだけスケールアウトしたい場合に独立してスケーリングできる
マイクロサービスのデメリット(私たちのケースで)
- 単一トランザクションで処理できない。ポイント残高・予約・受講履歴など同時に整合していないと厳しい業務が多く、課金系で部分失敗が起きた場合の運用設計をすべて自前で用意する必要がある
- ユーザー名やニックネームでの横断検索が一気に難しくなる。カスタマーサポートから「この名前で会員と講師を横断検索したい」というニーズは日常的にあり、サービス境界をまたいで実装するコストが大きい
- proto 定義に思いのほか時間がかかる。共通protoリポジトリにpush → 各サービスの
go.modを更新 → 反映、という流れがユースケース追加のたびに発生する - そもそも私たちは複数チームで大規模な並列開発をするほどの規模ではない。マイクロサービス本来のメリットである「組織のスケール」が享受しにくい
「単純なモノリス」では戻したくない理由
一方で「全部1つの大きなアプリに戻す」という選択も適切ではありません。前述のとおり、アーキテクチャ刷新前の状態はまさに「重複のあるモノリス的運用」で、ドメインの境界が曖昧なまま規模だけ大きくなることの痛みは身をもって知っていたからです。
その中間として現実的なのは、1つのデプロイ単位の中で内部を明確に分割するモジュラーモノリスです。
4. モジュラーモノリスを選んだ判断軸
§2で挙げた課題と、本節で照らし合わせる観点は次のように対応します。
| §2 の課題 | 決定打となる観点 |
|---|---|
| ドメインルールが複数箇所に散らばる | ドメインルールの一元化 |
| 修正の波及がレビューしきれない | 境界の強制力 / 将来の分割しやすさ |
| トランザクション境界が曖昧 | 単一トランザクション |
| 会員 × 講師の横断検索が頻出(業務特性) | 横断検索のしやすさ |
意思決定時に整理した観点をそのまま並べると、次の表のとおりです。
| 観点 | モノリス(戻す) | マイクロサービス | モジュラーモノリス |
|---|---|---|---|
| ドメインルールの一元化 | できる | できる(境界はサービス単位) | できる |
| 単一トランザクション | できる | できない | できる |
| 横断検索(会員 × 講師など) | できる | 実装コスト高 | できる |
| デプロイの単純さ | シンプル | 複雑 | シンプル |
| 境界の強制力 | 弱い(実装規律に依存) | 強い(ネットワーク境界) | 中(仕組みで担保可能) |
| 将来の分割しやすさ | 低い | -(既に分割済み) | 高い |
| 障害分離 | 弱い | 強い | 弱い |
| 学習コスト | 低い | 高い | 中(チームに初めての概念) |
「単一トランザクションで処理できる」「横断検索が普通のSQLで書ける」 という現実的な利点が、私たちの業務には決定打になりました。マイクロサービスのメリット(独立スケール、障害分離、組織スケール)よりも、業務トランザクションの整合性をシンプルに記述できるほうが、私たちのチームサイズと業務特性では圧倒的に価値が高いという判断です。
「将来本当に独立スケールが必要になったら、1モジュールだけ切り出してマイクロサービス化する」という移行余地を確保できるのも、モジュラーモノリスを選ぶ理由の1つです。
5. 実際の構成
最終的に落ち着いた構成は次のとおりです。まずは全体像を示します。
flowchart TB
subgraph Clients["クライアント別バックエンド(各リポジトリ)"]
direction LR
UB["ユーザー向けサイト用<br/>バックエンド"]
TB["講師向けサイト用<br/>バックエンド"]
CB["カスタマーサポート<br/>向けサイト用<br/>バックエンド"]
end
Core["<b>コアモジュール群リポジトリ</b>(モジュラーモノリス本体)<br/><br/>account ・ lesson ・ point ・ system<br/>query ・ orchestrator ・ shared"]
Clients -->|"go.mod require"| Core
各クライアントバックエンドは、自分が必要なコアモジュールを go.mod の require で取り込みます。実態としてはどのクライアントも横断的に多くのモジュールを使うので、矢印を全部引くと網の目になります。「クライアントは必要に応じて任意のコアモジュールを取り込む」と理解しておけば十分です。
コアモジュール群の内部は4つの役割で構成されており、それぞれ「他モジュールへのアクセス可否」のルールが異なります。
flowchart TB
ORC["<b>orchestrator</b>"]
subgraph Business["<b>ビジネスロジックモジュール</b>(相互アクセス不可)"]
direction LR
ACC["account"]
LSN["lesson"]
PT["point"]
SYS["system"]
end
QRY["<b>query</b>"]
SHR["<b>shared</b>"]
ORC -->|"usecase 呼び出し"| Business
QRY -.->|"SQL read"| Business
Business --> SHR
QRY --> SHR
ORC --> SHR
整理すると次のとおりです。
| 役割 | 他モジュールのコード | 他モジュールのテーブル | shared |
|---|---|---|---|
| ビジネスロジック(account / lesson / point / system) | 不可 | 不可 | 可 |
| query | 不可 | read のみ可 | 可 |
| orchestrator | 可(usecase 経由) | 不可 | 可 |
| shared | 不可 | 不可 | -(自身) |
ポイントは、業務ロジックを集約したコアモジュール群リポジトリと、クライアント別バックエンドを分離したことです。
5.1 業務モジュール: コアモジュール群リポジトリ
コアモジュール群リポジトリは、ドメインで分けたサブモジュールを並べた単一リポジトリです。
| |
サブモジュールごとに go.mod を持ち、独立した Go モジュールとして公開します。開発時は go.work で束ねてIDEが一括解決できるようにしています。一方、各クライアントバックエンドからは go.mod の require でバージョン付きで取り込みます(後述)。
各モジュールの内部はClean Architecture + DDDの構造で統一しています。
| |
ポイントはGoの internal パッケージを使って、モジュール外部からはユースケース層(と DTO)しか見えないよう、物理的に閉じていることです。リポジトリ実装や内部ドメインサービスは internal 以下にあるので、他モジュール・他リポジトリからはimport不可能です。
5.2 クライアント別バックエンド
各バックエンドリポジトリは、自分が必要なモジュールだけを go.mod で取り込みます。たとえばユーザー向けサイト用バックエンドの go.mod は以下のとおりです。
| |
バックエンド側の責務は、入口のハンドラーでリクエストを受け、Scenario層で各モジュールのユースケースを呼び出して結果を返すことだけです。HTTP APIならGin + OpenAPI、バッチならAWS Lambdaなど、入口ごとにハンドラーを使い分けます。バックエンド自身にユースケース層は持たせず、ビジネスロジックはすべてモジュール側にあります。Scenario層の役割は6.1で詳しく説明します。
6. モジュール境界の引き方と内部通信
モジュラーモノリスの本質は、「名ばかりモジュラー」にしないことです。境界を引いただけで実態がスパゲッティ化していると、モノリスの欠点と相互運用コストだけが残ります。私たちはモジュール境界を以下のように引いています。
全体像を先に示すと、APIリクエストはScenarioを起点に次の経路で処理されます。
flowchart TB
H["Handler<br/>(Gin + OpenAPI)"]
S["<b>Scenario</b><br/>クライアントバックエンド側<br/>BEGIN / COMMIT を張る"]
subgraph Core["コアモジュール群"]
O["<b>orchestrator</b><br/>(複数モジュールの write 合成)"]
subgraph Mods["ビジネスロジックモジュール"]
direction LR
U1["lesson.usecase"]
U2["point.usecase"]
U3["account.usecase"]
end
Q["<b>query</b><br/>(横断 read 専用)"]
end
T[("ビジネスロジック<br/>モジュールのテーブル")]
H --> S
S -->|"複数モジュールにまたがる write"| O
S -->|"単一モジュールの write / 単純な read"| Mods
S -->|"横断 read"| Q
O --> Mods
Mods --> T
Q -.->|"SELECT のみ"| T
トランザクション境界はScenarioが張り、orchestratorは複数モジュールへの書き込みを合成するだけでトランザクションを持ちません。queryはread専用で、他モジュールのテーブルに対して SELECT のみ許可しています。以降、Scenario・query・orchestratorの3つの役割を順に詳しく見ていきます。
6.1 API ロジックはバックエンドのScenario層で組み立てる
Scenario層という呼び方と「複数モジュールのユースケースを業務単位でまとめる」発想は、Finatextの事例を踏襲しています。
モジュール間で直接通信することは、原則として認めていません。account から lesson のユースケースを呼ぶ、といった横の依存を許すと、結局モジュール境界が崩れていくからです。例外は「複数モジュールにまたがるワークフローを共通化したい」ケースで、これは orchestrator モジュールに集約します(6.3で後述)。
同じ理由で、他モジュールのテーブルへ直接アクセスすることも認めていません。各モジュールは自分が管轄するテーブル群を持ち、書き込みは必ず自モジュールのユースケース経由になります。例外的に「複数モジュールにまたがるread」は query モジュールに集約します(6.2で後述)。
代わりに、API ごとのロジックは各クライアントバックエンドのScenario層で組み立てる 構造にしています。
- バックエンド側にユースケース層は置かない
- 代わりにScenario層が、複数モジュールのユースケースを呼び出して API のロジックを構築する 役割を担う
- Clean Architecture上はモジュール側の
usecase/と同じ役割だが、レイヤーを区別するためにバックエンド側はscenario/という名前にしている
リクエストの流れはHandler → Scenario → 各モジュールのUsecase → 各モジュールのDomain / Infraです。Scenarioからモジュールの internal 配下に直接アクセスすることはしません。Goの internal パッケージ規約で物理的にも禁止されています。
トランザクション境界の管理もScenario層の責務です。複数モジュールにまたがるユースケース呼び出しでは、Scenarioが BEGIN ... COMMIT を張ります。その内側で各モジュールのユースケースを順に呼び出します。各モジュールのユースケースに tx を明示的に引き渡すことで、Scenarioが張ったトランザクションは各モジュールのリポジトリ呼び出しにそのまま引き継がれます。「ポイントは消費されたがレッスン予約は失敗した」のような部分整合崩れを起こさずに済みます。
DIコンテナーでモジュール側のユースケースのインスタンスを束ねており、バックエンド起動時に必要なユースケースをScenario層へ注入します。
具体的に書き起こすと、レッスン予約とポイント消費を1つのトランザクションで扱うScenarioは次のとおりです。
| |
LessonBooking 構造体のフィールドが2つのモジュール(lesson / point)のユースケースで構成されており、DIコンテナーがここに具体的な実装を注入します。tx を各ユースケースに引き渡すことで Lesson.Create と Point.Consume が同じトランザクション上で実行されます。どちらかが失敗すれば defer tx.Rollback() で両方の書き込みが巻き戻ります。両方が成功した場合のみ最後の tx.Commit() で確定するという、Goの標準ライブラリだけで書けるシンプルな構造です。
なお、上記コード例ではScenarioから各モジュールのユースケースに *sql.Tx を直接渡していますが、これは説明を簡潔にするための簡略化です。厳密にはClean Architectureの依存方向ルールから外れる構造で、ユースケース層が database/sql というインフラ詳細を直接参照する形になっています。実装ではトランザクションを表す抽象を挟むことでこの結合を切っており、コード例ではその抽象を省略しています。
6.2 横断クエリは query モジュールに集約する
会員と講師をJOINするような「複数モジュールにまたがるread」を、各モジュールに散らすと境界が崩れます。私たちは query という read 専用モジュールを1つ用意して、そこに横断クエリを集約しました。
writeを各モジュールの境界に閉じ込め、モジュールをまたぐreadだけ query モジュールに集約するという非対称な分け方です。モジュール内に閉じたreadは各モジュールのユースケースで実装します。CQRSの発想に近い設計と言えます。共用DBを採用しているため、境界をまたぐJOINも物理的には書けてしまいます。この自由度を「モジュールをまたぐreadは query に閉じる」ルールで縛り、境界が崩れないようにしています。
6.3 複数モジュールを束ねる処理は orchestrator に置く
実はこの orchestrator モジュールは、最初は存在していませんでした。Scenario層が各モジュールのユースケースを直接組み合わせれば十分だと考えていたためです。
しかし開発が進むにつれて、まったく同じ一連のユースケースの流れを複数のバックエンドで実装するケースが増えていきました。たとえば「レッスン予約 → ポイント消費 → 履歴記録」のような流れを、ユーザー向けサイトとカスタマーサポート向けサイトの両方で組み立てる、という状況です。これだと結局、同じワークフローが複数のクライアントバックエンドに散らばります。
ドメインルールが散らばるのを避けるためにモジュラーモノリスを採用したのに、これではワークフローのレベルで同じ問題が再発しかねません。そこで、複数モジュールをまたぐwriteの組み立てを orchestrator モジュール へ集約することにしました。orchestrator は各モジュールのユースケースを呼び出す薄い層で、Scenario側から呼び出されます。
ただし、orchestrator 自体はトランザクション境界を持ちません。実際の BEGIN ... COMMIT を張るのは呼び出し側のScenario層です(6.1を参照)。orchestrator の責務は「複数モジュールにまたがる書き込みの順序やワークフロー合成」のみと決めて、トランザクション制御からは切り離しています。
これで、lesson から直接 point のユースケースを呼んだり、その逆を行ったりという横の依存が増殖しないようになっています。
6.4 go.work と go.mod を二段構えで使う
開発体験と境界の強制を両立する仕組みとして、Go Workspace(go.work)と Go Modules(go.mod)を二段構えで使っています。
- 開発時(コアモジュール群リポジトリ内):
go.workで全サブモジュールを束ねる。IDE補完やローカルビルドが効きやすい - 本番ビルド時(各バックエンド):
go.modのrequireでバージョン付きモジュールを取り込む。go.workは使わない
これにより、バージョンを上げるタイミングが明示的になり、コアモジュール群側の破壊的変更がバックエンドに自動で流れ込むことがありません。
なお go.work 単独運用にはいくつかの落とし穴があるので、本番ビルドは go.mod ベースで通すという基本姿勢は崩していません。
7. 採用してよかったこと
導入から運用に乗せてみての所感です。
ドメインルールが1箇所にまとまった
最大の効果はこの点でした。「ポイント消費はどのルートでもこのユースケースを通る」「講師の検索条件はこのモジュールに問い合わせる」という境界が明確に引けたので、修正の波及範囲を把握できるようになりました。仕様変更の影響調査にかかる時間が実感として大きく減っています。
単一トランザクションでシンプルに記述できる
Scenario層で BEGIN ... COMMIT を張れるので、予約・ポイント消費周辺の「片方だけ書き込まれて整合が崩れる」リスクをアプリ側で吸収する必要がほぼなくなりました。マイクロサービスにしていたらSagaパターンや補償トランザクションを本格的に検討していたはずで、そのコストを払わずに済んだメリットは大きいです。
将来マイクロサービス化への移行余地がある
各モジュールが独立した go.mod で公開されている構造です。もし将来「point だけ独立スケールしたい」となった場合、point のusecaseをgRPCサーバーとして公開する形に切り替えれば対応できます。クライアント側をgRPC呼び出しに差し替えるだけでマイクロサービスへ移行できます。境界線がすでに引かれているため、この選択肢を現実的に取り得ます。
8. 注意点と気をつけていること
良いことばかりではないので、運用して苦労したポイントも共有します。
モジュール間依存を「ユースケース経由のみ」に保つ
ルールとして決めても、急ぎの修正で「ちょっとだけ他モジュールのリポジトリに直接アクセスしたい」という誘惑は出てきます。internal パッケージ規約が支えになっている部分が大きく、規約で物理的に禁止されるため、レビュー時に「これ規約違反になりませんか?」を毎回議論せずに済んでいます。境界を引くなら、レビュー努力ではなく言語機構やリンターに守らせるのが重要だと改めて感じています。
モジュール間で循環参照を起こさない
A → B → A のような循環依存を一度作ってしまうと、go.mod のバージョン解決が破綻します。shared のような最下層モジュールへの依存は許容しつつ、ドメイン間の依存方向は orchestrator を頂点とした一方向にする、という設計を最初に合意しておくと問題を避けられます。
go.mod のコンフリクトが頻発する
バックエンドはモジュールがバージョンアップするたびに go.mod / go.sum を更新します。複数のPRが並行で進んでいて同じモジュールのバージョンを同時に更新する状況では、本体のコードレビューは通過しているにもかかわらず go.mod だけコンフリクトしてしまうケースが頻発します。開発が活発な時期には、これが無視できないストレス要因です。
初めての概念で立ち上がりに時間がかかる
モジュラーモノリスを採用するのはチームとしても初めてでした。「どこまで分ければよいか」「orchestrator と各モジュールの責務をどう分けるか」については1〜2ヶ月、設計レビューを通じて詳細を固めていきました。最初から完成形を作るのではなく、まずは粗く境界を引いて、運用しながら整えていくスタンスで進めたのが結果的に良かったと感じています。
1モジュールが落ちると全体が落ちる
これはモジュラーモノリスの構造上やむを得ない点で、マイクロサービスのような「一部停止」はできません。業務ドメイン部分の障害分離を見送るというトレードオフを受け入れています。
トランザクションの分離レベルは業務ごとに設計する
§6.1のコード例にある BeginTx(ctx, nil) はデフォルトの分離レベル(PostgreSQLなら READ COMMITTED)で動作します。複数モジュールの整合性が必要な処理では、たとえばレッスン予約とポイント消費の同時実行で残高マイナスを踏むような race condition を起こしえる構造です。本番では sql.TxOptions で分離レベルを上げるか、Point.Consume 内部で SELECT ... FOR UPDATE を使うなど、業務ごとに分離戦略を別途設計しています。
今後深掘りしたい論点について
紙幅の都合で本記事では踏み込めなかった論点を、別記事で扱う候補として残しておきます。
- マイグレーション運用: 共有DBを採用しているため、複数バックエンドが異なるモジュールバージョンに依存している状況で、マイグレーションの適用順序やロールバック方針をどう設計するかは独立した論点となる。次稿以降で整理したい。
queryモジュールの暗黙のスキーマ依存: Goのinternalはimport経路を物理的に防げる。一方でqueryが他モジュールのテーブルに投げるJOIN/SELECTは防げない。他モジュールの内部スキーマ変更でqueryのSQLが無言で壊れるリスクがあり、契約テストやCIでのスキーマ変更影響チェックの整備は今後の課題である。
9. まとめ
- 「ドメインは1つ、入口クライアントは複数」のサービスにはモジュラーモノリスが向いていた。マイクロサービスのメリットより、単一トランザクションでシンプルに記述できる利点のほうが大きかった
- 業務ロジックをコアモジュール群リポジトリのサブモジュール群に集約し、クライアント別バックエンドはそれを
requireするアダプタに留めた。境界はinternalパッケージ規約とgo.modのバージョン管理で物理的に守られる - 横断 read は
query、複数モジュールの write 取りまとめはorchestratorという分離で、モジュール間の依存が増殖しないようにした - 将来マイクロサービス化が必要になったときの移行余地は確保している。最初からマイクロサービスにするよりは、境界を引きつつモジュラーモノリスで始めて、本当に必要になってから切り出すほうが安全だと考えている
同様の規模・特性のサービス(ドメインは限定的だが、入口クライアントが複数あるサービス)でアーキテクチャを検討している方の参考になれば幸いです。
