InferDI コアアーキテクチャ宣言
この文書は packages/inferdi 内の @inferdi/inferdi を規定します。公開 API、型システム、get() の解決パス、登録の形、スコープのセマンティクス、クリーンアップの動作に触れる PR をレビューする前に、本書を読んでください。
1. 哲学と約束
ミッション
InferDI は、TypeScript の DI が静的な保証を手放すことなくランタイムの柔軟性を維持できることを証明します。依存グラフは TypeScript の型です。コンパイラがあるルールを検証できるなら、InferDI はそのルールを公開シグネチャにエンコードしなければなりません。ランタイムチェックは、as キャスト、捕捉された外部コンテナ、動的キー、その他 TypeScript がグラフを見られない場面のために存在します。
価値提案
グラフが型です。存在しないキー、誤ったコンストラクター引数の位置、重複した登録、シングルトンからスコープドへの漏れは、本番コードが動作する前に失敗すべきです。InferDI はランタイム契約も小さく保ちます。ランタイム依存なし、デコレーターなし、メタデータリフレクションなし、proxy のトラップなし、そしてコアパッケージにはフレームワークの仕組みもありません。
キャッシュヒットの解決は、単一の Map.get() の高速パスのままです。クラスの構築は、0〜7 個の依存に対してはアリティ展開された直接の new Ctor(...) 呼び出しを使い、8 個以上の依存に対しては計測済みの末尾パスを使います。
ある機能がこれらの約束を弱めるなら、それを拒否するか、コアの外へ移してください。
2. 妥協できない柱
2.1 エンドツーエンドの型安全性
すべての公開シグネチャは、TypeScript がそのルールを表現できる場所で、無効なグラフ状態を表現不可能にしなければなりません。
register*はK & ([K] extends [keyof T] ? never : unknown)を使い、重複キーがコンパイル時に失敗し、問題のキーがエラー内に可視のまま残るようにします。DepsOf<AllowedDeps<T, Kind>, A>は、depsタプルをコンストラクターのパラメーターと位置および構造的代入可能性によって照合します。AllowedDeps<T, Kind>は、ファクトリーへ渡されるコンテナを絞り込みます。シングルトンファクトリーの内部では、c.get('scoped')は型エラーです。Spec、LazySpec、SpecMap、Module、Container.Resolve、Container.ResolveUnwrapped、Container.UnwrappedValue、Container.Providersは契約の一部です。これらへの変更は公開 API の変更として扱ってください。- 新規または変更された公開型のインターフェースには、
packages/inferdi/__tests__/container.test-d.tsにおける肯定的な型テストと、否定的な// @ts-expect-errorテストが必要です。
既知の TypeScript の制限は、隠すのではなく文書化しなければなりません。例えば、同じ構造型を持つ 2 つの依存は、ユーザーが unique symbol キーやブランド化された値型などの名目上の区別を導入しない限り、相互に交換可能なままです。
2.2 ゼロデコレーター、ゼロ reflect metadata
InferDI は ES2022 をターゲットとする素の TypeScript です。デコレーター、reflect-metadata、experimentalDecorators、emitDecoratorMetadata、TS トランスフォーマー、トランスパイラープラグインを追加しないでください。
- コンストラクターの型が依存型の信頼できる情報源です。
- 明示的な
depsタプルが引数順序の信頼できる情報源です。 - ランタイムは、コンストラクターのパラメーター名、生成されたメタデータ、クラスフィールドを調べません。
デコレーターとメタデータは InferDI を別のライブラリに変えてしまいます。これらはランタイム状態、ツールチェーンの要件、コアパッケージが拒否するコールドスタートのコストを追加します。
2.3 ライフタイムは型である
コアには 3 つの登録の種類があります。singleton、scoped、transient です。各登録は、そのライフタイムを Spec<V, Kind> を通じて運びます。
- シングルトンは、スコープドまたはトランジェントのサービスに直接依存してはなりません。
AllowedDeps<T, Kind>はこれをコンパイル時に強制し、strict: trueはキャストと動的登録に対してこれをランタイムで強制します。 Lazy<V>はターゲットのライフタイムを保持します。シングルトンの消費者はLazySpec<V, 'singleton'>のみを注入できます。Lazy<scoped>とLazy<transient>はスコープドおよびトランジェントの消費者には引き続き合法であり、シングルトンの消費者には引き続き不正です。- ランタイムの
Registration.lazyフラグは、ターゲットの種類が'singleton'である遅延コンパニオンに対してのみtrueでなければなりません。 registerValueと.override()の値は外部が所有します。これらはクリーンアップキューに入りません。.override()はテストの脱出口です。元のkindとlazyフラグを保持し、スコープローカルのままにし、未知のキーを拒否し、破棄されたコンテナを拒否し、同じコンテナ上ですでに解決されたキーを拒否しなければなりません。dispose()は、そのコンテナが所有するインスタンスにのみ触れます。親コンテナと子コンテナは互いを破棄しません。
2.4 解決のホットパスは小さく保つ
get() における最初の操作はローカルキャッシュの参照です。
const cached = this.cache.get(key)
if (cached !== undefined) return ...この参照より前に作業を追加しないでください。
- 明示的な
undefined値はUNDEFINED_MARKERで表現されます。キャッシュヒットのパス上に 2 回目のcache.has(key)参照を再導入しないでください。 _disposed、ローカル登録の参照、親の参照、lookupCache、循環チェック、ライフタイムチェック、シングルトンスタックの変更は、すべてキャッシュ高速パスの後に存在します。- ローカル登録は親チェーンの参照より前にチェックされなければなりません。
lookupCacheは親のヒットのみを対象とするコールドパスのメモです。 - コンストラクターの呼び出しは、0〜7 個の引数に対してアリティ展開のままです。8 個以上のパスは、
pushで構築されたパック配列を伴うReflect.constructを使います。 get()は同期のままです。共有のresolving配列とsingletonStackが正しく機能するのは、1 回の解決がコールスタック上でアトミックに実行されるからにほかなりません。strict: falseは、キャッシュ高速パスの後でランタイムの循環チェックとライフタイムチェックを取り除くことができます。観測可能なキャッシュヒットのセマンティクスを変えてはなりません。
packages/inferdi/__tests__/container.bench.ts は CI で強制されません。get()、登録オブジェクトの形、キャッシュの表現、スコープの参照、遅延コンパニオン、コンストラクターの呼び出しへの変更については、レビュアーがベンチマーク出力を要求しなければなりません。関連するシナリオで 5% を超えるローカルのリグレッションは、PR が範囲を限定した書面による正当化を含まない限り、マージを阻止します。
2.5 ゼロランタイム依存
@inferdi/inferdi にはランタイム依存がありません。この状態を保ってください。
公開されるバンドルは gzip 圧縮後 2.5KB 未満を保つべきです。CI は現在この予算を強制していないため、コア実装または公開ヘルパーにコードを追加する PR については、レビュアーがバンドルサイズを確認しなければなりません。
3. PR フィルター
packages/inferdi/src、packages/inferdi/package.json、packages/inferdi/jsr.json、コアテストに触れるすべての PR について、レビュー時に以下の問いに答えてください。
- その変更はコンパイル時のグラフ保証を維持していますか、それとも文書化された TypeScript の制限なしにルールをランタイムチェックへ移していますか?
- それは
get()のキャッシュヒット動作、登録オブジェクトの形、スコープの参照、遅延解決、コンストラクターの呼び出しに触れていますか? もしそうなら、ベンチマークの証拠はどこにありますか? - それはコアパッケージにランタイム依存、デコレーターのサポート、メタデータリフレクション、proxy ベースの解決動作、トランスパイラーの要件を追加していますか?
1 番目が正当な理由なく型ルールをランタイムへ移している、2 番目がベンチマークの証拠を欠いている、または 3 番目が「はい」である場合は、その PR を拒否してください。
4. 厳格管理チェックリスト
以下のいずれかに一致する変更には、PR での明示的な正当化が必要です。
ホットパスとランタイムの形
- [ ]
get()のcache.get(key)より前に作業が追加された? - [ ]
UNDEFINED_MARKER、cache、regs、lookupCache、またはRegistrationの形が変わった? - [ ]
Registrationのプロパティ順序が{kind, lazy, fn}から変わった? - [ ] ローカルレジストリの参照が親の参照より後に移された?
- [ ] 解決に
Proxy、Reflect.get、Object.defineProperty、またはメタデータの参照が追加された? - [ ]
get()がasyncに変換された? - [ ] 0〜7 個のコンストラクター引数のためのアリティ展開分岐が削除または再形成された?
型システム
- [ ]
.override()の外で重複キーガードが弱められた? - [ ] いずれかの公開キー制約で
string | symbolがstringに絞り込まれた? - [ ]
AllowedDeps、LazySpec、またはライフタイムのフィルタリングが弱められた? - [ ]
NoKeyOverlap、Module、SpecMap、または名前空間のヘルパー型が変わった? - [ ]
src/に新たな不健全なany、unknown as、または// @ts-ignoreが追加された? - [ ] 公開型の動作が型テストなしに変わった?
依存とビルド
- [ ]
packages/inferdi/package.jsonにランタイム依存が追加された? - [ ]
reflect-metadata、tslib、またはフレームワークのグルーコードへの peer 依存が追加された? - [ ] レビューの承認なしにバンドル予算を超過した?
- [ ] TS プラグイン、トランスフォーマー、デコレーターフラグ、またはメタデータ生成が必要になった?
ライフサイクルと破棄
- [ ]
dispose()または[Symbol.dispose]()が、disposer の呼び出し前に_disposedを設定しなくなった? - [ ] 状態のクリアが disposer の呼び出し後に移された?
- [ ] 親のデタッチまたは
lookupCacheのクリアが削除された? - [ ] LIFO の破棄順序が変わった?
- [ ] disposer の探索順序が
Symbol.asyncDisposeからSymbol.dispose、.dispose()の順から変わった? - [ ] 複数のクリーンアップ失敗が
AggregateErrorにならなくなった? - [ ] 同期クリーンアップが非同期リソースの誤用を報告しなくなった?
脱出口と動的な利用
- [ ] 最初の解決の後に
.override()が許可された? - [ ]
.override()がkindまたはlazyを保持しなくなった? - [ ]
.has()がリゾルバーになった、またはキャッシュを変更し始めた? - [ ] ランタイムで構築されたキーが主要 API として推進された?
- [ ] コアに自動配線、自動注入、パラメーター名による注入、ファイルシステムのスキャン、またはモジュールの発見が追加された?
5. 意識的なトレードオフ
これらの選択は「修正」するのではなく、文書化してください。
| トレードオフ | 理由 |
|---|---|
| ES5 または ES2022 未満のターゲットなし | Map、Symbol、WeakRef、Reflect.construct、Symbol.dispose、Symbol.asyncDispose は基盤です。このパッケージは、それらを欠くランタイムに対して破棄シンボルのみを polyfill します。Node 16+ が引き続き下限です。 |
| デコレーター API なし | デコレーターベースの DI は別のライブラリです。 |
| ランタイムメタデータなし | コンストラクターのシグネチャと明示的な deps タプルがグラフを提供します。ランタイムのイントロスペクションは依存とより弱い失敗モードを追加します。 |
| 同一の構造的依存に対する名目上の区別なし | TypeScript は構造的代入可能性を使います。2 つのキーが同じ形を公開する場合、DepsOf はユーザーの意味的な意図を知ることができません。同じ形のサービス間で順序が重要な場合は、ブランド化された型または unique symbol キーを使ってください。 |
非同期 get() なし | 現在の循環ガードとライフタイムガードは、共有の同期コールスタック状態を使います。非同期解決 API は、解決ごとに別個の記帳を必要とします。 |
| 非同期ファクトリー間の循環の検出なし | await の後では同期解決スタックは消失し、保留中の promise が後続の c.get() 呼び出しを満たすことがあります。これを検出するには解決に非同期追跡を追加することになります。循環を分割するか、共有の初期化処理を引き上げるか、合法な場所で Lazy<singleton> を使ってください。 |
| 自動の循環断ち切りなし | 一方が明示的な遅延シングルトンコンパニオンでない限り、循環はアーキテクチャ上の欠陥です。InferDI はサポートされるランタイムの循環を検出して報告します。proxy や部分的なインスタンスを作り出すことはありません。 |
ジェネリックな <T>(c: Container<T>) => ... モジュールなし | ジェネリック本体の内部では、keyof T が DependenciesMap の上限に潰れます。インラインの .use() ラムダ、または既知の入力の形を持つ Module<TIn, TOut> を使ってください。 |
| 動的 DI リゾルバー API なし | .has(key) が認可された動的プローブです。静的キーは .get() を直接使うべきです。 |
| 本番のオーバーライド機構なし | .override() はテストとホットリロードのフィクスチャのために存在します。本番のグラフ選択は .use() または通常のビルダーコードに属します。 |
| 親から子への連鎖的破棄なし | 各コンテナは自身のインスタンスを所有します。連鎖的破棄は dispose() を非ローカルな副作用にし、スコープの所有権を壊してしまいます。 |
| 解決時のフック、インターセプター、ミドルウェアなし | それは AOP です。ホットパスに作業を追加し、コア契約を曖昧にしてしまいます。 |
| コアにフレームワークのグルーコードなし | フレームワークアダプターはアダプターパッケージに属します。コアは依存フリーかつフレームワーク非依存のままです。 |
6. 非目標
InferDI は以下にはなりません。
- 汎用的な IoC フレームワーク。
- デコレーターまたはリフレクションのコンテナ。
- リクエストコンテキストシステム、または
AsyncLocalStorageの代替。 - 自動配線スキャナー。
- 解決時ミドルウェアのプラグインホスト。
- レガシー DI コンテナのための互換レイヤー。
最終ルール: グラフが型であり、型が契約である。
