「モジュラーモノリスか、レイヤードか」で悩んでいたが、そもそも二択ではなかった
新しい案件に参画したばかりで、ドメイン知識のキャッチアップに時間と体力と脳のリソースを持っていかれ、個人開発がすっかり止まっているmaruyamaです。
そんな中で『ソフトウェアアーキテクチャの基礎』を読んでいたら、個人開発している匿名チャットサービスのことが気になりはじめました。
設計書には「モジュラーモノリスを採用する」と書いてあります。でも実際のコードを開くと、コントローラーがあってサービスがあってリポジトリがある。どう見てもレイヤードアーキテクチャです。
これ、モジュラーモノリスとレイヤードが混ざってないか?どういう立ち位置なんだろう?
結論から書くと、混ざっていたのではなく入れ子になっていました。そしてこの2つは、そもそも比較して選ぶような関係ではありませんでした。当たり前と言われればそれまでなのですが、自分はしばらく気づかずに悩んでいたので、整理して残しておきます。
この記事の内容は次のとおりです。
- 何に悩んでいたのか
- 図で見ると、切る向きが違うだけ
- この2つは、答えている問いが違う
- 実際にどうなっていたか
- なぜ外側を「機能」にしたのか
- 正直な話、内側の境界は言語では守られてない
- 「レイヤードを選んだから、外側はモジュールで分けない」ではない
- 参考
何に悩んでいたのか
アーキテクチャの記事を読んでいると、だいたいこの2つの構成が出てきます。
A. レイヤーでディレクトリを切る
src/
├── controllers/
│ ├── chat.go
│ ├── matching.go
│ └── topic.go
├── services/
│ ├── chat.go
│ ├── matching.go
│ └── topic.go
└── repositories/
├── chat.go
├── matching.go
└── topic.go
B. 機能でディレクトリを切る
src/
├── chat/
│ ├── controller.go
│ ├── service.go
│ └── repository.go
├── matching/
│ ├── controller.go
│ ├── service.go
│ └── repository.go
└── topic/
├── controller.go
├── service.go
└── repository.go
Aを「レイヤード」、Bを「モジュラーモノリス」と呼んで、どちらかを選ぶものだと思っていました。これが間違いでした。
よく見てほしいのですが、AとBに出てくるファイルはまったく同じ9個です。違うのは並べ方だけです。Bにもcontroller / service / repositoryは存在しています。つまりBは「レイヤードをやめた構成」ではありません。
図で見ると、切る向きが違うだけ
コードを9個のマスとして並べてみます。縦がレイヤー、横が機能です。
chat matching topic
Controller 1 2 3
Service 4 5 6
Repository 7 8 9
この9個をどう束ねるか、という話でした。
レイヤードは、横線で切ります。
chat matching topic
==========================================
Controller 1 2 3
==========================================
Service 4 5 6
==========================================
Repository 7 8 9
==========================================
controllers/ の中に3機能ぶんのコントローラーが同居します。壁は層と層の間にあり、機能と機能の間には壁がありません。chatのサービスからmatchingのサービスは、すぐ隣にいて丸見えです。
モジュラーモノリスは、縦線で切ります。
chat || matching || topic
|| ||
Controller 1 || 2 || 3
Service 4 || 5 || 6
Repository 7 || 8 || 9
|| ||
chat/ の中にコントローラーもサービスもリポジトリも入ります。壁は機能と機能の間にあり、層と層の間には壁がありません。
ここで大事なのは、9個のマスは1つも動いていないということです。移動したのは壁の向きだけ。だから「どちらかを選ぶ」という発想がそもそも噛み合っていませんでした。
そして、壁は1種類でなくてもかまいません。実際のコードはこうなっていました。
chat || matching || topic
|| ||
Controller 1 || 2 || 3
------------||--------------||----------
Service 4 || 5 || 6
------------||--------------||----------
Repository 7 || 8 || 9
|| ||
|| = 機能どうしを隔てる壁(モジュラーモノリス)
-- = 層どうしを隔てる仕切り(レイヤード)
縦に太い壁を立てて、その内側を横の仕切りで分ける。二重構造です。この壁と仕切りの「太さの違い」は後でまた出てきます。
この2つは、答えている問いが違う
マンションで例えると、もう少し腑に落ちると思います。
モジュラーモノリスは「1棟を何戸に区切り、壁をどこに立てるか」の話です。
3戸に区切るのか5戸に区切るのか。そして重要なのは、戸と戸の間に壁を立てるということです。隣の住人が勝手に入ってきて冷蔵庫を開けたりできないようにする。中で何をしていようと、他の住戸には影響しない。この「勝手に触れない」状態を作るのが目的です。
レイヤードは「その1戸の中を、玄関・リビング・水回りにどう分けるか」の話です。
靴を脱ぐ場所と料理をする場所と寝る場所を分けておかないと生活しづらい。でもこれは1戸の内側の都合であって、隣の住戸には一切関係ありません。
さて、ここで気づきます。
「このマンションは3戸に区切ります」と「各戸は1LDKにします」は、同時に決められます。
「3戸に区切るか、1LDKにするか」という質問は成立しません。粒度が違うからです。自分が悩んでいたのは、まさにこの成立しない質問でした。
なお、各戸の間取りは揃っていなくても建物としては成立します。ただ、全戸が同じ間取りになっていれば、住む人も内見する人も迷いません。これは後の話につながります。
整理するとこうなります。
| モジュラーモノリス | レイヤード | |
|---|---|---|
| 何を決めるもの? | システム全体をどの単位に分割するか | ひとつの部品の中で責務をどう分けるか |
| 粒度 | システム全体 | モジュール1個の中 |
| 動機 | 機能同士が絡まらないようにしたい | 入力・ロジック・保存を混ぜたくない |
ちなみに、マイクロサービスでも各サービスの中はレイヤードに書きます。「マイクロサービスかレイヤードか」とは誰も言いません。同じことでした。
実際にどうなっていたか
自分のコードはこうなっていました(Goですが、構造の話なので言語は気にしなくて大丈夫です)。
internal/
├── chat/ ← モジュール境界
│ ├── http.go コントローラー
│ ├── chat.go ドメイン / サービス
│ ├── repository.go インターフェース定義
│ ├── postgres_repository.go その実装
│ ├── store.go インターフェース定義
│ └── redis_store.go その実装
├── matching/ ← 同じ内部構造
├── topic/ ← 同じ内部構造
├── report/
└── moderation/
外側の区切りが機能で、内側の区切りがレイヤー。きれいに入れ子になっています。混乱していたのは、設計書に「モジュラーモノリス」としか書かれておらず、内側の話が一言も書かれていなかったからでした。
ちなみに本当に「混ざっている」状態というのは、こうなっている場合を指します。
internal/
├── chat/ ← 機能で割ったディレクトリ
├── matching/
└── services/ ← レイヤーで割ったディレクトリが同じ階層に同居
これは新しいコードをどっちに置くか毎回迷うので、素直によくありません。判断基準の違うディレクトリが同じ階層に並ぶと壊れる、というだけの話です。
なぜ外側を「機能」にしたのか
では、外側の軸として機能を選んだのはなぜか。設計書には書いていなかったので、改めて言語化してみました。
1. 将来切り出すときの単位になる
一番大きいのはこれでした。個人開発なのでマイクロサービスにするつもりはありませんが、たとえばモデレーション(不適切投稿の検知)だけ負荷が突出したら、そこだけ別サービスに切り出したくなるかもしれません。
レイヤーで割っていると、モデレーションのコードは controllers/ services/ repositories/ の3箇所に散らばっています。切り出すには全部のディレクトリを漁って回収する必要があります。機能で割っていれば、moderation/ フォルダごと持っていけば済みます。
分割したくなる単位で、あらかじめ割っておく。 これに尽きます。
2. 言語のアクセス制御が効く
ここはGoの話が少し入るので補足しておきます。
Goではディレクトリ1つがパッケージ1つで、アクセス制御がパッケージ単位で効きます。識別子が小文字で始まっていれば同じパッケージからしか見えず、大文字なら外からも見えます。JavaのパッケージプライベートやRustのモジュールに近い仕組みです。
つまり機能で割っておくと、chat パッケージから report パッケージの内部実装はそもそも見えません。先ほどの図で言えば、|| の壁が実際に立っている状態です。境界をコンパイラが守ってくれます。
逆にレイヤーで割ると、全機能のサービスが1つの services パッケージに同居することになるので、機能同士の境界が言語レベルではゼロになります。chatのサービスからreportの内部をいくらでも触れてしまう。壁のないワンルームに3世帯で住むようなものです。
JavaやC#のようにDIコンテナ文化が強い言語だとレイヤー割りにも旨味がありますが、Goだとこの理由でかなり分が悪いです。「言語が守ってくれる境界を、どの軸に使うか」という選択だったと言えます。
3. 「共通のインフラ層」など実在しなかった
実装が進んでから気づいたのですが、モジュールごとに永続化の形がまったく違っていました。
- チャット … PostgreSQL + Redis Pub/Sub + WebSocket接続の管理
- マッチング … Redisのソート済みセット + PostgreSQL
- トピック一覧 … PostgreSQL + プロセス内キャッシュ
- モデレーション … 永続化なし(1ファイル、純粋なロジックのみ)
共通の「リポジトリ層」というものが、そもそも存在していませんでした。ここで repositories/ というディレクトリを作っていたら、中身がバラバラな上に1つは空という、名前だけの箱になっていたはずです。
さらに、WebSocketの接続管理のようなリクエストとレスポンスで完結しない常駐オブジェクトは、コントローラー / サービス / リポジトリのどの引き出しにも入りません。リアルタイム通信を含むサービスをレイヤー割りしようとすると、おそらくここで最初に破綻します。
正直な話、内側の境界は言語では守られてない
きれいにまとめたくなるところですが、ひとつ都合の悪い事実があります。
先ほどの図で -- と書いた仕切りは、実体がありません。
chat.go(ロジック)と postgres_repository.go(SQLを書く場所)は、同じディレクトリにある = Goでは同じパッケージです。ファイル冒頭の宣言を書き出すと、こうなっています。
internal/
├── chat/ ← ここに壁がある
│ ├── http.go package chat ┐
│ ├── chat.go package chat │ Goから見ると
│ ├── repository.go package chat │ この4ファイルは
│ └── postgres_repository.go package chat ┘ 「1つのかたまり」
│
└── report/ ← ここにも壁がある
├── http.go package report
└── report.go package report
ファイルが分かれていても、package chat と書かれている限りすべて同じパッケージです。同じパッケージの中では、小文字始まりの非公開な関数や変数にも互いに自由に触れます。chat.go から見て postgres_repository.go は「隣のファイル」ではなく「自分の一部」なのです。
なので、やろうと思えばこう書けてしまいます。
// internal/chat/chat.go
package chat
type Service struct {
repo Repository
pool *pgxpool.Pool // ← フィールドを1つ足すだけで
}
func (s *Service) End(ctx context.Context, id string) error {
// repo を通さず、ロジックのファイルに直接SQLを書ける
_, err := s.pool.Exec(ctx,
"UPDATE conversations SET ended_at = now() WHERE id = $1", id)
return err
}
リポジトリ層を飛ばしていますが、コンパイルは通ります。レビューで気づかれなければ、そのままマージされます。
方向も問いません。postgres_repository.go から Service のメソッドを呼ぶ、という「下位が上位を呼ぶ」禁じ手も書けてしまいます。レイヤーごとにパッケージが分かれていれば循環参照でコンパイルエラーになるところですが、同じパッケージだとその安全装置がありません。
一方、モジュールをまたぐ違反はこうなります。
// internal/chat/chat.go
package chat
import "github.com/yosuke318/anontopic/internal/report"
func (s *Service) End(ctx context.Context, id string) error {
req := report.submitRequest{} // ← コンパイルエラー
...
}
submitRequest は小文字始まりなので、report パッケージの外からは存在すら見えません。こちらはビルドが失敗するので、コミットまで辿りつけません。
(正確に言えば、大文字始まりで公開している型は他モジュールからもimportできてしまうので、モジュール境界も完全に自動で守られるわけではありません。それでも「非公開にしておけばコンパイラが止めてくれる」手段があるだけ、内側よりはるかにマシです。)
整理するとこうなります。
| 境界 | 誰が守るか | 破ったら |
|---|---|---|
| モジュール間(chat ↔ report) | コンパイラ | ビルドが通らない |
| モジュール内のレイヤー | 規律とレビューのみ | 普通に動いてしまう |
外側の境界は言語がタダで守ってくれますが、内側の境界は人間が守るしかありません。マンションで言えば、戸境の壁はコンクリートで立っているけれど、玄関とリビングの境目は床にテープが貼ってあるだけ、という状態です。
そしてこれが、自分がそもそも混乱していた原因でもありました。うちのCONTRIBUTING.mdにはモジュール間のルール(水平方向)しか書かれておらず、レイヤーのルール(垂直方向)が1行もなかったのです。
- 「他モジュールのDBモデルを直接importしない」 ← 書いてあります
- 「コントローラーからDBを直接触らない」 ← 書いていません
書いていないのに全モジュールが同じ内部構造で揃っていたのは、単に最初の1つに倣って書き続けていたからでした。先ほど「全戸が同じ間取りなら住む人が迷わない」と書きましたが、その間取りを揃える根拠が、今のところどこにも存在していません。新しく入った人、あるいは半年後の自分が参照できるものがない状態です。
言語が守ってくれない境界こそ、文書に書く価値があります。逆になっていました。
「レイヤードを選んだから、外側はモジュールで分けない」ではない
最後に、この記事で一番伝えたかったことを書きます。
アーキテクチャスタイルを選ぶとき、「レイヤードでいく」と決めた瞬間に、ディレクトリを controllers/ services/ repositories/ で切ることまで自動的に決まる、と思い込んでいませんか。自分はそう思っていました。だから「レイヤードか、モジュラーモノリスか」という二択に見えていたわけです。
でも実際には、モジュールで分けて、その下をレイヤード構成にするという組み方ができます。
- 外側: 機能でモジュールに分ける(モジュラーモノリス)
- 内側: 各モジュールの中をレイヤードにする
こうすると、レイヤードの「入力・ロジック・保存を混ぜない」という利点をそのまま持ったまま、機能同士の境界も手に入ります。どちらかを諦める必要はありません。
そして順番としては、先に外側を決めるほうが取り返しがつきます。内側のレイヤー構成はモジュール単位で後から直せますが、外側の軸を後から変えるのは全ファイルの引っ越しになるからです。実際、モジュールごとに内部構造が違っていても、外側さえ分かれていればシステムとしては破綻しません。
「レイヤードにするか、モジュラーモノリスにするか」ではなく、「外側をどの軸で切って、その内側をどう分けるか」。この順番で考えると、選択肢がぐっと増えると思います。
ご自身が運用・開発しているサービスがどんなアーキテクチャスタイルを採用しているか見直してみると、そのサービスへの理解が一段深まります。よかったら確認してみてはいかがでしょうか。
参考
- ソフトウェアアーキテクチャの基礎 ― エンジニアリングに基づく体系的アプローチ(Mark Richards、Neal Ford著)