断り書き:俺はN5レベルで日本語を勉強中です。間違いがあるかもしれないけど、頑張っています!
マイクロサービスの話でいつも飛ばされる質問がある — 本当は何を解決したいんだ?ほとんどのチームにとって、マイクロサービスの運用コスト(分散トレーシング、独立したデプロイ、ネットワーク障害、サービスメッシュ)は、解決する問題より多くの問題を生む。Martin FowlerのMonolithFirstがこの話を長々と展開しているし、俺の経験則では、エンジニアが20〜30人に満たないチームなら、その代償はまず割に合わない。モジュラーモノリスはたいてい正しいデフォルトだ。デプロイは一つ、内部の境界は強く、進化させやすい。
この記事は、Goでモジュラーモノリスを作る話だ。パート1と同じく、「見せてから言う」を自分に課している — 境界についての主張には、それを証明するコマンドの出力を必ず添える。go1.26.4上の小さいスケルトンリポジトリから取ったものだ。スケルトンはdaveamit/go-architecture-demosにある — クローンして02-modular-monolith/demo.shを走らせれば、以下の証拠を全部再現できる。ここでこれが特に大事なのは、Goのモジュラーモノリスについて一番大事な事実を、ほとんどの記事が書かないからだ。Goはモジュールの境界を強制しない。 以下の設計は全部、Goが実際に強制してくれる唯一の境界と、一つの小さいテストだけで仕事をさせるための工夫だ。
「モジュラー」の本当の意味 #
モジュールはただのパッケージやディレクトリじゃない。自分のドメインを持ち、実装を隠し — ここは理想論じゃなくて文字通り守らなきゃいけない部分だ — 他のモジュールを一切importしない、コードのまとまりのことだ。具体的にはこうなる。
- 各モジュールは自分の型、ストレージへのアクセス、ビジネスロジックを持つ
- モジュール同士は絶対にimportし合わない。モジュール間のやり取りは全部composition rootを経由する
- オーケストレーション層(
internal/app)が全部を配線して、モジュール間の語彙の翻訳を全部引き受ける
internal/appの外側に何も触れずにモジュールの実装を差し替えられるなら、境界は正しく引けている。そして「モジュール同士はimportし合わない」は雰囲気の話じゃない。一行でチェックできる。go list -deps ./internal/billing | grep ordersは何も出力しないはずだ。これは後でCIのテストにする。
ディレクトリの構成 #
cmd/service/
main.go ← call run(), nothing else (Part 1's thin-main pattern)
internal/
app/ ← composition root: all wiring, all translation
orders/
service.go ← concrete Service, domain types, events
internal/store/ ← orders' private guts, hidden even from billing
billing/
billing.go ← its own types; imports no other module
platform/
bus/ ← in-process event bus (shared plumbing)
api/ ← protobuf / OpenAPI definitions
migrations/ ← one directory, but schema-per-module inside
go.mod
よく見るレイアウトとの意図的な違いが二つある。一つはpkg/がないこと — パート1自身の基準(今日、他のチームがこれをimportするか?)に照らすと、単一サービスのロギングやページネーションのヘルパーはその基準を満たさない。共有の配管はinternal/platform/に置く。もう一つは、各モジュールが自分専用のネストしたinternal/を持てること — これは飾りじゃなくて、強制のメカニズムそのものだ。以下の証拠がそれを見せてくれる。
graph TD cmd["cmd/service"] -->|starts| app["internal/app"] app -->|wires| orders["orders"] app -->|wires| billing["billing"] app -->|owns| bus["platform/bus"] orders -.->|"Created event"| bus bus -.->|"app's adapter translates"| billing
実線の矢印はimportだ — 全部appから始まっていることに注目してほしい。破線はランタイムのイベントの流れで、これもbusで合流する。ordersとbillingの間には、どんな種類の矢印も存在しない。この「矢印が存在しない」状態を守り続ける方法が、次のセクションのテーマだ。
不都合な真実:この境界は何にも強制されていない #
モジュラーモノリスについての記事なら本当は全部やるべきなのに、ほとんどやらない実験がある。スケルトンリポジトリで、billingがordersに直接手を伸ばしてみる — イベントをすっ飛ばして、サービスを直接呼ぶ、金曜の夜にやりがちな近道だ。
// internal/billing/violation.go
import "example.com/modmono/internal/orders"
func (s *Service) totalFor(ctx context.Context, svc *orders.Service, id string) int {
o, _ := svc.Get(ctx, id)
return o.Total
}
何かがこれを止めてくれるだろうか?
go build: PASS (no complaint)
go vet: PASS (no complaint)
ビルドは通るし、vetも通る。そしてbillingの依存グラフには、静かにordersモジュール全体が入り込んでいる。
$ go list -deps ./internal/billing | grep internal/orders
example.com/modmono/internal/orders/internal/store
example.com/modmono/internal/orders
パート1は、強制される境界と単なる慣習をはっきり分けていた — その基準で言うと、この記事のモジュール境界は全部「慣習」でしかない。ルート直下のinternal/が守ってくれるのは他のリポジトリからで、その内側の兄弟パッケージ同士に対して、ツールチェーンは無関心だ。放っておけば、境界は都合のいいimportが一つ増えるたびに削れていって、18か月後には「billingをサービスとして切り出す」が考古学の仕事になる。
だから二つの門番を用意する。
門番1:モジュールごとにネストしたinternal/を置く。 internal/のルールはディレクトリベースだ(パート1にその証拠がある)。だからinternal/orders/internal/storeはinternal/orders/の下からしかimportできない。近道の先をordersの表面じゃなく中身に向けると、ビルドはコンパイルの前に失敗する。
package example.com/modmono/internal/billing
internal/billing/violation.go:3:8: use of internal package
example.com/modmono/internal/orders/internal/store not allowed
これがコンパイラに近いところで手に入る唯一の強制で、しかも守ってくれるのはモジュールの実装であって、境界そのものじゃない。
門番2:境界テスト。 モジュール間のルールを守るには、CIに25行くらいのコードがいる。
// internal/app/boundaries_test.go
func TestModuleBoundaries(t *testing.T) {
forbidden := map[string][]string{
"./../billing": {"internal/orders"},
"./../orders": {"internal/billing"},
}
for pkg, banned := range forbidden {
out, err := exec.Command("go", "list", "-deps", pkg).Output()
if err != nil {
t.Fatalf("go list %s: %v", pkg, err)
}
for _, b := range banned {
if strings.Contains(string(out), b) {
t.Errorf("%s imports %s: module boundary violated", pkg, b)
}
}
}
}
きれいなツリーだとグリーン。近道を戻すとこうなる。
--- FAIL: TestModuleBoundaries (0.05s)
boundaries_test.go:23: ./../billing imports internal/orders: module boundary violated
FAIL
正直に言うと、痛い目を見て学んだ落とし穴が一つある。このテストはgo listを外部コマンドとして呼ぶから、Goのテストキャッシュはその結果が他のパッケージのソースに依存していることを知らない — キャッシュされたokが、新しい違反を平気で隠してしまう。CIでは-count=1を付けて実行すること。(golangci-lintのdepguardも、すでにlinterを使っているなら宣言的に同じ仕事をしてくれる。)
うまくいく3つのパターン #
1. インターフェースは使う側に置く #
他のエコシステムから来た直感だと、各モジュールが自分のインターフェースを公開したくなる。でもGo自身のコードレビューのガイドは逆のことを言っている — インターフェースは値を使うパッケージに属するべきで、「実装する側でインターフェースを定義するな」と。ここでは、この慣習が構造そのものを支えている。もしordersがorders.Serviceを公開して、billingがそれを使ったら、billingはordersをimportすることになる。その瞬間、「モジュール同士はimportし合わない」はコンストラクタのシグネチャの中で死んでいる。
だから、モジュールは具体的な型を公開する。使う側は自分専用の狭いインターフェースを定義する。internal/appが両者をつなぐ。billingは自分の語彙で、自分が必要なものだけを宣言する。
// internal/billing/billing.go — imports no other module
type OrderEvent struct {
OrderID string
Amount int
}
func (s *Service) HandleOrderCreated(ctx context.Context, e OrderEvent) { ... }
そしてcomposition rootが翻訳を引き受ける。
// internal/app/app.go — the only package that sees everyone
created := bus.New[orders.Created]()
bill := billing.New(out)
created.Subscribe(func(ctx context.Context, e orders.Created) {
bill.HandleOrderCreated(ctx, billing.OrderEvent{OrderID: e.OrderID, Amount: e.Amount})
})
ここで、「境界で変換する」はもう独立したパターンじゃなくて、ただの結果になる。orders.Createdはbillingの中に一度も入り込まない。アダプターが明示的に変換するからだ。(DDDでは、この本格版を腐敗防止層と呼ぶ。同じチームが持つ二つのモジュールの間なら、appの中の型付きアダプターくらいがちょうどいい分量だ。)そして見返りは、境界の主張が機械でチェックできるようになることだ — さっきのgo listのテストが通るのは、まさにbillingのimportにordersの痕跡が一つもないからだ。
2. プロセス内イベント — 雰囲気じゃなく本物のbusで #
注文が作られたらbillingは反応する。でも注文の作成がbillingに結合しちゃいけない。使う道具はプロセス内のイベントbusだ。これは雰囲気で流さずに、ちゃんと見せる価値がある。イベントについての難しい問いは全部、busのセマンティクスの中に隠れているからだ。スケルトンのbusは約40行で、はっきりした選択が三つある。
// internal/platform/bus/bus.go
type Bus[T any] struct {
mu sync.RWMutex
subs []func(context.Context, T)
}
func (b *Bus[T]) Publish(ctx context.Context, ev T) {
b.mu.RLock()
subs := b.subs
b.mu.RUnlock()
for _, h := range subs {
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("bus: handler panic recovered: %v", r)
}
}()
h(ctx, ev)
}()
}
}
選択をはっきり言葉にするとこうだ。ジェネリクスで型付け — イベントの種類ごとにBus[T]を一つ持つから、ペイロードの不一致は文字列ベースのトピック名+ランタイムの型アサーションじゃなく、コンパイルエラーになる。同期ディスパッチ — 決定的で、テストが簡単だ。遅いハンドラはpublisherを遅くする。それが許せないなら、subscriberの方が見える形でgoroutineを選ぶ。panicはrecoverする — 壊れたハンドラは障害じゃなく、ログの一行になる。
invoice created for order 42 (120)
bus: handler panic recovered: billing exploded on suspiciously large amount
service still alive after handler panic
exit: 0
ここからは正直に言う。この手のパターンのよくある語られ方は、「耐久性が必要になったら本物のキューに差し替えるだけ — コードは変わらない」で締めくくられる。この主張は嘘だし、信じると痛い目を見る。本物のキューは*少なくとも一回(at least once)配送する。だからハンドラは全部べき等(idempotent)にしなきゃいけない — これはモジュールのコードの話で、しばしばスキーマの話でもある(重複排除キー)。キューはGoの構造体じゃなくバイト列を運ぶから、イベントにはシリアライズとバージョニングが増える。そしてコミット後にpublishする設計には、クラッシュの隙間ができる — DBのコミットとpublishの間でプロセスが死んだら、イベントは消える — これへの本当の直し方はトランザクショナルアウトボックスで、モジュールの書き込みパスそのものを組み替えることになる。プロセス内busが実際に買ってくれるのは、今日の呼び出し側の分離と、後でキューを差し込める継ぎ目(seam)*だ。その「後で」が現実にありえるなら、ハンドラは最初からべき等に書いておくこと。
3. データの境界 — みんな飛ばす部分 #
一つのデータベースを共有していても、モジュールはモジュラーのままでいられる — スキーマを境界の一部として扱えばいい。将来の切り出しを可能にしておくルールはこうだ。各モジュールが自分のテーブルを持つ(モジュールごとのスキーマか、テーブル名のプレフィックス)、モジュールをまたぐ外部キーは禁止、モジュールをまたぐJOINも禁止。billing_invoicesからorders_ordersへの外部キーは、境界テストには見えないimport文みたいなものだ。そして何年か後、切り出しをこっそり拒否してくる。
同じプロセスにいることは、モジュラーモノリスの本当の必殺技も与えてくれる。だから意図的に使うこと。二つのモジュールの書き込みを一つのトランザクションにまとめて、マイクロサービスがoutboxとsagaの仕組みで近似しようとしているアトミック性を、そのまま手に入れることができる。守るべき規律は、これを意識的に、internal/appの中だけに閉じ込めて、フローごとに行うことだ。そして、手に入った二つの一貫性モードをフローごとに選ぶ。billingはまさに、投げっぱなしの非同期処理がビジネス上のインシデントになるモジュールだ — 「注文は受理されたのに、支払いは一度も試されなかった」というやつだ。だから注文+請求のフローには共有トランザクションがふさわしいかもしれないし、一方で「ウェルカムメールを送る」はbusに任せればいい。
テスト戦略 #
- ユニットテスト:各モジュールを単体で。使う側に置いたインターフェースがここで効いてくる — billingの
OrderEventはordersのモックを一切必要としない。ただの構造体リテラルで済む。 - イベントフロー:同期busのおかげで決定的になる — publishして、assertするだけ。sleepもflaky(不安定)なテストもない。
- 統合テスト:本番でデプロイするのと同じデータベースエンジンに対して、本物の
appを起動する(インメモリのSQLiteじゃなくtestcontainers)。パート1のやり方どおりビルドタグでガードして、go test ./...を速いままにしておく。 - 境界テストを
-count=1付きで、CIに、ずっと置いておく。これは実行可能なアーキテクチャそのものだ。 - ドメインのフィクスチャをモジュール間で共有しないこと。インフラ系のヘルパー(コンテナの起動、golden file)のための共有
internal/testutilは問題ない。
本当にサービスに分けるべき時 #
雰囲気じゃなく、測れる引き金。
- リソースの要求がまるで違う — あるモジュールは64GBのキャッシュやGPUが必要なのに、他は512MBで足りる。生のトラフィック量はこれに当たらないことに注意 — ステートレスなモノリスなら、ホットパスが10倍になってもレプリカを増やせば対応できる。
- 数字で示せるデプロイの競合 — リリーストレインの待ち時間、ロールバックの影響範囲、チーム同士が毎週デプロイをブロックし合っている状態。
- 障害の隔離やコンプライアンス — 障害範囲や監査の対象を、本当に閉じ込めなきゃいけないコンポーネント。
- チームの所有権 — コンウェイの圧力は本物だ。あるモジュールに専任チームと専用のオンコールがあるなら、サービスの境界は組織の境界に従ってもいい。
「いつかスケールが必要になるかも」は、それでも理由にならない。そして本物の引き金が引かれた時、ここまでの規律がそのまま移行の道筋になる。appの中のモジュールのアダプター面がRPCクライアントになり、スキーマはもう分かれていて、イベントはbusからbrokerに移る — 差分はinternal/appと新しいcmd/に集中する。
意見としてのまとめ #
- デフォルトはモジュラーモノリス。 Fowlerのmonolith-firstの議論は、たいていのマイクロサービス移行より、よっぽど長持ちしている。
- はっきり言う:Goはこの境界を守ってくれない。 モジュールをまたぐ直接importでも、ビルドとvetは通る — さっき見た通りだ。ネストした
internal/が各モジュールの中身を守り、25行のgo listテスト(-count=1付きで実行)が依存グラフを守る。三つ目の門番は存在しない。 - インターフェースは使う側のもの。 モジュールは具体的な型をexportして、
appが変換する。これがあるから「モジュール同士はimportし合わない」が本当になるし、チェックもできるし、Go自身のレビューガイドに沿った書き方にもなる。 - busを見せろ。 型付き・同期・panicをrecoverする、というのは十分いいデフォルトだ。でも何を選ぶにしても、そのセマンティクスは書き残しておくこと。イベントパターンが成功するか失敗するかは、そこにかかっている。
- 耐久性は差し替えじゃない。 at-least-onceはハンドラのべき等性を意味するし、コミット後にpublishするならoutboxが要る。その未来が本当に来るかどうかは、初日に決めておくこと。
- スキーマも境界の一部。 モジュールをまたぐ外部キーやJOINはなし — そして単一トランザクションという必殺技は、
appの中で、ビジネス上重要なフローにだけ意図的に使うこと。
モジュラーモノリスは、マイクロサービスに向かう途中の妥協案じゃない。運用の複雑さを低く保ちながら、後で切り出せる余地も残しておく、意図的なアーキテクチャだ。Goでは、自分で強制しない限り、この境界はだんだん嘘になっていく。コンパイラがくれる壁は一枚だけ。残りはテストとして自分で作るしかない。
Goアーキテクチャ シリーズの前回:Goプロジェクトの構成方法 — cmd/、internal/、pkg/、そしてツールチェーンが実際に強制してくれるもの。
Goアーキテクチャ シリーズ:1. Goプロジェクトの構成方法 · 2. Goでモジュラーモノリスを設計する