アプリケーションアーキテクチャ

Wippy アプリケーションはソースファイルのツリーではありません — それはレジストリエントリのグラフです。コードは function.luaprocess.lua のエントリに存在し、それらを結び付けるすべて — どの関数が HTTP ルートに応答するか、サービスがどのプロセスを監督するか、どのライブラリがどれをインポートするか — は _index.yaml で宣言されます。アプリを構造化するとは、そのグラフが成長しても構成可能・テスト可能・起動可能であり続けるように、グラフを名前空間へどう分割するかを決めることです。

このページはレイアウトの背後にある考え方を扱います。機械的なルール(ファイル形式、命名、_index.yaml の配置場所)については YAML & プロジェクト構造を、エントリ種別そのものについてはエントリ種別ガイドを参照してください。

単位はスライス

ファイルの種類ではなく、機能で整理します。スライスは 1 つのケイパビリティをエンドツーエンドで所有し — そのデータベースアクセス、長時間稼働するプロセス、HTTP サーフェス、そしてそれらが共有する語彙 — 1 つの名前空間プレフィックスの下に存在します:

src/app/jobs/          namespace: app.jobs
src/app/auth/          namespace: app.auth
src/app/billing/       namespace: app.billing

その代替案 — トップレベルの handlers/models/services/ という分割 — は、すべての機能をツリー全体に散在させ、近接によって互いを結合させます。スライスは機能の影響範囲を 1 つのフォルダの中に閉じ込めます。プロジェクト全体で参照を追いかけることなく、読むことも、テストすることも、削除することもできます。

スライス内のレイヤー

スライスの内部は、外部の世界に触れるものという軸に沿って分割します。これはポーツ・アンド・アダプターズ(ヘキサゴナル)アーキテクチャを、サブ名前空間として表現したものです:

src/app/jobs/                  namespace: app.jobs          ← shared vocabulary
  consts.lua  config.lua  types.lua
  persist/                     namespace: app.jobs.persist  ← database adapters (sql)
  service/                     namespace: app.jobs.service  ← processes, workers
  api/                         namespace: app.jobs.api      ← http.endpoints

インポートは一方向のみ、最外層から最内層へ流れます:

api  →  service  →  persist  →  { consts, config, types }

スライスのルート(共有語彙)は自身の子から何もインポートしません。子はルートをインポートします。どのレイヤーも上に手を伸ばさず、スライスが別のスライスを直接インポートすることもありません — スライス間の共有は共通の親名前空間(例:app.core:types)を経由し、決して横方向には行いません。

名前空間の境界は見た目のためではありません。それはランタイムが依存関係を注入し、起動順序を解決する継ぎ目です。インポートの方向こそが、有効な起動順序の存在を保証するものです — なぜこの形なのかを参照してください。

より小さなスライスでは形式を畳み込めます — ライブラリと 1 つのエンドポイントを持つ単一の _index.yaml で構いません。どの規模でも生き残るルールは、フォルダの数ではなくインポートの方向です。

共有語彙

よく構造化されたスライスのルートには 3 つのファイルが繰り返し現れます。それらは、すべてのレイヤーが読むが、どのレイヤーでもないものを保持します:

ファイル 保持するもの ケイパビリティ
consts.lua ステートマシン、列挙、キューの階層、プロセスのレジストリ ID。データベースの CHECK 制約を反映する値。 なし
config.lua コード上のデフォルトにフォールバックする、環境変数で調整可能なノブ(env.get(KEY) or DEFAULT)。値を任意にするために env.variable エントリを必要としない。 env
types.lua エンティティの形(type Job = { ... })— 永続化レイヤーが返す行。 なし

conststypesホストケイパビリティを一切宣言しません — テーブルを返す純粋な library.lua です。これは意図的です:ドメイン語彙は I/O を実行できないため、ビジネスロジックへと漂流することがなく、データベースもプロセスホストもなしにユニットテストできます。

この語彙はスライスプライベートに保ちます。スライス間で共有される定数と型は共通の親に置き、そこへのインポートを通じて参照します — 決して各スライスへコピーしません。

ケイパビリティはレイヤーごとに揃う

各エントリは必要なホストケイパビリティを modules: で宣言します。レイヤー化されたスライスでは、これらはきれいに揃います:

  • persist/*sql を宣言 — そしてそれ以外はデータベースアクセスを得ません。
  • service/*channel とプロセスホストのケイパビリティを宣言 — そしてそれ以外はスポーンも監督もしません。
  • api/* はエンドポイントがリクエストを整形するのに必要なものを宣言します。
  • ルートの語彙は何も宣言しません。

その見返りは、あらゆるケイパビリティの影響範囲がちょうど 1 つのレイヤーに収まることです。データベースに書き込めるすべてのコードを知りたければ、persist/ を読めばよいのです。依存関係逆転は抽象的な原則であることをやめ、grep できるプロパティになります。

アプリケーションとコンポーネント

同じ形は、誰が穴を埋めるかだけを変えることで、単一のアプリから公開ライブラリまでスケールします。

アプリケーションは、トップレベルのデプロイ可能なグラフです。具体的なインフラストラクチャ — http.serviceprocess.host、データベース接続 — をルート名前空間(慣例として app)の下に所有し、すべてを自分で配線します。

コンポーネントは、ホストにマウントされる公開可能なモジュールです。ホストのデータベースやルーターを知らないため、それらを名指しできません。代わりに穴のインターフェースns.requirement エントリ — を宣言し、ホストがコンポーネントに依存するときにそれを埋めます。内部的には、コンポーネントはアプリケーションスライスとまったく同じ構造です:同じレイヤー、同じ語彙、同じインポート方向。唯一の追加は、その縁にある要件インターフェースです。

これは 2 つのカテゴリではなくスペクトラムです:

  • 単一アプリ、内部スライス — スライスは src/app/ の下に存在し、app:dbapp:processes を参照してアプリのインフラストラクチャを直接共有します。要件インターフェースは不要です。外部から何もマウントされません。(フォーカスされたサービスはこのように構築します。)
  • マルチコンポーネント構成 — 各コンポーネントは、ns.definitionns.requirement インターフェースを持つ独立した公開可能なモジュールで、ホストが ns.dependency を通じて構成します。ホストは各要件(データベース、プロセスホスト、ルーター)を一度だけ埋めます。(再利用可能なパーツのプラットフォームはこのように構築します。)

スライスが自分の管理下にないものに消費されることを意図しているかどうかで選択します。そうであれば、要件インターフェースを与えて公開します。そうでなければ、アプリのインフラストラクチャを直接参照させ、形式を省きます。レイヤリングは両端で不変です。再利用に応じてスケールするのはパッケージングです。

要件/依存関係のメカニズムについてはコンポーネントの構築を、ロックファイル側については依存関係管理を参照してください。

なぜこの形なのか {#why-this-shape}

上記の規律はスタイルではありません。各ルールは、ランタイムがグラフを構成し起動する方法にとって、荷重を支える存在です:

名前空間の境界は注入の継ぎ目です。 レイヤーは明示的な imports: のみを通じてリンクし、別々の名前空間に存在するため、ns.requirement メカニズムには注入する具体的なターゲットがあります — ホストは自分のデータベースを persist レイヤーのエントリに、プロセスホストを service レイヤーのエントリに向けます。もし persistapp:db を直接つかんでいたら、そのコンポーネントは別のホストにマウントできません:埋めるべき穴が存在しないからです。レイヤリングこそが、コンポーネントを再配置可能にするものです。

一方向のインポートは、起動順序の存在を保証します。 ランタイムは起動時にエントリグラフを解決し、トポロジカル順序を見つけなければなりません。api → service → persist → root、決して横にも上にも向かわない — これはグラフが構造上非巡回であることを意味します。共有の親を経由してルーティングされるスライス間の結合は、ローダーが順序付けできないサイクルへとスライスを絡ませる代わりに、各スライスを独立してマウント可能に保ちます。

レイヤーごとにスコープされたケイパビリティは、影響範囲を限定します。 ホストケイパビリティはエントリ単位で付与されます。persist のみが sql を宣言していれば、データベースに到達できるコードの集合は 1 つのディレクトリであり、一目で監査できます — アプリ全体から創発する性質ではありません。

レイヤリングはテスト容易性の勾配を生みます。 純粋な語彙は外部の世界なしにテストできます。persist のテストはデータベースには触れますが、ワーカーには触れません。そしてモジュール全体のマウントテストが、ユニットテストが意図的に見ない継ぎ目を監査します — 監督されるすべてのサービスが実在のプロセスを指していること、スポーンされるすべての ID が解決されること、すべての要件が埋まっていること。この勾配が得られるのは、レイヤーが実際に分離可能な場合だけです。

短く言えば:ここでのヘキサゴナルなレイヤリングは、要件注入、レイヤー単位のケイパビリティスコープ、非巡回な起動解決のすべてが同時に成立する唯一の形です。ランタイムの構成モデルが機能するには、ポーツ・アンド・アダプターズの分割が必要なのです — この規律こそが、起動するグラフと、他者がマウントできるコンポーネントを買い取る対価です。

関連項目