Nguyen Le Phong

ソフトウェアアーキテクチャの基礎全 6 回中第 4 回

コードベースの構成:レイヤー・フィーチャー・ドメイン、どれを選ぶ?

トップレベルのフォルダーを controllers/services/models にすべきか、orders/billing/auth にすべきか?この選択はコードベースの成長を静かに形作ります。レイヤー別・フィーチャー別・Screaming Architecture を実践的に比較し、会社の規模ごとのトレードオフを解説します。

新しいプロジェクトを開いて、ロジックを一行も書く前に最初にすることは――フォルダーを作ることです。controllers/ と名付けるかもしれません。orders/ かもしれません。どちらにしても、その静かな選択はすでにアーキテクチャの決定であり、その後のすべてを静かに形作ります:新しいチームメンバーがどれほど速く足場を見つけるか、一つの機能が変わったときにいくつのファイルを触るか、そして六ヶ月後にコードベースがあなたのビジネスのように読めるかフレームワークのように読めるかです。

この記事は三つの主なアプローチ――レイヤー別フィーチャー別叫ぶ(ドメイン別)――の実践的なツアーです。各会社の規模ごとの正直なトレードオフを添えています。一つの答えがすべての状況に勝つわけではありませんが、デフォルトではなく意図的に選択するための明確なメンタルモデルを持ってここを離れられるはずです。

フォルダー構造がアーキテクチャの決定である理由――細かい話ではない

人々はフォルダー構造を「ただの整理」と軽視します。それははるかに多くのことを意味します。コードをどうグループ化するかは、非常に重要であることがわかる三つのことを制御します:

  • カップリング。一緒に変わるコードが遠く離れて住んでいると、小さな更新が毎回フォルダーをまたいだ遠征になります。一緒に住んでいれば、関連する変更は一つの場所にまとまります。
  • 発見可能性。コードベースに不慣れな開発者は「課金コードはどこにあるか?」と尋ねます。その答え――一つのフォルダー、または五つの散らばったフォルダー――がオンボーディング時間を決めます。
  • 影響範囲。新しい機能を追加したり古い機能を安全に削除したりするためにいくつのファイルを開かなければならないか?構造はその影響範囲を制限するか、制御なく広がるままにするかのどちらかです。
メンタルモデル

フォルダー構造を地図として考えてください。良い地図は、何でできているかではなく、現実世界でどう関連するかでものをグループ化します。ファイルをタイプ別(コントローラー・モデル)にグループ化するのは、地図のアイコンを形で並び替えるようなものです。フィーチャー別にグループ化するのは近所で並び替えるようなものです。一方はナビゲートするのに役立ちます;もう一方は技術的には正しいですが実践的には役に立ちません。

ソフトウェアシステムは、それを構築したチームのコミュニケーション構造を反映するという Conway の法則もここに適用されます。チームがビジネス機能(決済スクワッド・注文スクワッド)を中心に組織されているなら、フィーチャー別の構造は自然に感じられ、正しいバウンダリーを強制します。レイヤー別の構造はあなたと戦うことになります。

レイヤー別:おなじみのデフォルト

レイヤー別の整理は、技術的な役割でファイルをグループ化します。典型的な Node や Spring Boot プロジェクトはこのような形になります:

src/
├─ controllers/
│  ├─ OrderController.ts
│  ├─ UserController.ts
│  └─ BillingController.ts
├─ services/
│  ├─ OrderService.ts
│  ├─ UserService.ts
│  └─ BillingService.ts
├─ repositories/
│  ├─ OrderRepository.ts
│  ├─ UserRepository.ts
│  └─ BillingRepository.ts
└─ models/
   ├─ Order.ts
   ├─ User.ts
   └─ Invoice.ts
レイヤー別の構造:各技術レイヤーが独自のトップレベルフォルダーを持ちます。機能を追加するには四つすべてのフォルダーにファイルを追加します。

すべてのWebフレームワークのチュートリアルがここに行き着きます。Rails もそうです。Spring Boot のスターターもそうです。Express のジェネレーターもそうです。そのなじみ深さが主な資産です。

レイヤー別が輝く場所:

  • 二〜三の機能を持つ小さなアプリ――構造が非常にフラットなので、ほとんど問題になりません。
  • 全員がすべての機能をまたいで作業するチーム;レイヤーが専門化の単位です。
  • 初期セットアップのスピードが長期的な明確さに勝るプロトタイプや内部ツール。

レイヤー別が痛む場所:

  • 一つの機能がフォルダー全体に散らばっている。「払い戻し」機能を追加するには controllers/services/repositories/models/ を触る必要があります。概念的には一つのことなのに四つのフォルダーです。
  • 機能を削除することが考古学の作業になる。services/ のどのファイルが課金に属し、どれが注文に属するか、どうやってわかりますか?フォルダー名は教えてくれません。
  • マージコンフリクトが増える。すべての機能変更が同じレイヤーレベルのファイルを触り、二人の開発者が並行して作業するときに衝突が起きます。
スケーリングの崖

レイヤー別は10〜15の機能まで問題なく機能します。それを超えると、各トップレベルフォルダーはがらくた引き出しになります:40ファイルの services/ フォルダー、明確なオーナーシップなし、レビュアーが以前に見たことのないファイルに触れるプルリクエスト。

フィーチャー別:変更は一つのフォルダーに収まる

フィーチャー別の整理は、それが担うプロダクトのスライスでファイルをグループ化します。技術レイヤー(コントローラー・サービス・モデル)は依然として存在します――ただし、トップレベルではなく各フィーチャーフォルダーの内部に住みます:

src/
├─ orders/
│  ├─ OrderController.ts
│  ├─ OrderService.ts
│  ├─ OrderRepository.ts
│  └─ Order.ts
├─ billing/
│  ├─ BillingController.ts
│  ├─ BillingService.ts
│  ├─ BillingRepository.ts
│  └─ Invoice.ts
├─ auth/
│  ├─ AuthController.ts
│  ├─ AuthService.ts
│  └─ User.ts
└─ shared/
   ├─ database.ts
   ├─ logger.ts
   └─ Money.ts
フィーチャー別の構造:各プロダクト概念が独自のフォルダーを持ちます。払い戻し機能を追加しても一つのフォルダーが追加されるだけで、他は何も動きません。

フィーチャー別が輝く場所:

  • 変更は一つのフォルダーに収まる。払い戻しフローを追加するには billing/ 内にファイルを追加します――他のフォルダーは触られません。影響範囲は小さく封じ込められています。
  • 機能の削除がシンプル。フォルダーを削除します。外から何かがまだインポートしているなら、IDEがすぐに教えてくれます。
  • オーナーシップが明白。決済チームが billing/ を所有し、アイデンティティチームが auth/ を所有します。モノレポのツールとコードレビュールールがこれを機械的に強制できます。
  • オンボーディングが速い。注文に取り組む新入社員は orders/ だけを読みます。最初にコードベース全体を理解する必要はありません。

フィーチャー別が注意を要する場所:

  • 共有コードの配置が厄介。Money 型は orders/billing/ の両方で使われます。orders/ に置くのは違和感があります。結局 shared/ に入りますが、誰も所有しなければそれ自体が第二のがらくた引き出しになりえます。
  • フィーチャーをまたぐ依存には規律が必要。orders/OrderService.tsbilling/BillingService.ts から直接インポートすると、フォルダー構造が見えないカップリングを作ってしまいます。チームはリンティングルール(eslint-plugin-import/no-restricted-paths)やモジュール境界ツール(Nx・NestJS モジュール)を使って、これらを明示的にします。

Screaming Architecture:トップレベルは何を語るか?

Uncle Bob(Robert C. Martin)は有名なブログ投稿で Screaming Architecture という用語を作りました:「あなたのアプリケーションのアーキテクチャは何を叫んでいますか?トップレベルのディレクトリ構造と最上位パッケージのソースファイルを見たとき、それは叫んでいますか:ヘルスケアシステム、または会計システム、または在庫管理システム?それとも叫んでいますか:Rails、またはSpring/Hibernate、またはASP.Net?」

洞察はエレガントです:トップレベルのフォルダーが controllers/models/views/ なら、コードベースは MVCフレームワークを叫んでいます。フレームワークはツールです――読者が最初に出会うべきものではありません。

Screaming Architecture(ドメイン別とも呼ばれる)はフィーチャー別をもう一歩進めます。機能がトップレベルにあるだけでなく――それらの機能の名前は技術的な役割ではなく、ビジネス言語(ドメイン駆動設計のユビキタス言語)から直接引かれています:

// 「eコマースプラットフォーム」を叫ぶ
src/
├─ catalog/          // ドメイン概念:商品カタログ
├─ ordering/         // ドメイン概念:注文の配置と追跡
├─ fulfillment/      // ドメイン概念:ピッキング・梱包・出荷
├─ payments/         // ドメイン概念:請求・返金・請求書
└─ identity/         // ドメイン概念:アカウント・認証・権限

// フレームワークを叫ぶ代替案と比較:
src/
├─ controllers/      // 「MVC」を叫ぶ
├─ models/           // 「ORM」を叫ぶ
├─ views/            // 「テンプレートエンジン」を叫ぶ
└─ services/         // 「サービスレイヤーパターン」を叫ぶ

各ドメインフォルダーの内部レイアウトはチームに任されています――それで構いません。肝心なのは、トップレベルがインフラストラクチャではなくビジネスを伝えることです。新しい開発者(あるいはCTO・ビジネス側のドメインエキスパート)がトップレベルのフォルダーを読んで、どうやって作られているかを知らなくてもシステムが何をするかを理解できれば、成功です。

並べて比較

五つの実践的な次元にわたる正直な評価です:

次元 レイヤー別 フィーチャー別 ドメイン別(叫ぶ)
発見可能性 スケールで低い――「課金コードはどこ?」に速い答えがない 高い――概念ごとに一つのフォルダー 最高――フォルダー名はビジネス自体から来る
変更の局所性 低い――一つの機能変更がすべてのレイヤーに散らばる 高い――変更は一つのフォルダーにまとまる 高い――フィーチャー別と同じで、加えて明示的なドメイン言語
オンボーディング速度 遅い――新メンバーは何かに触る前にレイヤー全体を理解しなければならない 速い――一つの機能を作業するために一つのフォルダーを学ぶ 最速――ドメイン名が事前の文脈なしに理解を導く
カップリングリスク 高い――共有レイヤーが暗黙のカップリング面になる 中程度――フィーチャーをまたぐインポートは可能だが見える 低い――明示的な境界付けられたコンテキストと強制されたバウンダリー
「大きな泥団子」のリスク 高い――機能が増えるにつれてレイヤーがごちゃ混ぜ引き出しになる 中程度――shared/ フォルダーが無制限に成長しうる 低い――ドメインバウンダリーが泥を早期に見えるようにする

良いチームがたどり着く実用的なハイブリッド

実際には、優れたチームは一つのアプローチを選んで宗教的な一貫性ですべてに適用するわけではありません。実用的なハイブリッドを使います:トップにフィーチャーまたはドメインフォルダー、その中に小さな技術レイヤー

ハイブリッド構造:トップにドメイン/フィーチャーフォルダー、その中に技術レイヤー。 PRAGMATIC HYBRID — domain on top, layers inside src/ orders/ billing/ shared/ controller.ts service.ts repository.ts Order.ts controller.ts service.ts repository.ts Invoice.ts Money.ts logger.ts database.ts
ほとんどのチームがたどり着くハイブリッド:トップレベルにドメイン/フィーチャー、次にその中に小さな技術レイヤー。技術レイヤーはまだそこにあります――ただし最初に目にするものではなくなりました。

このハイブリッドは両方の良いところを手に入れます:

  • トップレベルはビジネスを叫びます――新メンバーはすぐにこれがeコマースアプリであり、一般的なMVCのスケルトンではないことを知ります。
  • 各フィーチャーが自己完結しています――課金フローへの変更は billing/ だけを触ります。
  • 技術レイヤーは各フォルダーの内部に依然として存在しますので、MVCや Clean Architecture レイヤーで考える開発者は迷いません。フィーチャーフォルダーの内側を見るだけです。
  • shared/ フォルダーが本当に横断的な関心事(Money 値オブジェクト・ロガー・データベース接続)を保持します。肝心なルール:shared/ に何かを追加する場合、それは逃げ道として捨てるのではなく、意図的に抽出・所有されるべきです。
共有フォルダーの命名

一部のチームは shared/・他は common/lib/・または core/ と呼びます。名前は規律よりも重要ではありません:ミニ内部ライブラリとして扱ってください。特定の機能と一緒に常に変更されるファイルがあれば、それは shared に属さず、その機能の内部に属します。

チームのスケールで:強制されたバウンダリーとモノレポ

コードベースが成長して複数のチームが貢献するようになると、フォルダーの慣習だけでは不十分です――開発者はいつでも相対インポートでバウンダリーをまたぐことができます。次のステップはバウンダリーを強制することです:

  • NestJS モジュールはモジュール間の依存を明示的にします:別のモジュールが exports するものへのアクセスだけが得られます。パブリックAPIを通らないモジュール境界をまたぐインポートはコンパイルエラーです。
  • Nx のバウンダリールールで、どのフィーチャーまたはライブラリが他のどれに依存することを許可されるかを宣言し、CIのリントエラーとして強制できます。
  • モノレポはこれを最も遠くまで進めます:各ドメイン(注文・課金・認証)が独自の package.json・独自のテスト・明示的に公開されたエクスポートを持つ独自パッケージになります。Turborepo と Nx が JavaScript エコシステムでこれを管理する主要なツールです。構造はフォルダーツリーよりパッケージのグラフに近くなります――しかし根本的なアイデアは同じです:ビジネス機能別にグループ化し、バウンダリーを明示的にし、機械的に強制する。

モノレポへの移行が常に必要なわけではありません。多くのチームは単一パッケージとよく規律されたハイブリッド構造で何年もうまくやっています。クロスチームのカップリングが本当の痛みになったとき――予防的にではなく――モノレポツールに手を伸ばしましょう。

会社の規模ごとの推奨

適切な構造は、今日あなたの組織がどこにあるかによります。各ステージへの直接的な推奨です:

ステージ 使うべき構造 理由 実例のヒント
ソロ / ローンチ前スタートアップ レイヤー別または浅いフィーチャー別 機能が3〜5個。どの構造でも機能します。スピードとシンプルさを最適化。 ほとんどの Rails/Express チュートリアルのデフォルトがここでは問題ありません。過剰設計しないでください。
成長中スタートアップ(5〜20人のエンジニア) shared/ フォルダーを持つフィーチャー別 機能が増えている。レイヤー別が痛くなり始めている。決済の変更が注文に波及すべきでない。 多くの成功した Series A 企業がここに住んでいます:一つのリポジトリ・一つのサービス・トップレベルフォルダーとしての機能。
スケールアップ(20〜100人のエンジニア) ハイブリッド(トップにドメイン・内部にレイヤー)+バウンダリーリントルール チームがドメインを所有します。偶発的なクロスドメインカップリングが本物のリスクです。CIでバウンダリーを強制。 これが Nx/NestJS モジュールの甘い場所です。Shopify のコア Rails アプリはこのステージで強力なドメイン分離を採用したことで有名です。
エンタープライズ(100人超のエンジニア) ドメインパッケージを持つモノレポ、または境界付けられたコンテキストごとの独立サービス パッケージレベルの分離・独立デプロイ・ドメイン間のバージョン管理されたパブリックAPIが必要になります。 Google・Meta・Airbnb が厳格なオーナーシップを持つ巨大モノレポを運用しています。多くのエンタープライズは反対方向に進みます:ドメインごとのマイクロサービス、これは Screaming Architecture を論理的結論まで進めたものです。
よくある間違い

最も一般的な構造的な間違いは間違ったアプローチを選ぶことではありません――間違ったアプローチに長く留まりすぎることです。チームはレイヤー別で始め、20の機能で痛みを感じ、それでも「構造は技術的には機能している」という理由でリファクタリングしません。15の機能で一午後のフォルダー再編成は、50の機能での何ヶ月もの混乱を防ぎます。

違いを見る:機能を追加したときに何が点灯するか

レイヤー別とフィーチャー別の違いを感じる最も具体的な方法は、問うことです:新しい機能を追加するとき、どのボックスが点灯するか?

レイヤー別:新しい機能は四つのレイヤーボックスすべてを触る。フィーチャー別:新しい機能は一つのボックスだけを触る。 BY LAYER — adding "refunds" controllers/ ← touched services/ ← touched repositories/ ← touched models/ ← touched 4 folders touched high blast radius BY FEATURE — adding "refunds" orders/ billing/ ← touched auth/ shared/ 1 folder touched contained blast radius
「払い戻し」機能を追加するとき。左側(レイヤー別)では四つの技術レイヤーすべてが点灯します――すべてのレイヤーフォルダーが触られます。右側(フィーチャー別)では billing/ だけが点灯します。コードベースの残りは構造的にこの変更から隔離されています。

この図はフィーチャーベースのグループ化に対する最も明確な論拠です。「影響範囲」は抽象的な概念ではありません――それは一つのプルリクエストで開くフォルダーとファイルの数です。レビュアー・CIパイプライン・git blame はすべて、機能変更が一つの凝集したかたまりであり、すべてのレイヤーに薄く塗られたものではないときに、よりうまく機能します。

まとめ

  • 構造はアーキテクチャです。フォルダーレイアウトはカップリング・発見可能性・将来のすべての変更の影響範囲を制御します。
  • レイヤー別はなじみ深く小さなアプリには問題ありませんが、10〜15の機能を超えるとがらくた引き出しになります。
  • フィーチャー別は変更を局所的に保ちます。新しい機能は一つのフォルダーを触ります。機能を削除することは一つのフォルダーを削除することを意味します。
  • Screaming Architectureは、トップレベルがフレームワークではなくビジネスを説明すべきだと言います。非技術的な関係者がフォルダー名を読んでシステムが何をするかを理解できれば、成功です。
  • 実用的なハイブリッドは多くの良いチームがたどり着く:トップにドメイン/フィーチャー名、各フォルダーの内部に小さな技術レイヤー。
  • スケールに合わせてバウンダリーを強制する。フォルダーの慣習は提案です;モジュールシステム・リントルール・モノレポパッケージがそれらを保証にします。
  • 早めに再編成する――15の機能での一午後のフォルダーリファクタリングは、50の機能での何ヶ月もの混乱を避けます。

単一のサービス内でコードをどう構成するかを見てきたので、次の自然な質問は、一つのサービスでは不十分になったときに何をするかです。続きはこちら:モノリス → マイクロサービス