Angular 22の@Service()とprovidedIn: 'root'の混在をどう整理するか
Angular 22の@Service()とprovidedIn: 'root’の混在をどう整理するか
Angular 22 で @Service() が加わった。@Injectable({providedIn: 'root'}) と書いていた箇所が @Service() の7文字で済むので、新しく書くサービスから順に置き換わっていく。
置き換えを自動で行う schematic は無い。@angular/core 22.2.1 の schematics/migrations.json に登録されているのは8件で、変更検知、HTTP のバックエンド、テンプレートの型検査、canMatch、ハイドレーション、model() の出力、省略可能連鎖に関するものだけである。DI のデコレーターを書き換えるものは入っていない。したがって、同じリポジトリの中で @Injectable と @Service が並ぶ期間が続く。並んだとき、どちらで書くべきかを差分の上で判断する材料が要る。
生成される定義の比較
デコレーターが何を作るかを見ると、両者の関係が決まる。@Service() の実体は、compileService が組み立てる1つのオブジェクトである。
function compileService(meta, resolveForwardRefs) {
const def = new DefinitionMap();
def.set('token', meta.type.value);
def.set('factory', meta.factory === undefined
? delegateToFactory(meta.type.value, meta.type.value, resolveForwardRefs)
: arrowFn([], meta.factory.callFn([]), DYNAMIC_TYPE));
if (meta.autoProvided === false) {
def.set('autoProvided', literal(false));
}
const expression = importExpr(Identifiers.defineService).callFn([def.toLiteralMap()], undefined, true);
return { expression, type: createInjectableType(), statements: [] };
}実行時に呼ばれる ɵɵdefineService は、このオブジェクトを受けて providedIn を自分で決める。
function ɵɵdefineService(opts) {
return {
token: opts.token,
providedIn: opts.autoProvided === false ? null : 'root',
factory: opts.factory,
value: undefined
};
}比較対象の ɵɵdefineInjectable は、同じ4つのプロパティを持つオブジェクトを返す。違うのは providedIn の決め方が opts.providedIn || null である点だけである。書き込み先の静的フィールドも共通で、どちらも ɵprov に入る。
ここから2つの対応が決まる。引数なしの @Service() は @Injectable({providedIn: 'root'}) と同じ定義になり、@Service({autoProvided: false}) は @Injectable()(providedIn なし)と同じ定義になる。インジェクターから見て区別は付かないので、置き換えによって解決の経路が変わることはない。
@Service() で書けなくなる指定
同じ定義になるのは、@Injectable 側が providedIn: 'root' か指定なしのときに限られる。Injectable のオプションには providedIn に4つの値と Type が書けるが、@Service() が受けるオプションは autoProvided と factory の2つしかない。
interface Service {
autoProvided?: boolean;
factory?: () => unknown;
}providedIn: 'platform' を使っているサービスは @Service() へ移せない。platform はページ上の複数アプリが共有する単一のインジェクターを指し、root とは別の寿命を持つ。providedIn: NgModule と providedIn: 'any' は非推奨なので移行の対象だが、移行先は @Service() ではなく、どのインジェクターに置くかを決め直す作業である。
InjectableProvider の側も落ちる。@Injectable({providedIn: 'root', useClass: ...}) や useExisting、useValue、deps 付きの useFactory は、@Service() には対応する書き方が無い。近いのは factory だけで、これは引数を取らない関数である。依存は関数の中で inject() して取る。
// 22 より前
@Injectable({
providedIn: 'root',
useFactory: (config: AppConfig) => new PriceFormatter(config.locale),
deps: [AppConfig],
})
export abstract class PriceFormatter { /* ... */ }
// @Service() で書くと
@Service({
factory: () => new PriceFormatter(inject(AppConfig).locale),
})
export abstract class PriceFormatter { /* ... */ }deps の列挙が消えて inject() に変わるので、依存を1つ足したときに引数の順序と列挙がずれる問題は無くなる。差分としては読みやすくなる方向である。
factory を付けたクラスの型
factory を付けた @Service() には、@Injectable には無い挙動がある。デコレーターの型が、付けたクラスの型を書き換える。
<T>(options: {
autoProvided?: true;
factory: () => T;
}): <C extends Type$1<unknown> | AbstractType<unknown>>(target: C) => C extends Type$1<unknown>
? Type$1<T>
: abstract new (...args: any[]) => T;戻り値が Type<T> になっている。T は factory の戻り値の型であって、デコレーターを付けたクラス自身の型ではない。つまり factory が別の型を返すように書けてしまい、そう書いた場合、クラス名をトークンとして inject() した結果の型は factory の戻り値の型になる。クラス本体にどんなメソッドが書いてあっても、その型は外から見えない。
@Service({
factory: () => inject(HttpBackedCatalog),
})
export class Catalog {
// ここに書いたメソッドは inject(Catalog) の型には現れない
findById(id: string): Item | undefined { /* ... */ }
}これは useExisting の代わりとして使える形だが、クラス本体に実装が残っているときは読み手を誤らせる。factory を付ける差分を見たら、クラス本体が空か、抽象クラスになっているかを確かめる。本体に実装があって factory が別の値を返しているなら、実装側は誰も使っていないか、トークンとしてのクラスと実装としてのクラスが分かれていないかのどちらかである。
@Reviewer: `factory` で `HttpBackedCatalog` を返しているため、`inject(Catalog)` の型は `HttpBackedCatalog` になります。この `Catalog` クラスに書かれている `findById` は型の上から消えるので、呼んでいる箇所があれば今は通っていないはずです。トークンだけを `Catalog` に残す意図でしたら、本体の実装を `HttpBackedCatalog` 側へ移して `Catalog` は抽象クラスにしてください。providers 配列に書いたときの重複
@Service() の定義は providedIn: 'root' なので、@Injectable({providedIn: 'root'}) と同じ重複の問題を引き継ぐ。コンポーネントの providers に書いても、それは root の登録を打ち消さない。
@Service()
export class WizardState { /* ... */ }
@Component({
selector: 'app-wizard',
providers: [WizardState], // ここで1つ作られる
// ...
})
export class WizardComponent {
readonly state = inject(WizardState);
}providers に型を直接書いた場合、プロバイダーの解決は getFactoryDef が返す ɵfac を使う。
if (isTypeProvider(provider)) {
const unwrappedProvider = resolveForwardRef(provider);
return getFactoryDef(unwrappedProvider) || injectableDefOrInjectorDefFactory(unwrappedProvider);
}providedIn は見ていない。したがってコンポーネントのインジェクターには別のインスタンスができる。このコンポーネントの外から inject(WizardState) した箇所があれば、そこでは root 側のインスタンスが作られ、2つが同時に生きる。どちらに書き込んだかで見えるものが変わるので、状態を持つサービスでは表示の食い違いになる。
@Reviewer: `WizardState` は `@Service()` なので root にも登録されています。この `providers` で作られるのはコンポーネント専用の2つ目で、`WizardComponent` の外から `inject(WizardState)` している箇所があるとそちらは root 側を見ます。この画面だけで使う状態でしたら `@Service({autoProvided: false})` に変えてください。書き忘れた箇所が `NullInjectorError` で落ちるようになります。コンポーネントに閉じた寿命がほしいなら @Service({autoProvided: false}) を選ぶ。この定義は providedIn: null になるので、providers に書いた箇所だけで作られ、書き忘れた箇所は NullInjectorError で落ちる。黙って2つ目ができる経路が無くなる。サービスの提供位置が状態の寿命を決める構造はNgRx SignalStoreの提供位置が状態の寿命を決めることをレビューで確かめるでも同じ形で現れる。
継承したサービスの定義
デコレーターを親クラスだけに付け、子クラスを注入する書き方は、@Service() でも @Injectable でも同じ経路に入る。定義を探す関数は自分のプロパティだけを見る。
function getInjectableDef(type) {
return getOwnDefinition(type, NG_PROV_DEF);
}
function getOwnDefinition(type, field) {
return Object.hasOwn(type, field) && type[field] || null;
}子クラスには ɵprov が無いので null が返り、継承をたどる側の関数に落ちる。
function getInheritedInjectableDef(type) {
const def = type?.[NG_PROV_DEF] ?? null;
if (def) {
ngDevMode && console.warn(`DEPRECATED: DI is instantiating a token "${type.name}" that inherits its @Injectable decorator but does not provide one itself.\n` + `This will become an error in a future version of Angular. Please add @Injectable() to the "${type.name}" class.`);
return def;
} else {
return null;
}
}返るのは親の定義である。親の定義の factory は、delegateToFactory が同一のクラスを受けたときに useType.prop('ɵfac') を返すため、親クラスの ɵfac そのものを指す。子クラスのトークンで注入を要求しても、作られるのは親クラスのインスタンスになる。開発モードでは上の警告が出るが、本番ビルドでは ngDevMode が落ちるので何も出ない。
警告の文面は「将来のバージョンでエラーになる」と述べている。子クラスを注入する箇所があるなら、子クラスにもデコレーターを付ける。@Service() を付けた親を継承する場合、子に付けるのは @Service() でも @Injectable() でもよいが、親が autoProvided: false なら子も同じにしないと、子だけが root に登録される。
遅延して注入する側の前提
@Service() が @Injectable({providedIn: 'root'}) と並んで名指しされている箇所が、22 の API に1つある。injectAsync の説明である。
* NOTE: To enable lazy loading, the injected service must be auto-provided. This means it should be decorated with either `@Injectable({providedIn: 'root'})` or `@Service()`.injectAsync は、呼んだ時点の注入コンテキストから Injector を取り、動的 import() が解決した後に injector.get() を呼ぶ。
function injectAsync(loader, options) {
if (ngDevMode) {
assertInInjectionContext(injectAsync);
}
const injector = inject(Injector);
let loadedPromise = null;
const load = () => {
if (!loadedPromise) {
loadedPromise = loader();
}
return loadedPromise;
};
if (options?.prefetch) {
options.prefetch().then(() => load()).catch(() => {});
}
return () => load().then(loadedToken => injector.get(maybeUnwrapDefaultExport(loadedToken)));
}injector.get() は読み込みが終わってから呼ばれる。自動で登録されていないサービスを渡した場合、失敗するのはこの時点であって、コンポーネントの生成時ではない。利用者が操作してから Promise が拒否される形になるので、起動時の検証では見つからない。@Service({autoProvided: false}) のサービスを injectAsync に渡した差分は、この理由で止める。injectAsync 自体の線引きは別に扱う。
@Reviewer: `ReportExporter` は `autoProvided: false` で、`ReportPageComponent` の `providers` に入っています。`injectAsync` は読み込みが終わったあとに `injector.get()` を呼ぶため、登録が無いインジェクターから呼ぶと、この画面を開いた時点ではなく出力ボタンを押したあとに Promise が拒否されます。遅延させたいのであれば自動で登録される側にするか、`injectAsync` を外して通常の `inject()` にしてください。レビュー観点チェックリスト
@Injectable({providedIn: 'root'})から@Service()への置き換えで、他のオプションが落ちていないかprovidedIn: 'platform'のサービスを@Service()に変えていないか(platformは表現できない)useClass/useExisting/useValue/depsがfactoryに移されたとき、依存がinject()で取れる位置にあるかfactoryを付けたクラスの本体が空か抽象クラスになっているか(本体の実装は外から見えない)@Service()のサービスをコンポーネントのprovidersにも書いていないか(root 側と2つ生きる)- コンポーネントに閉じた寿命がほしい箇所で
autoProvided: falseを選んでいるか - 親クラスだけにデコレーターがあり、子クラスを注入している箇所が無いか
- 親が
autoProvided: falseのとき、子クラスのデコレーターも揃っているか injectAsyncに渡すサービスが自動で登録される側になっているか- 同じ責務のサービスで
@Injectableと@Serviceが混ざっていないか(並ぶなら新規の基準を決める)
おわりに
@Service() が作る定義は @Injectable({providedIn: 'root'}) のものと同じで、autoProvided: false を付ければ @Injectable() と同じになる。解決の経路は変わらないので、置き換え自体は安全に進められる。見るべきは、置き換えの際に落ちたオプションと、factory を足したときの型である。
移行の schematic が無いことは、混在が続くという意味でもある。どちらで書くかの基準を決めないと、同じリポジトリで2つの書き方が理由なく並ぶ。基準は提供位置から立てられる。root に1つで足りるサービスは @Service()、コンポーネントやルートに閉じた寿命を持つものは @Service({autoProvided: false})、platform や useClass のように表現できない指定が要るものは @Injectable に残す。この3分類で、差分を見たときにどちらが正しいかが決まる。
DIとサービス境界の観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「DIとサービス境界」の群に並べてある。