🎄

バックエンドの開発を待たずにフロントエンドGraphQL開発をする

に公開

はじめに

下記のような経験がある人は多いと思います。

「新機能のフロントエンド開発をしたいけど、バックエンドの GraphQL 実装がまだ終わっていない...」

GraphQL を採用したプロジェクトでは、スキーマファーストな開発が理想とされていますが、実際の現場では「バックエンドの実装待ち」が発生することは珍しくありません。

この記事では、バックエンドの GraphQL 実装を待たずにフロントエンド開発を進める方法を、実務の際に考えて構築したので、それを共有します。

課題:開発のボトルネック

私のプロジェクトでは、Vue 3 + Apollo Client を使用しているので、その内容で紹介します。
プロジェクトでは、以下のような課題を抱えていました:

  1. GraphQL Codegen がリモートスキーマに依存 - スキーマの取得にバックエンド API への接続が必要
  2. 新規フィールド追加時の待ち時間 - バックエンドのマージを待たないと型定義が生成できない
  3. 並行開発の阻害 - フロントエンドとバックエンドの開発が直列化してしまう

これらを解決するため、以下の流れで修正を行いました。

解決策の全体像

┌─────────────────────────────────────────────────────────────┐
│                    開発フロー                                 │
├─────────────────────────────────────────────────────────────┤
│  1. ローカルスキーマ編集(バックエンド設計を先取り)                │
│          ↓                                                  │
│  2. ローカルCodegenで型定義を生成                               │
│          ↓                                                  │
│  3. @clientディレクティブでモックデータを使用                     │
│          ↓                                                  │
│  4. バックエンドマージ後、リモートスキーマに切り替え                 │
└─────────────────────────────────────────────────────────────┘

1. schema.graphql の導入

Before: JSON 形式のスキーマ

従来はschema.json(Introspection 結果の JSON 形式)を使用していました。しかし、これには以下の問題がありました:

  • 可読性が低い - JSON は人間にとって読みにくい
  • 編集が困難 - 新しいフィールドを追加するのが大変
  • 差分が見づらい - コードレビュー時に変更点が分かりにくい

After: SDL 形式のスキーマ

GraphQL SDL(Schema Definition Language)形式のschema.graphqlに変更しました。

# schema.graphql - 人間が読みやすい形式

type User {
  id: Int!
  name: String!
  email: String!
  # 新規フィールドもここに追加するだけ
  phoneNumber: String
  role: UserRole!
}

enum UserRole {
  ADMIN
  MANAGER
  MEMBER
  GUEST
}

type Query {
  user(id: Int!): User
  users: [User!]!
}

ローカルに存在するgraphql(SDL)ファイルを使用してのcodegen

codegen.tsで、リモートスキーマとローカルスキーマを切り替えられるようにしました:
これにより、以下のコマンドで使い分けができます:

# 通常のcodegen(リモートスキーマから取得 + codegen)
yarn codegen

# ローカルスキーマモード(すでに取得してあるスキーマをベースにcodegenを実行する)
yarn codegen:local

2. @client ディレクティブとモックデータ

ここが一番のキーポイントです。
Apollo Clientを使用していないとこちらの機能は使用できないので、もし graphql-requestなど他のgraphql clientを使用している方は独自でカスタムディレクティブなどを作成してください。

ローカルスキーマで型定義を生成しても、実際に GraphQL クエリを実行するとエラーになります。バックエンドにまだ実装されていないからです。

そこで、Apollo Client の@clientディレクティブを活用しました。

Using GraphQL directives in Apollo Client - Apollo GraphQL Docs

@client ディレクティブとは?

@clientは Apollo Client の標準機能で、「このフィールドはサーバーにリクエストせず、クライアント側で解決する」ことを示します。

query UserProfile($id: Int!) {
  user(id: $id) {
    id
    name
    email
    # バックエンド未実装のフィールド
    phoneNumber @client
    role @client
  }
}

useQuery でのモックデータ注入

@clientを付けたフィールドに値を設定するため、useQueryを拡張しました:

type Options<TResult = any> = {
  /**
   * Development環境でのみ動作するモックデータ
   * 実際のGraphQLレスポンスにマージ
   */
  mock?: DeepPartial<TResult>
}

export function useQuery<TResult, TVariables>(
  query: DocumentParameter<TResult, TVariables>,
  variables?: Variables,
  options?: Options<TResult>
) {
 // オリジナルの useQuery result (vue-apollo)
  const queryResult = useQueryApolloComposable(query, variables, options)

  // Development環境でmockが指定されている場合、resultにマージ
  const result = computed(() => {
    if (!isDev() || !options?.mock) {
      return queryResult.result.value
    }

    // 実際のデータが存在する場合のみマージ
    if (queryResult.result.value) {
      return deepmerge<TResult>(
        queryResult.result.value as TResult,
        options.mock as Partial<TResult>
      )
    }

    return queryResult.result.value
  })

  return {
    ...queryResult,
    result,
  }
}

使用例

コンポーネントでは以下のように使用します:

const { result } = useQuery(
  UserProfileDocument,
  { id: userId },
  {
    mock: {
      user: {
        phoneNumber: '090-1234-5678',
        role: 'ADMIN',
      },
    },
  }
)

ポイント:

  • isDev()によるチェックで、本番環境ではモックは適用されません
  • deepmergeを使用して、既存の API レスポンスとモックデータをマージ

MSWを使用することも考えましたが、このようなシンプルなやり方で済んだので、結局導入はしませんでした。

実際の開発フロー

ステップ 1: バックエンドの設計 issue を確認

まず、バックエンドチームと設計を共有し、追加される GraphQL スキーマの定義を把握します。

ステップ 2: schema.graphql をローカルで編集

# schema.graphql

# 新しい型を追加
type UserList {
  id: Int!
  title: String!
  status: Int!
  listType: Int!
  createdAt: DateTime!
}

# Queryに新しいクエリを追加
type Query {
  userLists(status: Int): [UserList!]!
}

ステップ 3: ローカル Codegen を実行

yarn codegen:local

これにより、型定義が生成されます!

ステップ 4: @client ディレクティブを使ってクエリを作成

query UserLists($status: Int) @client {
  UserLists(status: $status) {
    id
    title
    status
    listType
    createdAt
  }
}

クエリ全体に@clientを付けることで、サーバーへのリクエストを完全にスキップします。

ステップ 5: コンポーネントでモックデータを使用

const { result, loading } = useQuery(
  UserListsDocument,
  { status: 1 },
  {
    mock: {
      userLists: [
        { id: 1, title: 'hoge', status: 1, listType: 1 },
        { id: 2, title: 'fuga', status: 1, listType: 2 },
      ],
    },
  }
)

ステップ 6: バックエンド実装後の移行

バックエンドにマージされたら、以下の手順で本番コードに移行します:

  1. 通常の Codegen を実行
  2. @client ディレクティブを削除
  3. モックオプションを削除

まとめ

この記事で紹介した方法により、以下のメリットを得られました:

Before After
バックエンド待ちで開発が止まる 設計段階から並行開発が可能
schema.json が読みにくい schema.graphql で人間にやさしい
モックの仕組みがなく困る useQuery に統合されたモック機能
型がないと開発が進まない ローカル Codegen で即座に型生成

GraphQL のスキーマファースト開発の理想に近づきつつ、現実の開発ワークフローに対応した柔軟な仕組みを構築できました。

同じような課題を抱えているチームの参考になれば幸いです!

参考リンク

Discussion