Chapter 05

1-2.プレゼンテーションとドメインの分離

lacolaco
lacolaco
2023.04.08に更新

1-1 章に引き続き Angular アプリケーションの初歩的なリファクタリングの流れを学びます。この章では UI に関係しない処理についての責任、いわゆる ビジネスロジック と呼ばれる責任をリファクタリングします。

UI を組み立てるのが本質的な役割であるはずのコンポーネントが UI と関連しない責任を持つということは、ただ単一責任原則に反しているだけでなく、プレゼンテーションとドメインの分離という重要な原則にも反しており、避けるべき状態です。そのような状態に陥ったコンポーネントをどのようにリファクタリングしていけばよいか、その典型的なパターンを学びましょう。

データ取得ロジックの分離

まずは現在のアプリケーションの状態を再確認しましょう。現在の AppComponent は UI に関する責任を子コンポーネントに委譲し、HTTP クライアントを使って API から取得したユーザーリストのデータを UserListComponent に渡しています。

現在の役割から 2 つの責任を取り出すことができます。API からユーザーリストを取得することと、取得したデータを保持して UserListComponent に渡すことです。まずはひとつめの責任を移譲することにしましょう。このような UI と関係しない処理はコンポーネントではなくサービスとして分離します。

Angular におけるサービスという語彙は、特定の実装やインターフェースの名称ではなく、ある単一の関心のために作られ、依存性の注入により利用されるクラス全般を指します。Angular の一般的な設計パターンでは、UI に関係しないビジネスロジックはサービスに分離されます。HTTP 通信をおこなう処理は典型的なビジネスロジックなので、ほぼ例外なくコンポーネントからサービスに切り出されます。

それでは実際にリファクタリングを勧めましょう。次のように、UserService というサービスクラスを作成し、AppComponentngOnInit メソッドに書かれているユーザーリストを取得する部分のコードをコピーした getUsers() メソッドを実装します。

user.service.ts
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { User } from '../user';

@Injectable({ providedIn: 'root' })
export class UserService {
  private readonly http = inject(HttpClient);

  getUsers(): Observable<User[]> {
    return this.http
      .get<{ data: User[] }>("https://reqres.in/api/users")
      .pipe(
        map(resp => resp.data)
      );
  }
}

UserService@Injectable({ providedIn: 'root' }) デコレータによってアプリケーションのどこからでも依存性の注入によって利用可能になっています。
AppComponent では新しく追加した userService プロパティに UserService を注入しましょう。そして、もともとユーザーリストを取得していた ngOnInit() メソッドの中身を、UserService.getUsers メソッドの呼び出しに置き換えます。

app.component.ts
@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, UserListComponent],
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent implements OnInit {
  private readonly userService = inject(UserService);

  users: User[] = [];

  ngOnInit() {
    this.userService.getUsers().subscribe(users => {
      this.users = users;
    });
  }
}

この変更により、AppComponent が直接 HTTP クライアントを使わないようになりました。つまり、ユーザーリストを取得する処理を呼び出す責任はありますが、そのデータがどこからどのように取得されるのかは、UserService だけが知っている知識になりました。たとえば、API の URL が変更されたり、HTTP ヘッダを追加したりすることがあっても、そのために AppComponent が変更されることがなくなります。責任を切り出したことで、AppComponent が変更される理由がまたひとつ減りました。

ユースケースと状態管理

AppComponentの責務がほとんど切り出されましたが、まだ委譲すべきものが残っています。ユーザーリストを表示するための配列はAppComponentusers プロパティで保持されていますが、このデータを管理する責任を AppComponent が持ちつづける理由はありません。すなわち、状態管理の責任です。これを別のクラスへ移譲しましょう。移譲する先は、新しく作成する AppUsecase クラスです。

app.component.ts と同じディレクトリに、 app.usecase.ts という名前のファイルを作成し、次のように AppUsecase クラスを実装します。

app.usecase.ts
import { inject, Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { UserService } from './service/user.service';
import { User } from './user';

export type State = {
  users: User[];
};

@Injectable()
export class AppUsecase {
  private readonly userService = inject(UserService);

  private readonly store = new BehaviorSubject<State>({
    users: [],
  });

  get state$() {
    return this.store.asObservable();
  }

  initialize() {
    this.userService.getUsers().subscribe((users) => {
      const state = this.store.getValue();
      this.store.next({
        ...state,
        users,
      });
    });
  }
}

このクラスの責任は、 AppComponent にまつわるユースケース(シナリオ)を表現することです。このクラスが知っていることは、「AppComponent はユーザーリストを状態として必要とする」ということと、「そのデータは初期化時に取得しなければならない」ということです。この知識をできるだけそのままコードで表現するのが AppUsecase クラスの役割です。

また、このクラスが公開するのは、読み取り専用の state$ プロパティと、戻り値を持たない initialize() メソッドのみです。initializeメソッドを呼び出すと、結果的に state$ が更新されることになります。このように、副作用を起こさず値を返す読み取り専用の クエリ と、副作用を起こし結果を返さない書き込み専用の コマンド のメソッドを分離するのは、コマンド・クエリ分離原則という設計原則に基づいています。

この AppUsecaseAppComponent から利用しましょう。AppUsecaseAppComponent だけが必要とするサービスクラスであり、その ライフサイクル(生成〜破棄のタイミング)AppComponent と同じです。そのため、 AppUsecaseAppComponentコンポーネント固有のインジェクター[1]に直接プロバイダーを登録します。 @Component.providers 配列に AppUsecase を追加しましょう。

app.component.ts
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { AppUsecase } from './app.usecase';
import { UserListComponent } from './view/user-list/user-list.component';

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, UserListComponent],
  // AppUsecase のプロバイダーを登録
  providers: [AppUsecase],
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent implements OnInit {
  // AppUsecase のインスタンスを注入
  private readonly usecase = inject(AppUsecase);
  // AppUsecase が公開する状態をテンプレートから参照するためのプロパティ
  readonly state$ = this.usecase.state$;

  ngOnInit() {
    // 初期化処理を開始する
    this.usecase.initialize();
  }
}

ユーザーリストを保持する形式が同期的な配列から Observable<State> 型に変わったので、テンプレート中で AsyncPipe を使って非同期データの更新を購読する必要があります。テンプレート全体を <ng-container> タグで囲み、 state$async パイプで同期的な state 変数に変換し、state.users プロパティを UserListComponent に渡すようにしましょう。この実装パターンについては、Single State Stream パターンの章であらためて紹介します。

app.component.html
<!-- Single State Stream パターン で state$ を購読 -->
<ng-container *ngIf="state$ | async as state">
  <user-list *ngIf="state.users" [users]="state.users"></user-list>
</ng-container>

ついに、AppComponentからほとんどの責任を移譲することができました。今のAppComponentに残っている責任は次の 2 つです。

  • ブートストラップされるルートコンポーネントであること
  • アプリケーションの起動時にユースケースの初期化処理を開始すること(AppUsecase.initialize()の呼び出し)

UI を組み立てることが本来の役割であるコンポーネントから具体的なデータの取得や管理についての知識を取り除き、プレゼンテーションとドメインの分離 をある程度実現できたと言えるでしょう。もし余裕があれば、分割したそれぞれのクラスについてユニットテストを追加したり、状態管理のストアを別の実装に変えてみたりと、工夫する余地はありますので、ぜひ試してみてください。

UI と単方向データフローの設計パターン

ところで、今の AppComponent に関係するデータの流れを図にすると次のようになります。

AppComponentと単方向データフロー

コンポーネントが状態の変更を待ち受け、コンポーネントがトリガーするアクションから発生する副作用によって状態が更新される、単方向データフローを中心にした設計パターンは、Angular に限らず昨今のコンポーネント中心のアプリケーション開発において欠かせない考え方のひとつです。このパターンの利点は次のとおりです。

  • 状態管理の詳細を隠蔽することで、コンポーネントをテストしやすくなる
    • AppUsecase.state$ を差し替えるだけで、任意の状態におけるコンポーネントの振る舞いをテストできる
  • 状態の変更が UI に影響を与える経路を絞れるため、振る舞いを予測しやすくなる
    • state$ の更新以外で UI が変化することがない
  • コマンドの副作用を隠蔽することで、ユースケースを変更しやすくなる
    • コンポーネントが AppUsecase.initialize() の戻り値に依存しないので、AppUsecase 側で実装を変更してもコンポーネントに影響がない
    • 状態管理の方法を変えても、 Observable<State> のインターフェースさえ守っていればコンポーネントに影響がない

この章のまとめ

  • プレゼンテーションとドメインの分離を実現するために、データの取得と管理に関する責任をAppComponent から別のクラスに移譲しました。
  • HTTP 通信を行うための処理を UserService に移譲し、AppComponent からは UserService のメソッドを呼び出すだけにすることで、HTTP 通信の詳細を隠蔽しました。
  • アプリケーションのユースケースについての知識を表現する AppUsecase クラスを作成しました。
  • コマンド・クエリ分離原則に基づいたインターフェースの設計を行い、単方向データフローを中心にした設計パターンを実現しました。
脚注
  1. 『依存性の注入』の章を参照 ↩︎