テクノロジー

2020.12.15

Next.js + NestJS + GraphQLで変化に追従するフロントエンドへ 〜 ショッピングクーポンの事例紹介

Yahoo! JAPAN Advent Calendar 2020の15日目の記事です。

こんにちは、Yahoo!ショッピングでクーポンの開発を担当しているエンジニアの小倉です。

旧来のYahoo!ショッピングのクーポンページでは、以下のような課題がありました。

  • ヘッダーやボタンなどのデザインが古いままで、UI統一の観点で置き去りになってきた
  • 「商品検索モジュールを置くことでクーポンと一緒に商品を訴求したい」といったビジネス要求が増えてきた
  • ノンフレームワークなPHPの実装で、ビューに多くのロジックが侵略し、テスタブルな構成になっていなかった

これらを解消し、UI統一やビジネス要求などの変化に追従できるフロントエンドシステムにするため、2020年4月からリニューアルを始めました。
この中でどのような苦悩があり、どのような工夫をしてきたのかを紹介します。

今回リニューアルしたクーポン詳細ページ

システム構成

はじめに、リニューアル後のおおまかなシステム構成図をご紹介します。
構成としては、Private PaaS上にフロントアプリケーションとBFF(Backends For Frontends)アプリケーションが存在しており、その間をGraphQLによってやりとりするという形です。

システム構成図

また、BFFは以下のバックエンドマイクロサービスを集約し、レスポンスをフロント都合に加工する役割を担っています。

  • Common UI Service: ユーザー情報を加味して、ショッピング共通モジュール(ヘッダーやフッターなど)のレンダリング済みHTMLを提供するgRPCサービス
  • User Service: ショッピング共通で必要となるユーザー情報を集約し提供するgRPCサービス
  • Coupon Service: クーポン情報に関する主要なCRUDエンドポイントを提供するRESTサービス
  • Item Search Service: 商品の検索エンドポイントを提供するRESTサービス

フロントとBFFの技術スタックは以下のようになっています。

  • フロント: React, Next.js, TypeScript, Apollo GraphQL
  • BFF: NestJS, TypeScript, Apollo GraphQL

さらに、ショッピング共通のUIコンポーネントライブラリがプライベートなnpmパッケージとして運用されており、フロントではこれを利用しています。

技術選定

次に、これらの技術スタックを採用した理由を説明していきます。

社内の豊富な知見や共通UIライブラリを活用するためReactを選択

ヤフーでは多くのサービスがReactを採用しており、社内の知見が多いのが魅力です。
社内のReact導入事例については、以下の記事を参照してください。

ヤフー株式会社におけるWebフロントエンドの技術選定

また、Yahoo!ショッピング内では、Reactによる共通UIライブラリが運用されてきました。
クーポンページがUI統一で置き去りになってきた背景や、他のUIコンポーネントを再利用する機会も増えてきた背景を踏まえると、技術スタックをReactに合わせることで、これを活用する選択肢は必然的でした。

Reactによる開発自体は初めてだったものの、後述の通り、Next.jsがフロントエンド開発における面倒な部分をほとんど肩代わりしてくれているため、思ったより敷居は高くないと感じました。

また、ステート管理についても、Hooks APIを利用したステート管理の実装が直感的でわかりやすく、ローカルステートだけで済ませるなら、公式のチュートリアルだけでも実装の素振りとしては十分でした。

Next.jsでゼロコンフィグなフロントエンドへ

Reactベースのフロントエンドフレームワークとして、社内でも比較的事例が多いNext.jsを採用しました。

Next.jsはSSR(サーバーサイドレンダリング)が特徴として挙げられることが多いですが、それ以上に(ほぼ)ゼロコンフィグなフロントエンド開発が魅力です。
具体的には、以下のようなメリットが挙げられます。

  • webpackやbabelといったフロントエンドのビルド環境をフレームワーク側で内包
  • ファイルシステムを利用した手軽なルーティング機能
  • TypeScriptのビルトインサポート
  • CSS Modulesや数々のCSS in JSのビルトインサポート
  • 数々のパフォーマンス最適化

実際、今回のようにフロントエンド開発の知見が少ないチームでは、これらの恩恵は非常に大きかったです。
特に、webpackやbabelの設定といった部分は、カスタマイズし始めると属人化しやすい傾向にありますが、現状はほとんど弄らずに済んでいます。
スタイリングについてもCSS Modulesを採用していますが、Next.jsによる標準サポートの範囲内で問題なく利用できています。

こうした理由もあって、Reactの開発に対する敷居は当初想像していたよりもはるかに低く感じられました。

BFFを作ることでバックエンドとのギャップを埋める

今回の開発では、フロントに加えてBFFも構築することにしました。
その理由として、バックエンドマイクロサービスの数が多いことに加え、以下のようにフロント都合のデータ加工が多いことが挙げられます。

  • 条件によって細かく文言を出し分けたりと、ビューに侵略するロジックが多くなる
  • 同じリソース情報でも、用途によって異なるデータ加工が必要になる
  • バックエンドマイクロサービスのレスポンスフィールドのうち、複数を組み合わせた計算が必要になる

実際に開発を行うと、バックエンドサービスとUIで求められるデータのギャップは予想以上に大きかったです。
そういう意味でBFFを作るという選択は悪くなかったと感じており、むしろデータ加工の責務をもっとBFFへ委譲しても良かったと思うくらいでした。

Spring Bootに近いNestJSで参入障壁を下げる

BFFの言語選定は、フロントと定数ファイルや型定義を直接共有できるという観点で、Node.jsを考えていました。
一方、チームとしてNode.jsの習熟度は高くないため、フレームワーク選定では参入障壁を下げたいと考えた結果、NestJSにたどり着きました。

クーポンチームではJava/Spring Bootによるバックエンド開発が多かったのですが、実はSpring BootとNestJSには多くの類似点があります。

  • フレームワークのエコシステムとしてDIの機構を用意している
  • デコレーターやアノテーションによって関心の分離を行うことができる
  • Interceptorや大域エラーハンドリングなどのデザインパターンをフレームワークの機能として提供している

特に、NestJSのデコレーターによる関心の分離は、Spring Frameworkのアノテーションを多用する思想に深いシンパシーを覚え、Springユーザーのわれわれにとってはなじみやすかったです。
実際、NestJSで用意されているデザインパターンの多くは、「ああ、Springでいうところのアレか」という感じで類推できるところが良かったです。

また、後述のGraphQLを採用するにあたって、ビルトインサポートが十分に整っており、公式ドキュメントも簡潔な英語でわかりやすく、困る部分はほとんどありませんでした。

GraphQLの導入によってプロダクトの成長を支える

GraphQLは、Web API規格の1つで、以下のような特徴があります。

  • クエリ言語を記述することで、クライアント側が欲しいリソースとその形式を宣言できる
  • 単一のエンドポイントおよびリクエストで複数のリソースを取得できる
  • 型定義やクライアント実装の自動生成、クエリを試しに発行できるクライアントGUI機能、クエリ補完機能などといった開発体験の向上

GraphQLの特徴については、公式ドキュメントの他に、エンジニアHubのgfxさんの記事が参考になります。

「GraphQL」徹底入門 ─ RESTとの比較、API・フロント双方の実装から学ぶ(外部リンク)

特に、REST APIとしてBFFを開発すると、SSRとCSRを両立している場合、SSR用のエンドポイントに加えて、CSR用に部分的なリソース取得を行うエンドポイントを作成し……といったようにエンドポイントの数が増えていきます。
こういったケースで、GraphQLは真価を発揮します。いったん、スキーマとそれに対するリゾルバーさえ定義してあげてしまえば、新たにエンドポイントやI/Fの設計を行う必要がなくなるのです。

実際に導入してみて、後述の通り、型定義とhooksの自動生成やfragmentの活用によって、開発体験は大きく向上しました。
一方、Next.jsにおけるSSRとの相性やエラーハンドリングには、後で紹介するような落とし穴があります。

そのため、今回の要件の範囲であれば、他の選択肢も検討の余地があったかもしれません。
例えば、単純に型定義とhooksの生成が目的ならば、BFFはGraphQLサーバーではなく単純なREST APIとして作り、クライアント実装はaspidaでSWRのhooksを生成するといった方法もあります。
とはいえ、今後開発するページやモジュールが増えていくことを考えると、それ相応の導入価値はあったと感じます。

工夫と苦悩

graphql-codegenの型生成によってフロント〜BFFを型安全にする

今回は、最初にGraphQLスキーマを書いてから開発する、スキーマファーストな形式で開発しました。そして、フロント・BFFのそれぞれでGraphQLスキーマからtsの型定義やhooksを生成するようにしています。

BFFに関しては、NestJSがGraphQLにおける型生成をビルトインサポートしているため、公式ドキュメントを参照してください。

フロントに関しては、GraphQL Code Generatorを導入して型定義を生成します。
また、@graphql-codegen/typescript-react-apolloを用いることによって、queryやmutationに合わせてカスタマイズされたApolloクライアントのhooksやHOCなどを自動生成できます。

schema: ../gql/scheme/*.graphql
documents:
  - ./components/**/*.graphql
  - ./hooks/**/*.graphql
  - ../gql/queries/*.graphql
  - ../gql/mutations/*.graphql
overwrite: true
generates:
  ./gql/schema.tsx:
    plugins:
      - typescript
      - typescript-operations        # query, mutation などから型を生成する
      - typescript-react-apollo      # query, mutation などからhooks,hocを生成する
    config:
      withHooks: true
      withHOC: true
      withComponent: false
      reactApolloVersion: 3
  ./gql/schema.json:
    plugins:
      - introspection

これによって、ローディングやエラー処理も簡潔に記述できます。

const ItemSearch: FunctionComponent<Props> = props => {
  ...
  // 商品検索用のqueryから生成されたhooks (useSearchItemListQuery)
  // data: 
  const {
    data,       // クエリの結果(型がついている)
    loading,    // ローディング中であることを示すState
    error,      // クエリのエラーを保持するState
    refetch,    // 再fetchするためのハンドラ
  } = useSearchItemListQuery({      // 自動生成されたhooks
    ssr: false,
    variables: {params},
  });
  ...
}

GraphQLのfragmentによってDemand Drivenに情報を取得する

GraphQLには、スキーマのサブセットを表現するためにfragmentという概念があります。

# クーポンの利用条件を表示するコンポーネント(ConditionList)に対応するfragment
fragment ConditionList on Coupon {
    usePeriod {
        startTime
        endTime
    }
    discount {
        type
        price
        ratio
    }
    ...
}

さらに、このfragmentをまとめあげてqueryを作ったり、新たなfragmentを作ることもできます。

#クーポン詳細情報を表示するコンポーネント(CouponDetail)に対応するfragment
fragment CouponDetail on Coupon {
    title
    description
    imageUrl
    linkUrl
    discount { type ratio price }
    # 利用条件のfragment
    ...ConditionList
}
# クーポン詳細ページのquery
query DetailPage($couponId: String!) {
    coupon(id: $couponId) {
        # クーポン詳細情報のfragment
        ...CouponDetail
    }
    user {
        # レイアウトコンポーネントのfragment
        ...Layout
    }
    # 獲得処理hooksのfragment
    ...UseObtainCoupon
}

今回は、Fragment Collocationという考え方を採用して、以下のような構成にしました。

  • コンポーネントごとに必要十分なデータをfragmentとして表現し、コンポーネントと一緒に配置
  • 親コンポーネントでは、子コンポーネントのfragmentをまとめあげて新たなfragmentを用意
  • ページコンポーネントでは、さらにfragmentをまとめあげてqueryをつくる
  • カスタムhooksで必要となるデータもfragmentとして定義

こうすることで、いくつかのメリットがあります。

  • queryにおけるフィールドの取得漏れがなくなる
  • 必要以上にフィールドを取得しないため、ネットワーク帯域やJSONシリアライズ処理のオーバーヘッドを抑えることができる
  • 単体テストやStorybook用のスタブデータを作りやすい
  • 型推論による候補も最小限になるため、どのフィールドを使えばよいか迷わない
  • コンポーネントのpropsは、基本的にfragmentから生成された型がほとんどになるため、見通しがよい

fragmentを活用しなかった開発当初では、実際にqueryにおけるフィールドの取得漏れが起きたり、モックデータが作りにくいなどの課題があったため、導入に至りました。

ページコンポーネントでqueryを発行し、graphql-anywhereによってfragmentに変換する

実運用では、「GraphQLのクエリ発行をどこで行うか」「fragmentなどの必要なデータ定義をどこで行うか」についても考える余地があります。
これに関しては、vivit株式会社さんのテックブログ記事がとても参考になるので、そちらをご参照ください。

GraphQL の Fragment でコンポーネントの見通しがよくなった話(外部サイト)

具体的な選択肢としては、以下が挙げられます。

パターン データ定義を行う場所 クエリ発行を行う場所
パターンA 各コンポーネント 各コンポーネント
パターンB ページコンポーネントのみ(fragmentを利用しない) ページコンポーネントのみ
パターンC 各コンポーネント(fragmentを利用) ページコンポーネントのみ(fragmentをまとめあげてクエリを表現)

今回は、fragmentを活用するためにパターンCを採用しており、厳密には、以下のように方針を定めています。

  • SSR時のクエリ発行: ページコンポーネント単位
  • CSR時のクエリ発行: CSRが必要なコンポーネント単位

この際、取得したqueryの結果をどのようにfragmentへ変換するかというのがポイントです。
そこで、graphql-anywherefilter メソッドを用いることで、クエリ結果をfragmentへ変換します。

以下は、簡略化されたクーポンページの実装例です。

type DetailPageProps = {
  // GraphQLクエリの結果
  data: Query
}

const DetailPage: FunctionComponent<DetailPageProps> = ({
  data
}) => {
  // couponはGraphQLのCouponスキーマ、userはUserスキーマに相当
  const { coupon, user } = data;

  return (
    <Layout fragment={filter(LayoutFragmentDoc, user)}>
      <Paper>
        <CouponDetail
          fragment={filter<CouponDetailFragment>(CouponDetailFragmentDoc, data)}
        />
      </Paper>

      <Paper>
        <TargetItems
          fragment={filter<TargetItemFragment>(TargetItemFragmentDoc, coupon)}
        />
      </Paper>

      <HowToUse />

      <Paper>
        <PrecautionText
          fragment={filter<PrecautionTextFragment>(PrecautionTextFragmentDoc, data)}
        />
      </Paper>
    </Layout>
  );
};

export const getServerSideProps: GetServerSideProps<DetailPageProps> = async context => {
  // fetch処理
}

例えば、CouponDetail コンポーネントをみてみましょう。
GraphQL Code Generatorによって、CouponDetailFragmentDoc というgqlタグと CouponDetailFragment という型定義が自動生成されます。
graphql-anywherefilter では、第二引数の coupon (GraphQLの Coupon スキーマ)を CouponDetailFragmentCoupon スキーマに対するfragment)へ変換できます。

また、このような基底スキーマ→fragmentの変換だけでなく、親コンポーネントのfragment→子コンポーネントのfragmentといった変換も可能です。

Apollo ClientはSSR時にパフォーマンスオーバーヘッドが生じる

現状、Next.jsのSSRにおいてApollo Clientを用いると、レンダリングのパフォーマンスオーバーヘッドが生じるという報告があります。
詳しくは、Seiji Takahashiさんの記事や公式のDiscussionをご参照ください。

Real World GraphQL on Next.js SSR(外部リンク)
Using GraphQL API in getStaticProps or getServerSideProps #10946(外部リンク)

そのため、データフェッチの方法を以下のように使い分けました。

  • CSRを行うコンポーネント: 自動生成されたApolloのhooksによるデータフェッチ
  • SSRを行うコンポーネント(基本的にページコンポーネント): getServerSideProps 内でaxiosによるデータフェッチ

Discussionの内容を追っていくと、「useQueryQuery コンポーネントを利用したReactツリーが深くネストされている場合」というケースで起こるようなのですが、今後コードベースが成長していくことを考えると危惧すべき点です。
ただし、「SSR時のクエリ発行はページコンポーネントのみにする」という開発方針を定めたため、現状はクエリを発行するコンポーネントがネストする可能性は低いです。
そのため、現在ではSSR時でもApollo Clientを利用することを検討しています。

GraphQLのエラーハンドリングには少しクセがある

GraphQLサーバーでは、レスポンスのHTTPステータスを 200 OK で統一し、レスポンス内の errors キーに詳細な情報を持たせるという特徴があります。

なぜなら、GraphQLでは1リクエストに複数のクエリを含めることができ、リクエストが部分的に失敗することで、実際のデータとエラーが混合された状態で返却されるためです。
詳しくは、以下のZOZO Technologiesさんの記事が参考になります。

GraphQLにおけるエラーハンドリングの仕方(外部リンク)

例えば、以下の例では、ユーザー情報の取得に成功していますが、クーポン情報の取得には失敗しています。

{
  "errors": [
    {
      "message": "coupon detail is unavailable.",
      "path": [
          "coupon"
      ],
      "extensions": {
        "code": "COUPON_DETAIL_UNAVAILABLE",
        "couponId": "hogehoge"
      }
    }
  ],
  "data": {
    "user": {
      "isLogin": true
    }
  }
}

この場合、GraphQLサーバーからは 200 OK として返却されていますが、肝心のクーポン情報が取得に失敗しているため、クライアント側にあたるクーポンページとしてはエラー画面を表示したいです。
また、逆にユーザー情報の取得に失敗したがクーポン情報は取得できている場合、エラー画面にはせずクーポンページを表示したいです。
こうした場合、errors のリスト要素について、path の中に "coupon" が含まれるものが存在するかどうかを判定します。

const hasCouponError = (
  errors?: ReadonlyArray<GraphQLError>,
): boolean => {
  return errors?.findIndex(error => error?.path?.includes('coupon')) >= 0;
}

さらに、クーポン情報が 404 NOT FOUND な場合と、そもそもサーバーが 503 UNAVAILABLE な場合で、エラーメッセージを出し分けたいなどといった要件もあるでしょう。
その場合は、extensions 配下の code も検証した上でハンドリングを行う必要があり、実装が煩雑になるため注意が必要です。

openapi-generatorの型生成によってBFF〜マイクロサービスを型安全にする

BFF〜マイクロサービス間も型安全にするために、openapi-generator-cliを利用しました。
例えば、以下の手順だけで、出力先フォルダーにリクエスト・レスポンスの型定義やaxiosのラッパー関数が自動生成されます。

$ yarn add -D @openapitools/openapi-generator-cli
$ yarn run openapi-generator-cli generate -i スキーマファイル -g typescript-axios -o 出力先フォルダー

注意する点として、ショッピングクーポンのバックエンドはSpring Boot製なのですが、swaggerの定義ファイルは、springfoxを用いてソースコードから自動生成したものが多いです。
つまり、スキーマファーストではなくコードファーストに生成されたswaggerであるため、nullableかどうかといった情報までは信用しないようにしていました。

とはいえ、自分で型定義を作らずとも、バックエンドサービスの仕様変更に気づくことができ、型推論が効くだけでも開発体験は大きく向上します。

Atomic Designに縛られず依存関係を整理したファイル構成にする

当初のファイル構成はAtomic Designに倣っていましたが、最終的には、下記のような機能ごとにまとまったファイル構成に落ち着きました。
なぜなら、再利用性が高いコンポーネントのほとんどは共通UIライブラリに存在していて、このフロントエンドではそのUIコンポーネントを組み上げてモジュールやページを作るのが役割だからです。

components
├── pc
│   ├── atoms
│   │   ├── heading
│   │   │   ├── Heading.tsx
│   │   │   └── style.module.scss
│   │   └── paper
│   │       ├── Paper.tsx
│   │       └── style.module.scss
│   ├── coupon-detail
│   │   ├── CouponDetail.tsx
│   │   ├── fragment.graphql
│   │   └── style.module.scss
│   ├── item-search
│   │   ├── ItemSearch.tsx
│   │   ├── Pagination.tsx
│   │   ├── ResultItem.tsx
│   │   ├── SearchBox.tsx
│   │   ├── SearchOptions.tsx
│   │   ├── fragment.graphql
│   │   └── style.module.scss
│   ├── layouts
│   │   ├── Layout.stories.tsx
│   │   ├── Layout.tsx
│   │   ├── Masthead.stories.tsx
│   │   ├── Masthead.tsx
│   │   ├── Footer.stories.tsx
│   │   ├── Footer.tsx
│   │   ├── fragment.graphql
│   │   └── style.module.scss
...

また、atomsに相当する新たなUIパーツはできるだけ生み出さないようにし、共通UIライブラリで提供されるUIパーツに寄せて行くようにしました。
こうすることで、UI統一の観点でも優れたフロントエンドになりますし、コンポーネントの分類に悩むことはなくなりました。

このように、「既に存在するなんらかのデザインシステムを利用する立場のフロント開発」では、無理にAtomic Designを採用せずシンプルに機能ごとにまとめる選択肢も一考かと思います。

疎結合なレイアウトコンポーネントで新規ページを爆速プロトタイピング

レイアウトコンポーネントを作るにあたって、気をつけたことが2点あります。

まず1点目は、fragmentをユーザー情報のスキーマ User のみへ依存するようにしたことです。
例えば、fragmentがクーポン情報のスキーマに依存してしまうと、そのレイアウトを用いるすべてのページでクーポン情報を取得することになってしまいます。
実際、エラーページやちょっとしたヘルプページでは、ヘッダーやフッターなどを表示するためのユーザー情報以外はほとんど不要です。

type User {
    isLogin: boolean
    deviceType: DeviceType
    isNewMember: boolean
    isPremiumMember: boolean
    ...
}

fragment Layout on User {
    ...Masthead
    ...Emergency
    ...Footer
    ...AnalyticsScript
}

2点目は、必要以上にpropsをrequiredにしないことで、できるだけレイアウトコンポーネントの再利用性を高めたことです。
これはコンポーネント設計全般に言えることではありますが、粒度が大きくpropsが肥大化しやすいレイアウトコンポーネントだからこそ意識すべきことであるといえます。

type Props = {
  children: ReactNode
  fragment?: LayoutFragment
  title?: string
  topicPathList?: TopicPathPropsList
  analyticsSetting?: AnalyticsSetting
};

const Layout: FunctionComponent<Props> = ({
  children,
  fragment,
  title,
  topicPathList,
  analyticsSetting,
}) => {
  return (
    <div className={style.Layout}>
      <Head>
        <title>ショッピングクーポン - Yahoo!ショッピング - {title}</title>
        <link rel="stylesheet" type="text/css" href={CssLinkConstants.PcStyle} media="all"/>
      </Head>

      <Masthead fragment={filter(MastheadFragmentDoc, fragment)}/>

      <Emergency fragment={filter(EmergencyFragmentDoc, fragment)}/>

      <TopicPath pathList={topicPathList}/>

      <div>
        {children}
      </div>

      <Footer fragment={filter(FooterFragmentDoc, fragment)}/>

      <AnalyticsScript fragment={filter(AnalyticsScriptFragmentDoc, fragment)} analyticsSetting={analyticsSetting} />
    </div>
  )
};

export default Layout;

これによって、新規ページの実装を爆速でプロトタイピングできます。実際のStorybookと、それによって出力されるUIを見てみましょう。

export const story1 = () => (
  <ShpLayout
    title={'タイトルです'}
    pageType={'dummy'}
    paths={[
      ...basePathProps,
      { url: 'http://yahoo.co.jp', text: 'aaaaaaaaaaa' },
    ]}
  >
    <Paper>
      testtesttest<br/>
      testtesttest<br/>
      testtesttest<br/>
      testtesttest
    </Paper>
  </ShpLayout>
);
story1.storyName = 'default (Fragmentなし)'

レイアウトコンポーネントのStorybook

初めて開発するメンバーへのインプット時には、このレイアウトコンポーネントで実際にプロトタイピングすることで、開発の勘所を抑えてもらうことにしています。

モジュールレベルで閉じた状態管理はuseStateで事足りる

コンポーネントを横断する状態管理に関しては、以下のような選択肢があります。

  • useStateuseReducer のローカルステートをpropsのバケツリレーやコンポジションパターンによって伝搬する
  • Context APIを用いてグローバルステートを管理する
  • Reduxを用いてグローバルステートを管理する

ただし、Reduxは要件によって学習コストが見合わず、Context APIは予期せぬ再レンダリングの発生やユニットテストが難しくなるなどの懸念があります。
そこで、まずは可能な限り useState のみで実装することを心がけようという方針を定めました。

現状、最も大きなステートは、一部クーポンで表示される商品検索モジュールなのですが、基本的にモジュール内で閉じた状態管理に尽きます。
そのため、むやみにページレベルでグローバルステートを定義せず、必要最小限のコンポーネントレベルで useState を呼び出し、そこから子コンポーネントへpropsを通じてバケツリレーしています。

BFFの責務を明確化する

BFFにおけるアンチパターンとして、BFFに責務を持たせすぎるファットBFFが指摘されます。
具体的には、BFFの主な責務はマイクロサービスの集約とデータ加工ですが、それ以上のドメインロジックやフロー制御などを持たせるのは推奨されません。

当初、こうした意見を取り違えてしまい、ファットBFFを気にする余り、肝心のデータ加工の責務を軽くしすぎてしまったことがありました。
こうなると、結局、データ加工のロジックがフロント側に流出してしまいます。

そこで、こうしたことで悩まぬよう、以下のように責務の境界をドキュメントとして言語化しました。

ユースケース フロント BFF
データを組み合わせた計算 総ページ数: Math.ceil(総件数 / ページあたり表示件数)
データを組み合わせた判定 クーポン利用期間内かどうか: 利用開始日時 <= 現在時刻 && 現在時刻 <= 利用終了日時
メッセージの出し分け 対象商品種別 === ブランド"特定ブランドの商品が対象です。"
enum化 送料種別: 割引種別コード === 1定額値引き ×
表記や単位の変換 桁カンマ挿入: 9999"9,999" ×
クラス名生成 レビューのcssクラス名: レビュー平均点 === 3.6"rate35" ×

また、クーポンのバックエンドはレイヤードアーキテクチャを採用していますが、BFFではドメインロジックを持たないためドメイン層は不要です。
そのため、レイヤードアーキテクチャは採用せずフラットな構成にしてコードベースを抑えました。

src/
├── app.module.ts
├── config
│   └── ...
├── guard
│   └── ...
├── logger
│   └── ...
├── main.ts
└── resolver
    ├── common-ui
    │   ├── common-ui.adapter.ts
    │   ├── common-ui.module.ts
    │   ├── common-ui.resolver.ts
    │   └── common-ui.translator.ts
    ├── coupon
    │   ├── coupon.module.ts
    │   ├── coupon.resolver.ts
    │   ├── detail
    │   │   ├── coupon.detail.adapter.ts
    │   │   ├── coupon.detail.module.ts
    │   │   ├── coupon.detail.translator.ts
    │   │   └── openapi
    │   └── obtain
    │       ├── coupon.obtain.adapter.ts
    │       ├── coupon.obtain.module.ts
    │       ├── coupon.obtain.translator.ts
    │       └── openapi
    ├── item-search
    │   ├── item-search.adapter.ts
    │   ├── item-search.module.ts
    │   ├── item-search.resolver.ts
    │   ├── item-search.translator.ts
    │   └── openapi
    └── user
        ├── user.adapter.ts
        ├── user.module.ts
        ├── user.resolver.ts
        └── user.translator.ts

基本的には、スキーマごとに以下のようなコンポーネントを作成しています。

  • Resolver: GraphQLのスキーマリゾルバーとしての処理を行う
  • Adapter: fetch処理の実行やハンドリングを行う
  • Translator: バックエンドマイクロサービスのレスポンスをフロント都合に加工する

なお、AdapterやTranslatorといった命名は、ドメイン駆動設計などの文脈で登場するデザインパターンにならっています。

おわりに

それぞれの技術スタックを導入した感想を振り返ると、以下になります。

  • Next.jsは、フロントエンドの足回りが非常に整いやすく、React入門フレームワークとしてもおすすめできると感じた
  • NestJSは、狙い通りSpringユーザーにとってなじみやすく、GraphQLサーバーとしても十分にエコシステムが整っていた
  • GraphQLは、SSRのパフォーマンスやエラーハンドリングで思わぬ落とし穴があったものの、開発体験の向上という面ではそれ以上の恩恵があると感じた
  • Reactの開発自体は初めてだったが、Next.jsによって参入障壁はかなり低くなっていると感じた

また、今回採用した技術スタックは開発コミュニティーとしても盛り上がりが大きく(特にNext.jsは今回の開発中にv9.5やv10.0がリリースされました)、今後ますますの改善が期待できると感じました。

今後も、リニューアルしたフロントエンド基盤で、皆様に爆速でおトクを届けていきますので、Yahoo!ショッピングのクーポン一覧ページから、ぜひご活用ください!


小倉 陸
Yahoo!ショッピングエンジニア
Yahoo!ショッピングでクーポンの開発をしています。フロントエンド・サーバーサイド・機械学習のモデル実装まで携わっています。

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

関連記事

このページの先頭へ