React で作る中規模 SPA のレイヤードアーキテクチャ

AUTHOR :   ギックス

ground-layer

この記事は GiXo アドベントカレンダー の 23 日目の記事です。
昨日は、少人数の開発で Kubernetes を活用するための設計戦略 でした。

MLOps Div. の堀越です。本記事では、React と TypeScript で SPA の実装を行う際に採用しているレイヤードアーキテクチャについてご紹介します。

レイヤードアーキテクチャというとクリーンアーキテクチャや DDD が有名ですが、弊チームフロントエンド の場合はクリーンアーキテクチャから SPA にマッチする箇所を部分的に取り入れた簡易版のレイヤードアーキテクチャになっています。現状のサービスに適用できていない部分もまだありますが、各レイヤーの責務と依存関係を明確に定義しておくことで、アプリケーション改修を行う際の影響範囲が明確になったりバグ発生時の問題の切り分けが楽になったりと、開発をよりスムーズに進められるようになりました。

タイトルに”中規模 SPA”とありますが、これはバンドルファイルの Gzipped size が 1〜2 MB くらい、ページ数だと 10 ページ前後のサイズ感を指しています。弊社のトチカチが大体それくらいのサイズで、今回ご紹介するレイヤードアーキテクチャを取り入れています。(機械学習基盤 Refeed の GUI はもう少し小規模ですが、部分的に適用を進めています)

なぜレイヤードアーキテクチャ?

詳細に入る前に React SPA へレイヤードアーキテクチャを採用するに至った経緯を軽く記載しておきます。

  • 当初は View を担う Presentational Component と View に値やロジックを渡す Container Component を切り分けるというシンプルな制限のみで開発していた
  • 開発が進みページ数が増えて機能が追加されるにつれ、Container Component には責務や粒度の異なるロジックが詰め込まれ肥大化していった
  • 「この責務をもったロジックはどこに書くべきか?」という問いに対する明確な答えがなく、最終的に Container Component とヘルパー関数が無法地帯となった

細かな問題は他にもありましたが、大きくは上記の問題がきっかけで設計の見直しを検討することになりました。React の SPA 設計について調べる中で似たような悩みをもつ人は何度か見かけたため、割とよくある(?)話でしょうか。

設計の見直しをする中でレイヤードアーキテクチャを採用するに至ったのは、社内でクリーンアーキテクチャの存在を教えてもらったことが大きいです。クリーンアーキテクチャはフロントエンドに適用する前提で考えられたデザインパターンではないため、クリーンアーキテクチャで紹介されている各レイヤーを SPA にそのまま当てはめることはできません。しかし「各レイヤーに明確な責務を定義してモジュールを配置する」、「レイヤー同士の依存関係を明確にして制限する」という考え方は、当時の悩みを解決するために必要なものであると思い取り入れることにしました。

責務が明確なレイヤーを定義する

繰り返しになりますが、クリーンアーキテクチャはフロントエンドを想定したデザインパターンではありません。そのため SPA にクリーンアーキテクチャで紹介されている各レイヤーをそのまま適用すると不必要に複雑な構成となってしまいます。(正確には自分が一度それを試したところ複雑になってしまった)

そこで弊チームのレイヤードアーキテクチャは SPA の実装を行うにあたり必要とされる責務に何があるかを考え、それを担うレイヤーをひとつずつ定義することにしました。レイヤーを定義する際はクリーンアーキテクチャを参考にしたため、レイヤーの名前はクリーンアーキテクチャに出てくる名前を部分的に拝借しています。実際に定義したレイヤーは下図の通りです。

灰色の矢印はレイヤー間の依存関係を表しており、基本的には隣り合うレイヤー同士かつ一方向にしか依存関係を持たないようになっています。厳密にはそう上手くいかない部分もあるのですが、それについては各レイヤーの概要をご説明する際に補足します。(エンティティ層が小さいのは横にユーザーを入れたためです)
The Clean Architecture で有名な下記の図のようにかっこよくできたら良かったのですが、これが限界です。シンプルイズベストということで。

(出典:The Clean Architecture

それでは定義した各レイヤーの概要についてそれぞれ紹介していきます。

インフラ層

インフラ層はデータベースや認証といった外部サービスの API を呼び出す関数やオブジェクトを定義する層です。弊社は Firebase のサービスを使うことが多いため、Firestore や Firebase Auth を呼び出す関数が該当します。

インフラ層はクライアントから見れば外部サービスそのものであるため外部サービスに依存していると言えますが、レイヤー同士の関係においてはリポジトリ層のみに依存するよう設計されています。この依存の方向については本家クリーンアーキテクチャの説明自体も(個人的に)理解が難しい部分であるため詳細は割愛しますが、インフラ層の関数に定義する入出力インターフェースにはリポジトリ層に合わせて定義されたインターフェースを使うため、インフラ層の関数はリポジトリ層に依存するといえます。よって、インフラ層を呼び出すのはリポジトリ層の責務です。
インフラ層を設けるメリットは下記の通りです。

  • 利用している外部サービスの詳細はインフラ層を見れば把握できる
  • データベースや認証機能を異なるサービスに変更してもインフラ層とリポジトリ層以外の書き換えはほとんど発生しない(例:Firestore を RDB に変更)

実装は外部サービスを使用するための初期化処理や各サービスの利用に必要なオブジェクトの export が主です。ソースコード例は利用する外部サービスによる部分が大きいため割愛します。

リポジトリ層

リポジトリ層はアプリケーションから外部サービスを呼び出す橋渡し役です。この層のモジュールがもつ具体的な処理は「ユースケース層から受け取ったユーザーモデルを Firestore に渡してユーザーのドキュメントを作成(永続化)」、「ユースケース層から受け取ったログイン情報を認証サービスに渡してユーザーログイン(認証)」のようになります。クリーンアーキテクチャ内の用語だとゲートウェイの方が近いですが、こちらが使いやすいように読み替えています。

リポジトリ層はユースケース層のみに依存し、、と言いたいところですが、外部サービスのデータ構造を知る必要があるためインフラ層にも少なからず依存しています。この辺の議論はクリーンアーキテクチャに関する考察記事でもよく見かけますが、全く依存しない状態にすることは現実的でないという意見が多く私もそう思うためあまり気にしていません。ただし、リポジトリ層を呼び出すのはユースケース層のみという制約は設けています。

リポジトリ層がユースケース層から受け取るデータはユースケース層のインターフェースに合わせたデータ構造となるため、DB へ保存する場合は永続化用のデータ構造に変換してからインフラ層に渡します。逆のパターンではインフラ層からデータを受け取り、ユースケース層のインターフェースに合わせて変換してからユースケースに渡します。

リポジトリ層のモジュールは外部サービスとのやりとりが必要なアプリケーションのモデルに対応して作成しています。ユーザーのモデルに関するデータを外部とやりとりするリポジトリであれば userRepository.ts 、お店のモデルに関するデータをやりとりするならば shopRepository.ts といった単位になります。
リポジトリ層を設けるメリットは下記の通りです。

  • リポジトリ層を見れば特定のモデルが外部サービスに対してどのような処理をもつか分かる
  • 外部サービスの詳細は知らずともインターフェースに合わせて引数を渡すだけで、ユースケースは外部サービスを操作しモデルの取得や保存ができる

実装は下記のようにモデルのオブジェクトを外部サービスから取得したり、外部サービスへ書き込んだりする処理が主です。(詳細は割愛して記載)

ユースケース層

ユースケース層はユーザーの各アクションに対応する一連の処理を実行する層で、最も多様な処理を呼び出します。この層のモジュールがもつ具体的な処理は「UI で入力された値をプレゼンテーション層から受け取りリポジトリ層へ渡す」、「カートに追加されたアイテムをリポジトリ層に渡しつつ Redux Store へ Dispatch」のようになります。

ユースケース層はプレゼンテーション層に依存し、プレゼンテーション層(View)が必要とするオブジェクトの加工処理はユースケース層で行います。リポジトリ層を呼び出すこともユースケース層の責務ですが、外部サービスは関係しないクライアント内で完結する処理の方がユースケースには多くなります。また、React Hooks はこのユースケース層から使用可能にしています。React への依存範囲を広げることになりますが、ユースケースで Hooks の使用を認めないと今度はプレゼンテーション層の Container が肥大化しがちなためです。

ユースケース層のモジュールはページと対応して 1:1 で作成しており、ユーザー管理ページのユースケースであれば userPageUsecase.ts といったファイル名にしています。
ユースケース層を設けるメリットは下記の通りです。

  • ユースケース層を見れば各ページで定義されているユーザーのアクション(ユースケース)を把握できる
  • 外部データの構造や加工処理の詳細は知らずとも、Component はインターフェースに合わせて引数を渡すだけで描画に必要なオブジェクトを取得できる
  • 新しいユーザーアクションを定義したいときはとりあえずユースケースから書き始めればよくなる

実装には、リポジトリから受け取ったオブジェクトをプレゼンテーション層で使用できる形に変換して返したり、UI のテーブルに並んだオブジェクトを受け取ってソートして返したり、キャッシュしたいオブジェクトを Store に保存したりと様々な処理が入ります。(詳細は割愛して記載)

プレゼンテーション層

プレゼンテーション層は View であり React Component です。ユーザーの目に触れるのはこのプレゼンテーション層となります。現状の設計では View となる Presentational Component だけでなく、View に値やロジックを渡す Container Component もこの層に含めています。ユースケース層を呼び出すのは Container Component です。

React Component の設計方針は下記の記事で提案されている SFC(Singe File Component)をそのまま踏襲しています。
「経年劣化に耐える ReactComponent の書き方」

これまでも Presentational Component と Container Component は切り分けて書いていましたが、Component.tsx, Container.tsx, Style.css のようにコンポーネントに対して 3 つ(CSS in JS を使うときは 2 つ)のファイルが存在し、ディレクトリ構成や依存関係が複雑になる要因となっていました。現在は上記の記事で提案されている SFC を採用したことで、そうした問題は解決しクリーンになったと思います。

また、これに加えて各ページ は Atomic Design のような粒度で作成した Component の組み合わせで構成されるようにしています。”Atomic Design のような”と書いたのは Atomic Design のパターンと一致しているわけではないためです。「atoms」、「molecules」、「organisms」、「pages」の粒度で Component を分けることが基本ですが、4 つの粒度に分割する必要がない場合は 3 つになるケースもあります。もちろん個々の Component は SFC になっています。

プレゼンテーション層は React Component へ渡すアプリケーションのモデル(エンティティ層)に依存しています。
プレゼンテーション層を設けるメリットは下記の通りです。

  • React Component に関する記述に集中できる
  • React Component とユースケースの問題の切り分けが容易になる
  • モック作成時はプレゼンテーション層のみ実装すればよいという決めができる

なお Presenter の責務(ユースケースから渡されたデータを View にわたすための加工処理を行う)をもったレイヤーは用意しておらず、ユースケース層に寄せています。切り分けを検討したこともありますが、現状のアプリケーションでは特に問題は起きていません。ユースケース が肥大化したら切り分けを検討しようと思っています。

プレゼンテーション層の実装は「経年劣化に耐える ReactComponent の書き方」と基本同じため、そちらを参照していただくのがよいです。

エンティティ層

エンティティ層にはアプリケーション固有のモデルに関する型や定数、ロジックを定義します。レイヤーとして扱ってはいますが、エンティティ層で定義されるモデルはプレゼンテーション層に限らず他の層のインターフェースを構成するパーツにもなるため、いちレイヤーというより各レイヤーの核といった方が位置付け的には正しいです。

エンティティ層は各レイヤーの核と書いた通りどのレイヤーにも依存しません。
エンティティ層を設けるメリットは下記の通りです。

  • アプリケーションに存在するモデルはエンティティ層を見れば把握できる
  • アプリケーションに依存する定数・型・ロジックとそれ以外の定数・型・ロジックを切り分けることができる

実装は前述の通りアプリケーション固有のモデルの型や定数、ロジックとなるため詳細は割愛します。

レイヤー以外の横断的関心事

ここまで各レイヤーに対して定義した責務の概要をご紹介してきましたが、レイヤーの積み重ねのみで SPA が完成するかというとそうではありません。レイヤーに属さない存在として Redux Store やユーティリティ関数があります。これらは グローバルオブジェクトを管理したり、キャッシュをしたり、エラーハンドリングをしたり、レイヤーを横断する形で必要となる機能たちです。このようにどのレイヤーでも呼ばれる可能性のある機能は「横断的関心事」と呼ばれます。

横断的関心事のようにレイヤーから切り離されたモジュール群はレイヤーほどの厳しい制限をもたないため、無法地帯の源泉となってしまわないかいささか心配な面もあります。しかし横断的関心事はある意味レイヤー以外という枠組みをもって管理されているため、これまでの設計より再利用性が高い形で各モジュールを管理できています。これもまたレイヤードアーキテクチャのメリットのひとつかもしれません。

おわりに

本記事では React と TypeScript で SPA の実装を行う際に採用しているレイヤードアーキテクチャの概要をご紹介しました。
今年は一年を通して React による SPA 設計パターンを調べては実装しての繰り返しでしたが、テックブログを読んでも Github を覗いても、各自がその時々の状況に応じて最適な形を模索しつつ実装しているんだな、、とたびたび感じたため、本記事も同じように悩める人の一助になれば幸いです。

最後になりますが、弊 MLOps チームは現在採用強化中です。今回の記事に限らず、 MLOps Div. の紹介記事テックリードの採用ページを見て少しでもご興味をもっていただけた方は、カジュアル面談も行っておりますのでぜひお気軽にお問い合わせください。

明日は「React + TypeScript + Cypress で始める E2E テスト」を公開予定です。


Go Horikoshi
MLOps Div. Lead / Kaggle Master
React・TypeScript を中心としたフロントエンド技術や、機械学習・データ分析を活用したサービス開発について発信していきます。

SERVICE