バイセル Tech Blog

バイセル Tech Blogは株式会社BuySellTechnologiesのエンジニア達が知見・発見を共有する技術ブログです。

Apollo Clientを採用した際のフロントエンドの構成について考えてみた

はじめに

こちらは バイセルテクノロジーズ Advent Calendar 2021 の 1日目の記事になります!

今年度新卒で入社した開発1部の早瀬です。

ここ最近は新規プロジェクトのPLとして、要件定義から開発まで携わっています。

新規で立ち上がったプロジェクトというのもありBE・FEともにアーキテクチャの検討から携わらせていただいているのですが、特にFEに関してはかなり悩んだ末に自分なりに納得がいく構成になったので今回はその内容を紹介しようと思います!

前提

今回紹介するFEの構成の前提として下記のようなものがあります。

  • ベースとしてTypeScript、React、Next.jsを採用
  • UIコンポーネントにMUIを採用
    • スタイルにはsx-propsをメインで使用
  • 通信規格にGraphQL、状態管理ライブラリにApollo Clientを採用
  • BEではHasuraを使用してGraphQL APIを自動生成している
    • Hasuraでは基本的なCRUDのAPIしか生成してくれないため、可能な限りロジックはFE側に寄せる必要がある

アーキテクチャ

アーキテクチャに関してはApollo GraphQL Blogの下記の記事を大いに参考にさせていただきました。

ここでは記事の内容を自分なりに解釈した上でコアな部分だけ解説しますが、詳しくは元記事を参照してください。

GraphQLに限った話ではなくFE全般に関わる話で、とても勉強になる内容が詰まっていると思うのでお勧めです!

www.apollographql.com

ベースとしてはMVP(Model-View-Presenter)になるのですが、その中でもModelの部分を細分化して関心の分離を行っています。

View

UIのレンダリングとユーザーイベント(クリックや入力など)の発行を行います。

UIロジック(この条件の場合はこれを表示するなどといったUIに関するロジック)はViewが持っても大丈夫ですが、UIロジック以外のロジック(APIリクエストなど)はViewでは持たずユーザーイベントを発行することだけを責務とします。

Presenter

ViewとModelをつなげるためのレイヤーで、ViewからのイベントをModelに渡したりModelから受け取ったデータをViewに渡す役割を担います。

Model

  • Interaction
    • 意思決定を行うためのレイヤー
    • バリデーションなどの何か強制するルールがある場合や、アプリケーションに関するロジックはここで処理する
    • GraphQLミューテーションはここのレイヤーで実行する
  • Networking & data fetching
    • APIコールの実行とメタデータの状態を管理するためのレイヤー
    • レスポンスの格納や、isLoadingなどのメタデータの管理を行う
  • State management & storage
    • 状態管理を行うためのレイヤー

本プロジェクトでは状態管理にApollo Clientを使用しているので、ネットワーク周りと状態管理をまとめて管理してくれます。

そのため自分たちで実装しなければいけない部分が絞られて、全体的にとてもシンプルな構成になります!

フォルダ構成

上記のアーキテクチャを踏まえた上で、全体のフォルダ構成は下記のようになりました!

src/
├── components/
│   ├── pages/
│   │   └── Task/
│   │       ├── index.tsx
│   │       ├── index.test.tsx
│   │       ├── hooks.tsx
│   │       └── style.ts
│   ├── domain/
│   │   └── task/
│   │       ├── TaskList/
│   │       │   ├── index.tsx
│   │       │   ├── index.test.tsx
│   │       │   ├── stories.tsx
│   │       │   ├── hooks.ts
│   │       │   └── style.ts
│   │       └── services/
│   └── uiParts
│       ├── Loading/
│       └── Button/
├── pages/
│   ├── _app.tsx
│   ├── _document.tsx
│   └── index.tsx
├── interaction/
│   └── task/
│       └── searchTask/
│           └── index.ts
├── graphql
│   ├── generated
│   │   ├── hooks.ts
│   │   ├── operations.ts
│   │   └── types.ts
│   ├── mutations
│   └── queries
├── hooks/
└── services/

各フォルダの役割を順番に解説していきます。

components/pages

こちらの記事を参考にして作成することにしたディレクトリです。

ページの実態をここで定義してsrc/pagesでインポートします。

アーキテクチャのPresenter部分に相当して、役割としては下記になります。

  • src/components/domainからコンポーネントをインポートしてページを定義する
  • GraphQLクエリを実行して取得したデータを子コンポーネントへ渡す
  • src/interactionからインポートしたオペレーションを子コンポーネントへ渡す

実装イメージ

import useSearchTask from "~/interaction/task/useSearchTask";
import TaskSearchForm from "~/components/domain/task/TaskSearchForm";

const Task = (): JSX.Element => {
  const { searchTask } = useSearchTask()

  return (
    <>
      <h1>タスク検索</h1>
      <TaskSearchForm 
        onSubmit={searchTask}
      />
    </>
  );
};

export default Task;

components/domain

ドメイン知識を持ったコンポーネントを定義します。

ここで言うドメイン知識を持つとは下記のような状態です。

  • 特定のWebアプリケーションの知識持っている状態
  • コンポーネント内の変数にWebアプリケーション特有の型を持つオブジェクトが存在している
  • 他のWebアプリケーションで流用できない

src/components/uiPartsやMUIからUIコンポーネントをインポートして、ドメイン知識を持ったコンポーネントを定義します。

アーキテクチャのView部分に相当して、役割としては下記になります。

  • UIのレンダリング
  • 場合によってはGraphQLクエリを実行して取得したデータを子コンポーネントへ渡す
    • 後述しますがここのコンポーネントでGraphQLクエリの実行を許可するかどうかについては絶賛悩み中です
  • ユーザーイベントを元に、親コンポーネントから受け取ったオペレーションを実行する

components/uiParts

ドメイン知識を持たないコンポーネントを定義します。

ここで定義するコンポーネントは複数ドメインからインポートされる再利用可能なコンポーネントで、UIに関する状態・ロジックのみを持ちます。ドメイン知識を持たないのでAPIリクエストなどは行わず、極論他のWebアプリケーションでも使用できるコンポーネントになります。

本プロジェクトではMUIを使用しているのでボタンなどのコンポーネントをここで定義することはなく、MUIにない独自で定義する必要のあるUIコンポーネントやレイアウトなどを定義します。

アーキテクチャのView部分に相当して、役割としては下記になります。

  • UIのレンダリング
  • ユーザーイベントを元に、親コンポーネントから受け取ったオペレーションを実行する

pages

Next.jsのpagesです。

interaction

インタラクションロジックを持ったhooksを定義します。src/components/pagesで定義されているコンポーネントからインポートされ使用されます。

アーキテクチャのInteraction部分に相当して、役割としては下記になります。

  • 基本的にドメインやアプリケーションのロジックを定義する
    • 処理を実行する際のバリデーションなど
    • BEにAPIリクエストを送る

graphql

fetchTasks.gqlなどといったGraphQLのクエリやミューテーションを定義します。

それらを元にGraphQL Code Generatorで生成されたファイルがgeneratedに格納されます。

hooks

プロジェクト全体で使用するhooksを定義します。

services

プロジェクト全体で使用するユーティリティを定義します。

その他・検討事項

依存関係

インタラクションレイヤーを含めた全体の依存関係ですが、interactionpagesdomainuiPartsの方向のみでインポート可能としてあります。コンポーネントの同一階層のインポートを許可するかどうかは進めていく中で決める予定です。

複数domainから使用したい共通コンポーネントが出てきた場合はuiPartsとして抽出を検討したり、ドメイン知識を持たざるをえないuiPartsが出てきたらdomainに昇格させるような運用を考えています。

Atomic Designの不採用

今回FEの構成を考えるに当たって既存プロジェクトがAtomic Designを採用していることもあり当初はAtomic Designの採用を検討していたのですが、最終的には採用せず前述したようなフォルダ構成になりました。

理由としては

  • コンポーネントを作成する際にどこに分類するかが難しい
  • シンプルにコンポーネントの量が多くなる
  • MUIを採用してことでatomsやmoleculesといったコンポーネントを作ることが稀

といった感じになります。

Atomic Designのメリットの一つに再利用性が高くなるというのがありますが、それはmolecules以下だけというのが個人的な感想です。

organisms以上になるとドメイン知識を持つことが増えてくると思っていて、ドメイン知識が入ってきた時点で再利用性は極端に低くなると感じています。

そのため分類分けの観点としては複数ドメイン間で再利用可能かどうかだけでいいと思ったのと、MUIを使うことでmolecules以下のコンポーネントを作ることは少なくなるので、Atomic Designほど細かく分類する必要がないと思い、今回のような分類になりました!

domain配下のコンポーネントでのGraphQLクエリの実行を許可するか?

冒頭にも紹介したApollo GraphQL Blogのこちらの記事によると、GraphQLクエリはViewに含めた方がいいと述べられています。

基本的にデータの取得と表示はセットで、どちらかに変更がある場合はもう片方も修正する必要があるためこれらは近くに置いておいた方がいいとの理由からです。これに関してはGraphQL的にも「欲しいデータを欲しい形で取得できる」という強みが生かされているので理にかなっていると思います。

ただ個人的にGraphQLクエリをViewに含めるとなると下記のような懸念があります。

  • domainコンポーネントの責務が増える
  • PropsとGraphQLクエリの両方からデータを受け取る必要が出てきた場合、データの受け渡し周りが少し複雑になる

MVPの観点から考えるとdomainコンポーネントはViewに相当するのでデータの受け取りはPropsに限定して、Presenterに相当するpagesコンポーネントでGraphQLクエリを実行してdomainコンポーネントに渡すという形の方がdomainコンポーネントの責務が減っていいのかなとは思っています。

これに関しては現時点ではまだ結論が出てなく、実装を進める中で様子を見ながら決めていこうと考えています!

まとめ

いかがでしたでしょうか?

個人的にはアーキテクチャ、フォルダ構成ともにシンプルな構成になっていい感じになったかなと思っています。

何よりここ数ヶ月かなり頭を悩ませながら考えた末に1つの結論を出せたので、かなりいい経験になりました!

インタラクションレイヤーの詳細やGraphQLクエリの実行場所などいくつか検討事項はあるのですが、そこらへんに関しては実際に開発を進めて行く中で見えるものもあると思うので、またどこかで実際に開発を続けてみての感想もブログにしたいと思います。

最後にバイセルではエンジニアを募集しています。

少しでも興味のある方はぜひご連絡ください!

hrmos.co

hrmos.co

明日のバイセルテクノロジーズ Advent Calendar 2021は藤澤さんからGolangのテスト周りの取り組みについて紹介されるのでそちらもぜひ併せて読んでみてください!