🎆

【GraphQL】スキーマ駆動開発におけるエラーレスポンス設計パターン集

2023/09/11に公開

ハコベル物流DXシステム開発部のおおいし (@bicstone) です。普段はフロントエンドエンジニアとして ハコベル配車計画 の開発を行なっています。

この記事では、GraphQLをプロダクトに投入するにあたり検討したエラーレスポンス設計パターンについてご紹介します。

はじめに

ハコベル配車計画では、バックエンドとフロントエンド間の通信においてGraphQLを活用しています。1年ほど運用していく中で、設計における課題がいくつか表面化してきました。

今回、社内ハッカソンイベント “HackWeek” [1] が開催され、ハコベル配車計画チームでは1年間の運用の中で感じた、GraphQLスキーマの設計における悩みを振り返ることにしました。

GraphQLスキーマ設計で悩まれている方の参考になれば幸いです。

エラーレスポンスにおける課題

ハコベル配車計画では、ユーザーがフォームに入力し、GraphQLを通じて送信する画面がいくつかあります。フォームに入力された内容については最大文字数などのバリデーションを行っています。

バリデーションエラーが起こった場合は、ユーザーにフィードバックする必要があります。しかし、GraphQLにおいてはエラーレスポンスに関する取り決めはなく自由度が高いため、プロダクトごとにエラーレスポンスを設計する必要があります。

我々は1年間の運用の中で、エラーレスポンスの試行錯誤を繰り返してきました。その過程で検討した3パターンのメリット・デメリットを紹介します。

エラーレスポンススキーマの定義パターン

パターン 1. アプリケーションのエラーをすべてスキーマに表現する

まずは、起こり得るすべてのエラーのエラーコードをスキーマに定義し、エラーメッセージはフロントエンドで管理する方法を紹介します。

次の例では、送信された文字列が長すぎる場合と、都道府県IDが存在しない場合のエラーコードをスキーマ上に定義しています。

schema.graphqls
union RegisterLocationResult =
    RegisterLocationSuccessResponse
  | RegisterLocationNotFoundErrorResponse
  | RegisterLocationArgumentErrorResponse

type RegisterLocationSuccessResponse {
  result: Boolean!
}

type RegisterLocationNotFoundErrorResponse {
  target: RegisterLocationNotFoundErrorTarget!
}

enum RegisterLocationNotFoundErrorTarget {
  Prefecture
}

type RegisterLocationArgumentErrorResponse {
  locationCodeErrors: [LocationCodeError!]!
  locationNameErrors: [LocationNameError!]!
}

type LocationCodeError {
  code: LocationCodeErrorCode!
}

enum LocationCodeErrorCode {
  TOO_LONG
}

type LocationNameError {
  code: LocationNameErrorCode!
}

enum LocationNameErrorCode {
  TOO_LONG
  ENVIRONMENT_DEPENDENT_CHARACTER
}

type Mutation {
  registerLocation(
    code: String!
    name: String!
    prefectureId: ID!
  ): RegisterLocationResult!
}

下記はエラーレスポンスの例です。送信された name が長すぎる場合にエラーコードを返しています。

{
  "data": {
    "registerLocation": {
      "__typename": "RegisterLocationArgumentErrorResponse",
      "locationCodeErrors": [],
      "locationNameErrors": [
        {
          "__typename": "LocationNameError",
          "code": "TOO_LONG"
        }
      ]
    }
  }
}

メリット

  • 返し得るエラーがスキーマ上で厳密に定義されることで、スキーマを見るだけで必要なバリデーションが明確になる
  • すべてのエラーに対してエラーコードを定義しているため、フロントエンド側でエラーに応じた分岐を機械的に行いやすい
  • フロントエンド側でエラーメッセージを生成するため、文言をフロントエンド側に集約できる

デメリット

  • エラーコードの意味について認識をすり合わせる必要がある
    例えば、上記スキーマの LocationCodeErrorCodeTOO_LONG の場合、何文字以下なのか認識をあわせる必要がある
  • すべてのエラーコードを定義する必要があり、スキーマが肥大化し、実装量が増加する

評価

起こりうるすべてのエラーがスキーマに明示されるメリットがあるものの、実装工数は増加することがデメリットとして挙げられます。

バックエンド側でエラーメッセージを一元管理したい場合はおすすめしません。

そのため、フロントエンド側でエラーメッセージの扱いを柔軟にしたい場合や、エラーに応じて処理を分岐させたい場合におすすめします。

パターン 2. バリデーションエラーメッセージをGraphQLエラーと併用する

次に、仕様で定められた GraphQL エラーに、バリデーションエラーを含める方法を紹介します。

スキーマ定義は次のとおりです。スキーマ上でエラーについては定義されていません。

schema.graphqls
type Mutation {
  registerLocation(code: String!, name: String!, prefectureId: ID!): Boolean!
}

下記はエラーレスポンス例です。送信された name が長すぎる場合にエラーメッセージを返しています。

errors 内のオブジェクトプロパティは定められています。独自で追加する場合は extensions 内に入れることが推奨されています

{
  "data": {
    "registerLocation": false
  },
  "errors": [
    {
      "path": ["registerLocation"],
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "extensions": {
        "code": "UnprocessableEntity",
        "field": "name"
      },
      "message": "納品先名が長すぎます。"
    }
  ]
}

メリット

  • エラーを表現する必要がないためスキーマの記述量が最小限で済む
  • GraphQLのエラー仕様に則っているため、簡潔に実装ができる
  • バックエンドの実装において、エラーメッセージをそのまま引き渡すだけでよく、エラー定義やマッピング処理が不要となる
  • バックエンドにバリデーションロジックが閉じ込められるため、仕様変更に強い

デメリット

  • バックエンドで文言を管理しているため、フロントエンドでのエラーメッセージの柔軟性が欠ける
  • スキーマにおいてアプリケーションで発生するエラーのバリエーションが定義されていないため、型による強制は行われずメンテナンス性が低下する
  • エラー発生時の挙動がQuery, Mutationの種類に密結合なため、フロントエンドでのエラーハンドリングが煩雑になる

評価

バックエンドにバリデーションロジックが閉じ込められるため、スキーマの記述量が減り、簡潔に実装ができるメリットはあるものの、フロントエンドでエラーメッセージの柔軟性は欠けることがデメリットとして挙げられます。

バリデーションに関して実装工数をかけたくない場合におすすめします。

パターン 3. バリデーションエラーメッセージとGraphQLエラーを分ける

GraphQLエラーは構文エラーやシステムエラーなどのために使用し、バリデーションエラーは、エラーフィールドを含むPayload内で返す方法を紹介します。

スキーマ定義は次のとおりです。 messagefield を含んだ UserError の配列を返すことを明確にしています。フロントエンドでは userErrors の配列の数が0で返ってきた場合、検証に成功したと判断できます。

schema.graphqls
type Mutation {
  registerLocation(
    code: String!
    name: String!
    prefectureId: ID!
  ): RegisterLocationPayload!
}

type RegisterLocationPayload {
  userErrors: [UserError!]!
  location: Location
}

type UserError {
  message: String!
  field: [String!]
}

下記はエラーレスポンス例です。送信された name が長すぎる場合に userErrors の配列内でエラーメッセージを返しています。

{
  "data": {
    "userErrors": [
      {
        "message": "納品先名が長すぎます。",
        "field": ["registerLocation", "name"]
      }
    ]
  },
  "location": null
}

メリット

  • エラーコードを定義する必要がないためスキーマの記述量が少ない
  • バックエンドにバリデーションロジックが閉じ込められるため、仕様変更に強い

デメリット

  • バックエンドで文言を管理しているため、フロントエンドでエラーメッセージの柔軟性が欠ける
  • スキーマからアプリケーションで発生するエラーを知ることができない

評価

パターン2とは異なり、致命的なレベルのエラーとバリデーションエラーを区別できる上にパターン1よりも記述量は大きく減るメリットがあります。

記述量を減らしつつ、パターン2のデメリットを許容できない場合におすすめです。

事例紹介

ハコベル配車計画では、エラーレスポンススキーマの設計として 「パターン 1. アプリケーションのエラーをすべてスキーマに表現する」 を採用していました。

パターン1を選択した理由としては、下記が挙げられます。

  • 発生しうるエラーをすべてスキーマ上で定義でき、フロントエンド/バックエンド間でのコミニュケーションコストの削減につながる
  • 多種クライアントになることを想定し、エラーメッセージの表示をクライアントに任せたい

しかし、1年ほど運用したところ次の課題がありました。

  • 想定以上に開発工数が多くかかった
  • バリデーションエラーになるケースが少なく、フロントエンド側で分岐処理が活用される機会は少なかった

これらの課題を解決するため、 「パターン 2. GraphQLエラーにバリデーションエラーメッセージを含める」 への変更を検討しましたが、フロントエンドにおいて、システムエラーとバリデーションエラーが混同することによりエラーハンドリングが煩雑になりました。さらに型による強制が行われずメンテナンス性は低下することが分かったため許容できませんでした。

そこで、今回の議論を通して、 「パターン 3. バリデーションエラーメッセージをGraphQLエラーと分けて返す」 への変更を決定しました。

まとめ

GraphQL開発におけるエラーレスポンススキーマについて検討した3つのパターンについて紹介しました。

私達は、パターン3を選びましたが、それぞれメリット・デメリットがあるため、プロジェクトの特性によって適宜選択することが重要です。

GraphQLはスキーマ駆動開発に最適だとよく言われていますが、エラーレスポンスについては、すべてをスキーマに表現しなくても良い場合も考えられます。どこまでスキーマで表現するかの判断は難しいので、同じように設計で悩んでいる方のヒントになれば幸いです。

次回は、GraphQLのスキーマ駆動開発におけるバリデーションの取り決め設計パターン集を執筆する予定です。

参考文献

脚注
  1. 社内ハッカソンイベント”HackWeek”待望の5日間がもうすぐ到来! - RAKSUL TechBlog ↩︎

Hacobell Developers Blog

Discussion