Marginalia

Angular: Signal as a Dependency(依存オブジェクトのシグナル化)

最近思いついて、妙案ではないかと感じているもの。まだはっきり断言はできないが、もし興味を持った読者がいたら試してみて感想や意見をもらいたい。


Angularアプリケーションにおいて、あるサービスクラスが別のサービスクラスに依存することは多い。たとえば、あるコンポーネントのUIの状態が、ユーザーの認証状態に依存するというとき、これまでは次のようにクラスが記述されることが多かった。つまり、あるサービスが保持する状態にアクセスするために、そのサービスのインスタンスへの依存を宣言する( inject(UserAuthState) )ということである。

export type UserAuth = { permissions: string[] };

// ユーザーの認証状態を管理するrootスコープのサービス
@Injectable({ providedIn: 'root' })
export class UserAuthState {
  $currentUser = signal<UserAuth | null>(null);
}

// Appコンポーネントの状態を管理するコンポーネントスコープのサービス
@Injectable()
export class AppState {
  userAuth = inject(UserAuthState);

  $isAdmin = computed(() =>
    this.userAuth.$currentUser()?.permissions?.includes('ADMIN')
  );
}

@Component({
  selector: 'my-app',
  standalone: true,
  providers: [AppState],
  template: `
    <button [disabled]="!state.$isAdmin()">Admin Only</button>
  `,
})
export class App {
  state = inject(AppState);
}

ここで考えなければならないのは、このシステムにおいて、真に依存関係があるのは状態のレベルであって、クラスのレベルではない。 AppState クラスが UserAuthState クラスに依存しているのはその中身の $currentUser にアクセスするためで、クラスそのものには興味がない。

そのことがよく現れるのはこの App コンポーネントのテストを書こうとしたときで、 $isAdmin がtrueになるようなテストケースを書くためにまず思いつくのは UserAuthState クラスのインスタンスを差し替えることだ。

// テストコードは @testing-library/angular を使ったコードで示す。
// 1. UserAuthState のインスタンスを差し替える
await render(App, {
  providers: [
    {
      provide: UserAuthState,
      useValue: {
        $currentUser: signal({ permissions: ['ADMIN'] }),
      },
    },
  ],
});

クラスそのものに関心がないからこそ、関心のあるフィールドだけを持ったモックオブジェクトを作成することになる。また、特に好ましくないのは、 App コンポーネントが直接依存しているのは AppState サービスなのに、テストケースのために間接的な依存である UserAuthState を差し替えていることだ。

AppState を差し替えるにしても、同様にインスタンスの実態はモックオブジェクトにならざるを得ない。なぜかというと AppState もAngularのInjectorによるインスタンス化を前提としているからだ。UserAuthState の注入が必要なので、 new AppState() はできない。やるにしてもInjection Contextのセットアップが必要になり、結局 UserAuthState が必要になる。

// 2. AppState のインスタンスを差し替える
await render(App, {
  componentProviders: [
    {
      provide: AppState,
      useValue: {
        $isAdmin: signal(true),
      },
    },
  ],
});

つまりこのアプローチでは、本物の AppState を使おうとすればUserAuthState のモックが必要になり、UserAuthState を関心から外すには 本物の AppState は使えない、ということになる。

AppState のようなコンポーネントスコープのサービスは、コンポーネントと協調して動作しなければ意味がない。そういう意味で、この2択はどちらも選びたくないものだった。

Signal-as-a-Dependency

そこで思いついたのは、依存関係が状態のレベルなら、それをそのまま表現してみてはどうか、ということだ。クラスからクラスに依存するのではなく、クラスが必要とする外部の状態に直接依存する。これは、 Signal 型のオブジェクト(シグナル)をコンストラクタ引数で受け取る形で表現できる。ただのオブジェクトではなくSignalなので、外部での状態変化に反応して $isAdmin の値は追従できる。

export class AppState {
  constructor(readonly $currentUser: Signal<UserAuth | null>) {}

  $isAdmin = computed(() =>
    this.$currentUser()?.permissions?.includes('ADMIN')
  );
}

もちろん、このクラスのインスタンス化は、AngularのDependency Injectionでそのまま解決はできない。このクラスをどのようにインスタンス化すればよいかは、 AppState クラスのプロバイダー関数で教える必要がある。 provideAppState 関数を作成し、 useFactory でファクトリー関数を定義する。ここではプロバイダー関数に引数があればそれをそのまま利用し、なければDependency Injectionで必要なオブジェクトを解決して new している。

// プロバイダー定義を返す関数
export function provideAppState(override?: AppState) {
  return [
    {
      provide: AppState,
      useFactory: () => {
        if (override) return override;
        const userAuth = inject(UserAuthState);
        return new AppState(userAuth.$currentUser);
      },
    },
  ];
}

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule],
  // コンポーネントのプロバイダー宣言で呼び出す
  providers: [provideAppState()],
  template: `
    <button [disabled]="!state.$isAdmin()">Admin Only</button>
  `,
})
export class App {
  state = inject(AppState);
}

一見冗長だが、このプロバイダー関数を今後書き直すのは AppState のコンストラクタ引数が増えたときだけだし、このプロバイダー関数はテストでなかなか便利に使える。

新しい AppStateprovideAppState() 関数により、ユーザーの認証状態を変更するテストコードは次のようになる。 UserAuthState クラスへの依存をやめたことで、 new AppState(user) の形でインスタンス化できるようになった。このインスタンス化にDependency Injectionは一切関わっていない。また、そのインスタンスをコンポーネントから参照させるために使うのは、アプリケーションコードでも使ったのと同じ provideAppState() 関数である。オプショナル引数を受け付けるようにしておくことで、同じプロバイダー定義を再利用できる。

// AppState のインスタンスを差し替える
const state = new AppState(signal({ permissions: ['ADMIN'] }));
await render(App, {
  componentProviders: [provideAppState(state)],
});

このアプローチであれば、本物を使ってテストすることに以前よりも近づいているだろう。 App コンポーネントが直接依存していない UserAuthState についての関心は漏れ出てこない。それは自身のインスタンス化についての AppState の責任にとどまる。

ちなみに、これと同じことは Signal が登場する以前でも、Observableを使って実現できた。しかし外部ライブラリが必要なObservableとフレームワークビルトインのSignalでは、アプリケーションにおけるプリミティブ具合がまるで違う。また、Observableは本質的に状態管理の道具ではなく(「現在の値」という概念がない)、常に非同期という属性と不可分であり、それが状態の管理を複雑にするが、Signalは通知機能がついた同期的な値であってその点でも簡単になる。

コンポーネントに近いスコープのサービスクラスは、このようなアプローチを試してみるとコンポーネントと一緒に統合テストが書きやすくなる。モックオブジェクトを作るニーズが減り、テストコードの見通しもよくなる効果を見込んでいる。