断り書き:俺はN5レベルで日本語を勉強中です。間違いがあるかもしれないけど、頑張っています!
マイクロサービスの話で、大事な質問がよく飛ばされる:本当にどんな問題を解決したいのか?ほとんどのチーム — 特にスタートアップや20人以下のエンジニアチーム — にとって、マイクロサービスの運用コストは、解決する問題より多くの問題を作る。モジュラーモノリスはたいてい正しいデフォルトだ:一つのデプロイ、強い内部の境界、簡単に進化できる。
「モジュラー」の本当の意味 #
モジュールは自分のドメインを持って、小さい公開インターフェースを定義して、内部を外に見せない。
実際にはこういうことだ:
- 各モジュールは自分の型、データベースアクセス、ビジネスロジックを持つ
- 他のモジュールは公開インターフェースだけを使う
- ワイヤリングレイヤー(
internal/app)が全部を組み立てる — モジュール同士は知らない
モジュールの実装を変えて、インターフェースの外を何も変えなくていいなら、境界は正しく引けている。
ディレクトリの構成 #
cmd/service/
main.go ← 依存を作って、サーバーを開始
internal/
app/ ← 全部のワイヤリングを持つ場所
orders/
service.go ← 公開インターフェースと型
repository.go
model.go
billing/
service.go
repository.go
users/
service.go
repository.go
pkg/ ← 共有ヘルパー(ロギング、リトライ、ページネーション)
api/ ← protobuf / OpenAPI の定義
migrations/
go.mod
internal/app は組み立ての場所だ — 全部のモジュールを作って、依存を注入する。main.go は app.New() を呼んでサーバーを開始する。app の外は具体的な実装を知らない。
モジュールの組み立て #
graph TD cmd["cmd/service"] -->|starts| app["internal/app"] app -->|injects| orders["orders"] app -->|injects| users["users"] app -->|injects| billing["billing"] orders -->|"OrderCreated event"| billing
app は全部のモジュールの参照を持っている。コードベースで具体的な型を見る唯一の場所だ。全部のモジュールは依存をインターフェースとして受け取る。
うまくいく3つのパターン #
1. インターフェース駆動の境界 #
各モジュールは小さいインターフェースを公開する:
// internal/orders/service.go
type Service interface {
Create(ctx context.Context, o Order) (Order, error)
Get(ctx context.Context, id string) (Order, error)
}
internal/app はこのインターフェースを billing に注入する — *orders.ServiceImpl じゃない。モジュールが独立してテストできて、別々に進化できる。
2. プロセス内イベントで非同期の流れ #
全部が直接の呼び出しである必要はない。注文が作られた時、billingは反応する必要がある。でも、billingが注文の作成をブロックするべきじゃない。
シンプルなプロセス内イベントバスを使う:
// orders は成功した Create の後にパブリッシュする
bus.Publish(ctx, "orders.created", OrderCreatedEvent{OrderID: o.ID, Amount: o.Total})
// billing は起動時にサブスクライブする
bus.Subscribe("orders.created", billing.HandleOrderCreated)
注文の作成は速いままで、billingの動作は独立してテストできる。耐久性が必要になったら、プロセス内バスを本物のキューに変える — モジュールのコードは変わらない。
3. 境界での腐敗防止 #
あるモジュールが他のモジュールのデータを必要とする時、境界で変換する。orders.Order を billing に渡さない — billing.OrderSummary を定義して、明確に変換する。内部の表現がモジュールの外に漏れるのを防いで、将来のリファクタリングを楽にする。
テスト戦略 #
- ユニットテスト:各モジュールを独立して、モックしたインターフェースでテスト。速くて、I/Oなし。
- 統合テスト:本物の
appをテスト用データベースで起動して、全体をテストする。 - テストヘルパーをモジュール間で共有しない。少しの重複の方が、テストコードでのモジュール間結合よりずっとマシだ。
いつサービスに分けるか #
本当に必要な時に分ける:
- 独立したスケーリング — このモジュールは他の10倍のトラフィックがあって、それを計測した
- 独立したデプロイ — コンプライアンスや規制で分離が必要
- 違う技術 — モノリスでは満たせない制約がある
「いつかスケールが必要になるかも」は理由じゃない。運用の複雑さは本当のコストだ — 他の方法がもっと悪い時だけ払う。
おわりに #
モジュラーモノリスはマイクロサービスへの妥協じゃない — 運用の複雑さを低く保ちながら、後でリファクタリングして分割できる意図的なアーキテクチャの選択だ。Goでは、internal/、小さいインターフェース、プロセス内イベントバス、薄い組み立てレイヤーがあれば、チームの成長に耐えるシステムを作れる。
ここから始めよう。サービスに分けるのは、本当に必要になってからだ。