💉

AngularのDIを、設定で迷わない程度に理解する

に公開

この記事は、Angular Advent Calendar 2025 9日目です。

今年Angularを書いていて地味によくハマったのが、依存関係の指定ミスでした。DIの仕組みやスコープの話は理解していたつもりでしたが、そのイメージが大ざっぱすぎて、いざ設定を書こうとするとどこでprovideすればいいか迷ったり、 ApplicationConfig に設定追加を忘れていたり...ということがよく起こりました。
Angularの公式ドキュメントにはDIの設定方法が詳しく書いてありますが、自分はインプット過多なところがあり、全てを理解しようとすると手が動かないまま今年が終わってしまうので、今回は基本的な設定ができるくらいの粒度で整理してみます。

ProviderとInjector

Angularの公式ドキュメントを読むと、DIのページでProviderとInjectorという2種類の言葉が出てきます。明確な定義を見つけられていないのですが、自分はどちらも文字通りに

  • Provider: 依存関係を提供(provide)する役割
  • Injector: 依存の要求があったらProviderを探して依存の注入(inject)を行う役割

と解釈しています。
Injectorは基本的にAngular側で管理されているので、単純なコードであれば意識しなくてよいことも多いです。ただ最もよく目にするInjectorの働きといえば、Componentなどで記述する inject() 関数でしょう。

private readonly httpClient = inject(HttpClient);

例えばHTTPリクエストを送信するServiceで上のように書くと、Injectorは HttpClient を提供しているProviderを探しに行き、Providerが見つかったらServiceに注入します。これによりServiceの初期化が正常に開始されます。
もし HttpClient を提供するProviderが見つからないと、Serviceの初期化中に以下のエラーが発生します。

NullInjectorError: No provider for _HttpClient!

Injectorには階層があり、ComponentやDirectiveレベルのProviderを見る ElementInjector や、@InjectableApplicationConfig を見るEnvironmentInjector などがありますが、その階層の最上位に位置するのが NullInjector です。
Angularは依存が要求されると、下層のInjectorから上層へとたどっていき、 NullInjector まで来るとエラーになります。これは一通りのInjectorを使って探しても、要求された依存が見つからなかったということです。

Injection Tokenとは

たとえば、Componentで RecipeService を利用しようとしてprovidersに加える場合、以下のように書けます。

providers: [RecipeService]

しかしこれは省略記法です。本来は以下のようになっています。

providers: [
    { provide: RecipeService, useClass: RecipeService }
]

Angularのprovidersが取る値として指定できる Provider にはいくつか種類があり、上のものは Class Providerです。
この定義を見てみると、provide プロパティの値について以下のように書かれています。

An injection token. (Typically an instance of Type or InjectionToken, but can be any).

このInjection Tokenは、Angularが管理対象とする依存を識別するために使用されます。

Injection Tokenについて誤解していたこと

今まで自分は「Injection Tokenはidのような識別子で、依存となるクラスの型を一意に識別するものだ」と思っていました。しかしこれは多くの点で誤りです。

  • まずInjection Tokenはstring型とは限らず、任意の値を取れます。例えば上の RecipeService の例では、RecipeServiceのインスタンス自体をInjection Tokenとして使っています。また、 InjectionToken という型を使って明示的な定義をすることもできます。

  • Injection Tokenと依存の型は1対1ではありません。型が同じでも用途が違う依存であれば異なるInjection Tokenを割り当ててもよいですし、逆に同じInjection Tokenで複数種類の依存を管理することもできます。(具体例は後述のInterceptorの実装で触れます)

  • そして、AngularのDIではクラスだけではなく、任意の型の値を管理対象にできます。例えばAPIのURLをDIの対象としてComponentに注入するといったことも可能です。

AngularのDIは、単なるクラスの管理にとどまらず、より広い用途で利用されているものです。

provideRouterとDI

これらをふまえて、Angularアプリケーションの ApplicationConfig の内容を見てみます。

// ng newの際に生成されたApplicationConfig
export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }), 
    provideRouter(routes),
  ]
};

ここで行っていることは、アプリケーションレベルでの依存の注入です。例えばprovideRouterは、内部的では以下のような処理になっています。

// 内部実装の一部抜粋
function provideRouter(routes, ...features) {
  if (typeof ngDevMode === 'undefined' || ngDevMode) {
    _publishExternalGlobalUtil('ɵgetLoadedRoutes', getLoadedRoutes);
    _publishExternalGlobalUtil('ɵgetRouterInstance', getRouterInstance);
    _publishExternalGlobalUtil('ɵnavigateByUrl', navigateByUrl);
  }
  return makeEnvironmentProviders([{
    provide: ROUTES,
    multi: true,
    useValue: routes
  }, typeof ngDevMode === 'undefined' || ngDevMode ? {
    provide: ROUTER_IS_PROVIDED,
    useValue: true
  } : [], {
    provide: ActivatedRoute,
    useFactory: rootRoute
  }, {
    provide: APP_BOOTSTRAP_LISTENER,
    multi: true,
    useFactory: getBootstrapListener
  }, features.map(feature => feature.ɵproviders)]);
}

EnvironmentProviders という言葉が出てきましたが、これは EnvironmentInjector からしか利用させたくないProviderたちをラップするための型です。
ここでは app.routes.ts で定義した routes を、 ROUTES というInjectionTokenでprovideしています。 routes はクラスでもなんでもない単なる配列ですが、先述の通りこれもDIの対象にできます。ROUTES はあらかじめ定義されている InjectionToken 型のインスタンスで、これに対応させることで routes の内容がルーティング定義としてアプリケーションで利用されます。

DIを利用してInterceptorを追加する

Angularが行っている設定をなんとなく理解できたところで、 EnvironmentInjector に紐づくProviderを自分たちで追加してみましょう。ここでは、Componentツリーの外で管理される依存のひとつとして、Interceptorを追加します。
試しに以下のようなInterceptorを作成しました。

/**
 * environmentで定義したbase urlを、リクエストで指定されたpathに結合させる。
 */
@Injectable()
export class BaseUrlInterceptor implements HttpInterceptor {
  private baseUrl = environment.apiBaseUrl;

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const modifiedReq = req.clone({
      url: this.baseUrl + req.url
    });
    return next.handle(modifiedReq);
  }
}

このInterceptorを利用するには、先ほどの app.config.ts に設定を追加します。

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }), 
    provideRouter(routes),
+    provideHttpClient(
+      withInterceptorsFromDi(),
+    ),
+    {provide: HTTP_INTERCEPTORS, useClass: BaseUrlInterceptor, multi: true},
  ]
};

Interceptorを注入する際は、Angular側で定義された HTTP_INTERCEPTORS を使います。このInjectionTokenで登録されたものが、Interceptorとして認識されます。
また、 multi: true を指定することで、同じInjection Tokenに対して複数の依存を対応させることができます。 これにより、今後別の役割のInterceptorを加えたいときも、 HTTP_INTERCEPTORS に問題なく認識させることが可能です。
なお、InterceptorをStandalone Angularで利用する際は provideHttpClient の中で明示的に withIntercetorsFromDi() を指定する必要があります。このHttpClientもEnvironmentProviderとして指定されていることはいうまでもありません。

このように、普段Angularで何気なく使っている機能にもDIの仕組みが使われています。あまり意識しなくても開発を回すことはできますが、仕組みの理解を深めるとトラブルシューティングの引き出しを増やすことができます。

Angular Advent Calendar 2025、明日の記事はhttp_kato83さんです!

Discussion