水曜日, 8月 20, 2025
水曜日, 8月 20, 2025
- Advertisment -
ホームニューステックニュースGraphQLスキーマ設計:Global Object ID、エラーハンドリング、Non-null、Cursorの考え方

GraphQLスキーマ設計:Global Object ID、エラーハンドリング、Non-null、Cursorの考え方



PeopleXのエンジニアの野口(@joe_re)です。

最近活動できていないのですがGraphQL Tokyoというコミュニティのオーガナイザをしています。

https://www.meetup.com/ja-JP/graphql-tokyo/

自分はGraphQLを長くProductionで使っていて、Prismaの前段プロジェクトであるgraphcoolを導入していました。

https://www.graph.cool/

graphcoolは2020年に終了しています。記憶では自分は2016-2017年ごろに使っていたと思います。
それ以降も、GraphQLをproduction運用してきました。GraphQLがオープンソース化されたのが2015年なので、オープンソース化されてからの期間で考えると、発表後のほとんどの時間をGraphQLに関わってきました。
振り返れば自分のエンジニア人生においては非常に大きく関わってきた技術だなぁと思います。

長ければすごいというものでもないのですが、経験値はあるということでよくGraphQLスキーマの設計について質問される機会があります。

この記事では、よく質問されていて、かつ初見ではわかりづらそうなものをピックアップして紹介しようと思います。

仕様やプラクティスの説明と自分の考えは分けています。
「これを踏まえて筆者の考え方」の項目は、あくまで筆者はこう考えているということを示しています。
他の考え方もあると思いますので、それを拒絶するものではありません。

GraphQLの仕様ではないですが、公式が紹介するベストプラクティスとして、Global Object Identificationというものがあります。

https://graphql.org/learn/global-object-identification/

内部構造(DB内のIDなど)によらず、必ず一意となる文字列をIDとするべきというプラクティスで、 User:1234 のような値をbase64 encodingしたものが使われます。

これは元々はRelayのcacheの仕組みで、クライアントでnormalized cacheを生成するときのキーとして必ず一意となる値を採用する必要があったところから始まっています。

Relayにおいてはこの制約を利用して、Paginationのクエリを自動生成もしています。

キャッシングの観点では、現在のApollo clientではtype name + idをキャッシュのキーとしているように必須ではありません。

この仕様は、Node Interfaceを提供できるところとセットで価値があります。

Node Interfaceは ID! フィールドを持つinterfaceです。ベストプラクティスでは、スキーマ上のオブジェクトの全てがこれをimplementするようにします。
その上で、Node interfaceをクエリできるようにすることで、全てのオブジェクトがIDをキーに取得可能な単数クエリを提供します。

以下のような定義になります。

interface Node {
  id: ID!
}

type User implements Node {
  id: ID!
  name: String!
}

type Article implements Node {
  id: ID!
  title: String!
}

type Query {
  node(id: ID!): Node
}

この例では、User, Articleの両方ともにトップレベルにあるnode queryで取得できるようになっています。
以下がクエリの例です。


node(id: VXNlcjoxMjM0Cg==) {
  id
  ... on User {
    name
  }
}

Global Object IDでbase64エンコードした値を使う理由はここにあります。
DBに持つテーブルのIDをインクリメンタルな整数値として持っていた場合、このクエリは成立しません。User, Articleともに同じIDを持つ可能性があるためです。

DB内のIDをUUIDとして持っている場合には、base64エンコードせずとも一意な値になっていますが、それはあくまで内部構造上の話です。

GraphQLのスキーマはDBに永続化されたデータとは直結しないので、時にはテーブル上のデータを表さない時もあるでしょう。

全てのオブジェクトが単一のnode queryで返されるという特性から、心理的にも、IDの中にtypenameを持って必ず一意になることが保証されている方が望ましいです。

RelayではNode interfaceが実装されていることを前提に、cacheの更新を自動化したりもしています。

これを踏まえて筆者の考え方

よく聞かれることが、Relayを使ってない場合にこの実装が必須かどうかという点です。筆者は必須ではないかな、と考えています。

同僚からはbase64エンコードされていると調査時にいちいちデコードしないとIDがわからないので不便という意見も聞きました。

必須とは思いませんが、実装も難しくないし、自分は推奨はしたいです。

単純に単数系のクエリを悩まずに提供できるというだけでもその利点はあります。

筆者はGraphQLではユースケースをスキーマ上に表現することが大切だと考えています。
その観点では、単数系のクエリと複数形のクエリではそれぞれが必要なユースケースが異なるため、それぞれ単数系と複数形は別々に提供するべきです。

例えばユーザプロフィールページでユーザの詳細を取得するケースを考えた時に、必要十分だからと言ってユーザリストを取得するqueryからユーザ一件を取らなければいけないとすると、それはユースケースに寄り添えているとは言えないでしょう。

userByID のようなqueryをこのために定義していくのはそれはそれで煩雑です。
node queryを通じて、一般的にユースケースが考えやすいIDをキーにしたオブジェクトの取得をデフォルトで提供することができるのは利点です。

この記事では実装の詳細には触れませんが、node interfaceをimplementしているオブジェクトをスキーマ上から取得することで、node resolverの実装は自動化することもできます。

GraphQLにはエラーを返す手段が仕様に定義されています。

https://spec.graphql.org/draft/#sec-Errors

しかしこのエラーはGraphQLのスキーマの外側になるため、型情報や、どんなエラーが発生する可能性があるのかなどをクライアントにあらかじめ伝える手段がありません。

また、2025年の1月にapplication/graphql-response+jsonのcontent typeが仕様として標準化されました。

https://mbonnin.net/2025-01-13_graphql-errors/

GraphQLのレスポンスで返すhttpステータスコードはこれまで曖昧でしたが、これによってどういう時に4xx, 5xx系を返すのか、その時にdata fieldはどういう状態になるのかが明確になりました。

仕様はこちらに定義されています。

https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json

これによると、responseにdata entryが含まれている限りは例えそれがnullであったとしても200を返すべき、と記載があります。
これはGraphQLのレスポンスが基本的にpartial responseであり、部分的にnullであることを許容しているために定められた仕様です。

逆にdata entryが含まれていない場合には4xx, 5xx系を返すべきと記載があります。
また、data entryがない場合にはerrors entryを含めないといけません。(data entryがある場合にerrors entryを含めることはできます。)

Apolloのdocumentを見ると、errorsをdata(GraphQL schema)で返すアプローチについて記載があります。

https://www.apollographql.com/docs/graphos/schema-design/guides/errors-as-data-explained#when-to-use-errors-as-data

ここにはerrorsはシステムエラーのためのフィールドであり、このエラーに関しては予期せぬものになるためクライアント側での適切なハンドリングが難しいという旨の記載があります。

そこで紹介されているのがunion typesでエラーをスキーマ上に表現する手段です。

Apolloの例をここに記載します。

union CheckoutResponse =
    Order
  | InsufficientStockError
  | InvalidPaymentMethodError

interface CheckoutError {
  message: String!
}

type InsufficientStockError implements CheckoutError {
  message: String!
  product: Product!
  availableStock: Int!
}

type InvalidPaymentMethodError implements CheckoutError {
  message: String!
  paymentMethod: ID!
}

ここでは在庫不足として、InsufficientStockError, 支払い情報のvalidationエラーとしてInvalidPaymentMethodErrorを定義しています。

クライアント側は以下のようなqueryでそれぞれのエラーケースを取得することができます。

mutation OrderCheckout($payment: ID!) {
  checkout(paymentMethod: $payment) {
    ... on Order {
      id
      items {
        product {
          name
        }
        quantity
        price
      }
      totalPrice
      status
    }
    ... on InsufficientStockError {
      message
      product {
        name
      }
      availableStock
    }
    ... on InvalidPaymentMethodError {
      message
      paymentMethod
    }
  }
}

これにより、クライアントはInsufficientStockErrorの時、InvalidPaymentMethodErrorの時のエラーハンドリングをそれぞれ適切に行うことができます。

また、エラーハンドリングをそれぞれのケースで行いたくない時には以下のようなqueryでCheckoutErrorとして、まとめて行うこともできます。

mutation OrderCheckout($payment: ID!) {
  checkout(paymentMethod: $payment) {
    ... on Order {
      id
      items {
        product {
          name
        }
        quantity
        price
      }
      totalPrice
      status
    }
    ... on CheckoutError {
      message
    }
  }
}

これはそれぞれのエラーがCheckoutErrorをimplementしているので可能になっています。
message fieldはCheckoutErrorをimplementしているそれぞれのエラーに共通のフィールドなので、このinterfaceを通じてまとめて扱うことができます。

クライアントのユースケースによって、クライアントに手段の選択を委ねられるのがGraphQLの大きな特徴であり、One-Size-Fits-All APIとしての利点だと思います。

これを踏まえて筆者の考え方

Apolloの記事にもある通り、システムエラーではerrorsを使い、アプリケーションエラーはスキーマでエラーを表現するのが良いと考えます。

エラーを返す手段としては、上記のunion typeで返す他にも、以下のようにerror用のfieldを追加して表現する方法もあります。

type CheckoutResponse {
  checkoutError: [CheckoutError!]!
  order: Order
}

enum CheckoutErrorCode {
  InsufficientStockError
  InvalidPaymentMethodError
}

type CheckoutError {
  message: String!
  code: CheckoutErrorCode!
}

どちらを使うかは好みの問題なので、プロジェクトに合わせて考えれば良いと思います。
形式が統一されていることがより重要です。

筆者はより型の恩恵を受けやすいunion typesの定義を好んで使います。

GraphQLのスキーマ設計で度々話に上がりますが、Non-null fieldを使う時は慎重になるべきという考えがあります。

これは上記のエラーフィールドのところでも述べた、GraphQL responseがpartial responseであるという仕様と、non-nullからnullableへの変更がSchemaの破壊的変更を引き起こすことの2点から語られます。

GraphQLのresponseがpartial responseであるという点から説明します。

GraphQLではフィールドでエラーが発生した時、nullableなtypeが出現するまでエラーを親のタイプに伝播させていくことが定められています。

https://spec.graphql.org/draft/#sec-Handling-Execution-Errors

以下のようなSchema定義があるとします。

type User implements Node {
  id: ID!
  name: String!
  postedArticles: [Article!]!
  favoriteArticles: [Article!]
}

type Article implements Node {
  id: ID!
  title: String!
  references: [Article]!
}

この時、favoriteArticlesを以下のようなqueryで取得します。
この時referencesの生成でエラーが発生したとします。

query {
  users {
    id
    name
    favoriteArticles {
      id
      title
      references { 

この場合に、サーバが返すべきレスポンスは以下のようになります。

{
  data: {
    users: [
      {
        id: 1,
        name: "joe-re",
        favoriteArticles: null
      },
    ]
  },
  "errors": [
    {
      "message": "Failed to fetch references",
      "path": ["users", 0, "favoriteArticles", 0, "references"]
    }
  ]
}

referencesでエラー -> 親(Article.references)のreference fieldはnon-null -> さらにその親(User.favoriteArticles)はnullable

ということでエラーの伝播はそこで止まり、User.favoriteArticlesがnullで設定されます。

これが親のタイプにエラーが伝播するという仕様です。postedArticlesはnon-nullで設定しているので、同じようにreferencesでエラーが発生した場合にはrootオブジェクトまでエラーが伝播することになります。この場合はdataをnullにした上で、errors entryでエラーを表現しないといけません。

GraphQLではクライアントに必要な情報を選択させるべき(Demand-Driven)という思想に基づいているので、返せるべきデータは部分的にでも返すべきという考えです。

non-nullを使うとこの部分で不都合があります。

もう一点のnon-nullからnullableへの変更がSchemaの破壊的変更を引き起こす点についてです。

これは単純な話で、nullable前提でハンドリングしているクライアントコードは後からnon-nullableに変更されても動作しますが、non-nullableからnullableへの変更はエラーハンドリングを追加しないといけないことになるので動作しなくなります。

この2点を考慮して、nullableなフィールドの定義は慎重になるべきという議論になります。

これらの問題を解決するために、True Nullability Schemaという議論があります。

https://github.com/graphql/graphql-wg/discussions/1394

本来エラーになるべきでないものはsemantics的にnon-nullと表現し、エラーが発生してしまったケースのみnullを返すことを許容し、その場合はerrors entryを使って理由を返すという仕様です。

この議論は @semanticsNonNull を新しい構文とすることで議論が進んでいます。

https://graphql.org/conf/2024/schedule/8daaf10ac70360a7fade149a54538bf9/

実装もRelayとApollo Client(kotlin)で実績があります。

これを踏まえて筆者の考え方

Semantics non-nullを使わない場合に、システムが正常な場合にはnon-nullを表現したい場合、private APIで用途が限定されていればnon-nullにしてしまって良いのではないかというのが筆者の考えです。

private APIであれば用途は限定されていて、かつ予期せぬエラーであれば拾いたいことがほとんどなので、partialにデータを返せる仕様を優先する必要は薄いと考えています。

とは言え、外部APIに依存するフィールドだったり、privateでも自分たちとは別のチームからも呼ばれる汎用的なAPIを提供している場合には考える必要があります。

Public APIであれば多くのフィールドをnullableにします。

用途が狭ければ狭いほどnon-nullフィールドを許容し、広ければ広いほど許容しないというのが筆者の基本的な考えです。

Cursor-Based Paginationが何かという説明は以前に書いた記事あるのでそちらを参照してください。

https://qiita.com/joe-re/items/e3610d1ee8130da05288

Cursorはpaginationにおける要件の変化に柔軟に対応できるようにするため、あえて不透明にして、必要な情報を入れてbase64エンコードした文字列とすることが公式で推奨されています。

https://graphql.org/learn/pagination/#pagination-and-edges

これを踏まえて筆者の考え方

ここではCursorはどういう形式で生成するべきかという点について自分の考えを述べます。

必要な情報というのが何かを具体的に考えると、ほとんどの場合でこれはsort条件による値とIDを条件を含めることになります。

例えば名前、IDの順にソートしているとすると、以下のような値をエンコードします。

このデータからは、以下の条件をSQLで表現することができます。

WHERE (name > 'joe-re') OR (name = 'joe-re' AND id > 1)

afterでcursorを指定した時、取得したいのは次の位置のレコードからになります。
複合キー(name, id)での「次の位置」を表現するには、「nameが大きい」または「nameが同じでidが大きい」という条件になります。

これに限らず、paginationで必要な情報があれば自由な値を入れられるためにbase64にしてあえて不透明さを持たせているのがcursorの文字列になります。

五月雨でしたが、自分がよく聞かれた記憶があり、かつ初見では分かりづらそうなスキーマ設計のポイントを解説しました。

まだ他にもあると思いますので、コメントなどいただければ自分の分かるものに関しては追記しようと思います。

どなたかの参考になれば幸いです。



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -