🚀

【GraphQL Ruby】型定義から値解決するまでの道のり

に公開

こちらの記事は「MEDLEY Summer Tech Blog Relay」の17日目の記事です🎉

今週はFY25新卒エンジニアの4人が連続して記事をお届けします!


はじめに

株式会社メドレーに2025年4月に新卒入社した、おぎのしきぶと申します。
人材プラットフォームの新規事業開発チームでソフトウェアエンジニアをしています。

担当プロダクトでは、バックエンドに Ruby 、フロントエンドとの通信には GraphQL を用いており、その開発には、GraphQL Ruby という gem を採用しています。

GraphQL Ruby は、Ruby で GraphQL API を構築するための gem であり、サーバーサイドの実装を容易に開発できます。
具体的には、Ruby でスキーマや型(Type)、フィールド(Field)といった GraphQL の構成要素を、直感的なクラスやメソッドとして定義することができます。

例えば、User というモデルに対応する GraphQL の型を定義する場合、以下のように UserType というクラスを作成します。

app/graphql/types/user_type.rb
module Types
  class UserType < Types::BaseObject
    description "A user"
    field :id, ID, null: false
    field :name, String, null: false
    field :email, String, null: true
  end
end

Ruby の世界から出ることなく、ライブラリのクラスを継承し、 field メソッドを使って、クライアントに公開するデータとその型(ID や String など)を宣言的に記述するだけで、APIの仕様を実装できます。
また、単に Ruby で書けるというだけでなく、API のドキュメントと実装の乖離が起こりにくくなるというメリットもあります。

このように便利な GraphQL Ruby ですが、ぱっと見ではライブラリがどのような処理をしているのかわかりません。
本記事では、GraphQL Ruby の field DSL が、どのようにして値解決へ到達するのかについて、ライブラリ実装の対応箇所とともに整理します。

本稿のゴール

  • GraphQL の基本的な構成要素である Type、Query、Mutation が、GraphQL Ruby でどのような実装になっているかを理解する
  • field DSL がどのクラスに、どのような情報として格納されるかを理解する
  • 実行時に どのメソッドが呼ばれるかの決定ロジックを把握する

環境

  • GraphQL Ruby: 2.5.11

Type

一般的なTypeの定義方法

GraphQL スキーマの型を作成するには、Types::BaseObject クラスを継承したクラス内で、field メソッドを用いて定義できます。

app/graphql/types/user_type.rb
# 再掲
module Types
  class UserType < Types::BaseObject
    description "A user"
    field :id, ID, null: false
    field :name, String, null: false
    field :email, String, null: true
  end
end

上記コードによって、このようなスキーマが生成されます。

type User {
  id: ID!
  name: String!
  email: String
}

この Types::BaseObject 自体は、install 時に生成されるファイルapp/graphql/types/base_object.rbのクラスを指しており、このような実装になっています。

app/graphql/types/base_object.rb
module Types
  class BaseObject < GraphQL::Schema::Object
    edge_type_class(Types::BaseEdge)
    connection_type_class(Types::BaseConnection)
    field_class Types::BaseField
  end
end

ここでは「GraphQL Ruby が提供している GraphQL::Schema::Object を、カスタマイズしたいときに変更する場所」程度の理解で収め、ライブラリの内容に入っていきましょう。

GraphQL::Schema::Objectとfieldメソッドの実態

この BaseObject の継承元である GraphQL::Schema::Object は、lib/graphql/schema/object.rbにあります。

https://github.com/rmosolgo/graphql-ruby/blob/master/lib/graphql/schema/object.rb

今回は field メソッドを中心に探っていきましょう。
実装上部を見ると、

lib/graphql/schema/object.rb
module GraphQL
  class Schema
    class Object < GraphQL::Schema::Member
      extend GraphQL::Schema::Member::HasFields

とあり、HasFields が引っ張ってきているようです。

https://github.com/rmosolgo/graphql-ruby/blob/master/lib/graphql/schema/member/has_fields.rb

lib/graphql/schema/member/has_fields.rb
        # Add a field to this object or interface with the given definition
        # @see {GraphQL::Schema::Field#initialize} for method signature
        # @return [GraphQL::Schema::Field]
        def field(*args, **kwargs, &block)
          field_defn = field_class.from_options(*args, owner: self, **kwargs, &block)
          add_field(field_defn)
          field_defn
        end

ありました。
field_class.from_options(*args, owner: self, **kwargs, &block)で field 定義を準備し、add_fieldでクラスに追加しているようです。

add_field メソッド自体は単純です。

  1. 定義したフィールド名が、予約語と重複していないかチェックする
  2. ハッシュown_fieldsへ適切にセットする

といった処理が、lib/graphql/schema/member/has_fields.rb内に実装されています。

lib/graphql/schema/member/has_fields.rb
        def add_field(field_defn, method_conflict_warning: field_defn.method_conflict_warning?)
          # Check that `field_defn.original_name` equals `resolver_method` and `method_sym` --
          # that shows that no override value was given manually.

          # 1. 定義したフィールド名が、予約語と重複していないかチェックする
          if method_conflict_warning &&
              CONFLICT_FIELD_NAMES.include?(field_defn.resolver_method) &&
              field_defn.original_name == field_defn.resolver_method &&
              field_defn.original_name == field_defn.method_sym &&
              field_defn.hash_key == NOT_CONFIGURED &&
              field_defn.dig_keys.nil?
            warn(conflict_field_name_warning(field_defn))
          end
          # 2. ハッシュ`own_fields`へ適切にセットする
          prev_defn = own_fields[field_defn.name]

          case prev_defn
          when nil  # 新規のフィールド名であればそのまま追
            own_fields[field_defn.name] = field_defn
          when Array  # 既にフィールドあり、それが配列ならば配列内に追加
            prev_defn << field_defn
          when GraphQL::Schema::Field  # 既存にフィードあれば、配列化して両方保持
            own_fields[field_defn.name] = [prev_defn, field_defn]
          else
            raise "Invariant: unexpected previous field definition for #{field_defn.name.inspect}: #{prev_defn.inspect}"
          end

          nil
        end

field_class.from_options(*args, owner: self, **kwargs, &block)についてはどうでしょうか?

この field_class は、lib/graphql/schema/member/has_fields.rb内のメソッドで、

lib/graphql/schema/member/has_fields.rb
        # @return [Class] The class to initialize when adding fields to this kind of schema member
        def field_class(new_field_class = nil)
          if new_field_class
            @field_class = new_field_class
          elsif defined?(@field_class) && @field_class
            @field_class
          else
            find_inherited_value(:field_class, GraphQL::Schema::Field)
          end
        end

となっています。

一見 nil 引数が渡されて最後のfind_inherited_value(:field_class, GraphQL::Schema::Field)によって探されていそうですが、実は先ほどスルーした BaseObject 内で呼び出されています。

app/graphql/types/base_object.rb
# 再掲
module Types
  class BaseObject < GraphQL::Schema::Object
    edge_type_class(Types::BaseEdge)
    connection_type_class(Types::BaseConnection)
    field_class Types::BaseField  # 👈ココ
  end
end

また、この Types::BaseField についても BaseObject 同様、gem の install 時に生成されたファイルapp/graphql/types/base_field.rbが参照元です。

app/graphql/types/base_field.rb
module Types
  class BaseField < GraphQL::Schema::Field
    argument_class Types::BaseArgument
  end
end

つまり、継承元である GraphQL::Schema::Field がうまくやってくれていそうです。

https://github.com/rmosolgo/graphql-ruby/blob/master/lib/graphql/schema/field.rb

呼び出している from_options メソッドは以下です。

lib/graphql/schema/field.rb
      # Create a field instance from a list of arguments, keyword arguments, and a block.
      #
      # This method implements prioritization between the `resolver` or `mutation` defaults
      # and the local overrides via other keywords.
      #
      # It also normalizes positional arguments into keywords for {Schema::Field#initialize}.
      # @param resolver [Class] A {GraphQL::Schema::Resolver} class to use for field configuration
      # @param mutation [Class] A {GraphQL::Schema::Mutation} class to use for field configuration
      # @param subscription [Class] A {GraphQL::Schema::Subscription} class to use for field configuration
      # @return [GraphQL::Schema::Field] an instance of `self`
      # @see {.initialize} for other options
      def self.from_options(name = nil, type = nil, desc = nil, comment: nil, resolver: nil, mutation: nil, subscription: nil,**kwargs, &block)
        if (resolver_class = resolver || mutation || subscription)
          # Add a reference to that parent class
          kwargs[:resolver_class] = resolver_class
        end

        if name
          kwargs[:name] = name
        end

        if comment
          kwargs[:comment] = comment
        end

        if !type.nil?
          if desc
            if kwargs[:description]
              raise ArgumentError, "Provide description as a positional argument or `description:` keyword, but not both (#{desc.inspect}, #{kwargs[:description].inspect})"
            end

            kwargs[:description] = desc
            kwargs[:type] = type
          elsif (resolver || mutation) && type.is_a?(String)
            # The return type should be copied from the resolver, and the second positional argument is the description
            kwargs[:description] = type
          else
            kwargs[:type] = type
          end
          if type.is_a?(Class) && type < GraphQL::Schema::Mutation
            raise ArgumentError, "Use `field #{name.inspect}, mutation: Mutation, ...` to provide a mutation to this field instead"
          end
        end
        new(**kwargs, &block) # 👈
      end

どうやら単なる引数を処理するインターフェースのようなメソッドで、単に new していると思って良さそうです。
ということで、ようやく GraphQL::Schema::Field#initialize が本丸ということがわかりました。

ただこの initialize メソッドは100行超にわたる長い処理であり、さらに各所で多くのメソッドを呼び出しているため、全てを細かく追うのは現実的ではありません。
ひとまず今回は、field :id, ID, null: falseと呼び出したとき、どのような処理になるかを見てみましょう。

Fieldクラスはどのように初期化されるか

field :id, ID, null: falseと3つの引数を渡したとき、それぞれの引数は以下のようにインスタンス変数へ格納されます。

lib/graphql/schema/field.rb
      def initialize(...)
      # ...
        @original_name = name
        @return_type_expr = type
        @return_type_null = if !null.nil?
          null
        elsif resolver_class
          nil
        else
          true
        end
      # ...
      end

これらは実行時に参照する変数としてみて良さそうです。
例えば、@return_type_nullは、実際に値がセットされたときに使用されます。

lib/graphql/schema/field.rb
def type(new_type = NOT_CONFIGURED)
  # ...
  elsif !@return_type_expr.nil?
    @type ||= Member::BuildType.parse_type(@return_type_expr, null: @return_type_null)
  end
end

単に直接引数を参照している箇所はこの程度ですが、もう一箇所重要な実装があります。
それは、@resolver_methodの設定です。

lib/graphql/schema/field.rb
method_name = method || hash_key || name_s
@method_str = -method_name.to_s
@method_sym = method_name.to_sym
@resolver_method = (resolver_method || name_s).to_sym

ここでは、「このオブジェクトの値解決時、どのメソッド名を参照すべきか」を設定しています。
今回のケースでは、resolver_method を引数に渡していないので、@resolver_method = name_s.to_symとなります。
これによって、値解決時に「obj.id で取れるんだ!」と認識することができます。
また、これによって我々は、フィールド名と同じメソッド名、「obj.id が値を返す」ようにすれば、あとはGraphQL Rubyが勝手に処理してくれるようになっています。

具体的な値解決処理は、GraphQL::Schema::Field#resolve に記載されており、該当箇所を見てみましょう。

lib/graphql/schema/field.rb
def resolve(object, args, query_ctx)
  # ...
  elsif inner_object.respond_to?(@method_sym)
    method_to_call = @method_sym
    method_receiver = obj.object
    if !ruby_kwargs.empty?
      inner_object.public_send(@method_sym, **ruby_kwargs)
    else
      inner_object.public_send(@method_sym)
    end
  # ...

ここでは、inner_object.public_send(@method_sym)が呼び出されています。
このpublic_sendは、obj.public_send(method_name)と同じで、obj の method_name メソッドを呼び出します。

今回のケースでは、obj はUserオブジェクト、method_name は id なので、obj.idが呼び出されます。
これによって、Userオブジェクトの id メソッドが呼び出され、その結果が返されます。

Typeのまとめ

  • Types::BaseObject を継承し、field DSL で公開フィールドを宣言する。
  • field メソッドは GraphQL::Schema::Field に変換・登録される。
  • 変換時に、フィールド名と同じメソッド名が resolver_method に設定されるため、値解決時にそのメソッドを呼び出すようになる。

Query

一般的なQueryの定義方法

GraphQL Ruby では、Query は以下のように定義します。

module Types
  class QueryType < Types::BaseObject
    field :users, [UserType], null: false

    def users
      User.all
    end
  end
end

これによって、以下のようなスキーマが生成されます。

type Query {
  users: [User!]!
}

ここで QueryType についてよく見てみましょう。
継承元を見てみると、Type でも継承していた Types::BaseObject であることがわかります。

つまり、GraphQL Ruby では、Query も Type も全く同じオブジェクトを継承しています。
そのため、Query に対して field メソッドを呼び出すことは、Type に対して field メソッドを呼び出すことと全く同じです。

今回提示した users では、field メソッドの引数自体も同じなため、Type と全く同じように処理されて行きます。

ちょっと意外に感じますが、言われてみれば確かにそうです。
GraphQL において Query は、Graph 上の取得可能な部分グラフを表すものです。
Query のルート型から取得可能なフィールドに辺が伸び、その先にもさらにフィールドがある、というイメージです。

値解決の観点で言えば、Query だけに特別な仕組みは必要ありません。
各フィールドが、自分の子フィールドをどう解決するかを知っていれば、十分に実現できます。
後はリクエストを捌く実行エンジンに Query のルート型を渡しておくだけで、再帰的に必要なフィールドを全て解決することができます。

Queryのまとめ

  • GraphQLRuby において、Query と Type は(ほぼ)同じもの

Mutation

一般的なMutationの定義方法

Mutation は、GraphQL Ruby では GraphQL::Schema::Mutation を継承したクラスを定義することで実現できます。

app/graphql/mutations/create_user.rb
module Mutations
  class CreateUser < GraphQL::Schema::Mutation
    # 引数(入力)
    argument :name,  String, required: true
    argument :email, String, required: true

    # 返却ペイロードのフィールド
    field :user,   Types::UserType, null: true
    field :errors, [String],        null: false

    def resolve(name:, email:)
      user = User.new(name: name, email: email)
      if user.save
        { user: user, errors: [] }
      else
        { user: nil,  errors: user.errors.full_messages }
      end
    end
  end
end
app/graphql/types/mutation_type.rb
module Types
  class MutationType < Types::BaseObject
    field :create_user, mutation: Mutations::CreateUser
  end
end

これによって、以下のようなスキーマが生成されます。

type Mutation {
  create_user(name: String!, email: String!): User
}

Mutation のルートノードである MutationTypeは、Type、Query と同じくGraphQL::Schema::Object を継承していますね。
ベースの仕組みは同じものだと思って良さそうです。

一方、field メソッドに渡す引数が異なっており、mutation 引数を指定していることがわかります。
この差分について詳しく見てみましょう。

fieldメソッドにmutationを渡したときの仕組み

field :create_user, mutation: Mutations::CreateUser のように mutation 引数を渡すと、GraphQL::Schema::Field.from_options により、そのクラスが resolver_class として Field インスタンスに紐づきます。

lib/graphql/schema/field.rb
      if (resolver_class = resolver || mutation || subscription)
        kwargs[:resolver_class] = resolver_class
      end

これにより、このフィールドは型側のインスタンスメソッドではなく、resolver_class(= Mutation クラス)の resolver_method(通常は resolve)で解決されます。(この解決順序については後述します)
GraphQL の引数は Ruby のキーワード引数として resolve(**kwargs) に渡ります。
resolver_class を渡すことで、型オブジェクトで値解決していたところを、そのクラスに全て移譲しているわけです。

GraphQL::Schema::Mutationの仕組み

GraphQL::Schema::Mutation は GraphQL::Schema::Resolver を継承しています。

https://github.com/rmosolgo/graphql-ruby/blob/master/lib/graphql/schema/resolver.rb

GraphQL::Schema::Resolver は、GraphQL RubyのResolverの基底クラスであり、大まかな仕組みは今まで見てきた Type や Query と同じです。
つまり、継承元との差分:GraphQL::Schema::Mutation へ追加された実装が重要そうです。

差分1. call_resolve

Resolver 実行時に呼ばれるメソッドcall_resolve に、dataloader.clear_cache が追加されており、Resolver 実行前にキャッシュをクリアするようにしています。

lib/graphql/schema/mutation.rb
class Mutation < GraphQL::Schema::Resolver
  # ...
  # @api private
      def call_resolve(_args_hash)
        # Clear any cached values from `loads` or authorization:
        dataloader.clear_cache
        super
      end
  # ...
end

これは、Mutation が副作用を伴うことが原因です。
副作用を伴う場合、同じフィールドを複数回実行したときに、前回の実行結果が残ってしまいます。
そのため、Resolver 実行前にキャッシュをクリアするようにしています。

差分2. HasPayloadType

GraphQL::Schema::Mutation は、HasPayloadType と HasFields を extend しています。
これにより、クラス内で宣言した CreateUser 内に記載したフィールドは「返却ペイロード型」のフィールドとして再利用され、CreateUser → CreateUserPayload のようなオブジェクト型が自動生成されます。

lib/graphql/schema/resolver/has_payload_type.rb
class Mutation < GraphQL::Schema::Resolver
  extend GraphQL::Schema::Member::HasFields
  extend GraphQL::Schema::Resolver::HasPayloadType
  # ...
end

これは、clientMutationId や errors などのメタ情報とドメインデータをまとめた「ペイロード型」を返す運用が主流であり、その慣習に合わせるためだと思われます。
(PRやドキュメントを軽く探してみたのですが、明確な背景はわからず。。。)

Mutationのまとめ

  • Mutation のルート型は、Type、Queryと同じく GraphQL::Schema::Object を継承している
  • 副作用を持つ Mutation を実現するため、GraphQL::Schema::Mutation を継承したクラスを定義する
  • 返却型は HasPayloadType により自動生成され、CreateUser 内で宣言したfield :user, ...field :errors, ...がその型のフィールドになる

フィールド解決の優先順位

最後に、フィールド解決について少しだけ詳しく見てみましょう。

今まで言及していませんでしたが、GraphQL::Schema::Field#resolve 内には、値解決時に呼び出すメソッドの優先順位が設定されています。

フィールド値は次の順に解決されます。

  1. hash_key / dig が指定されている場合: inner_object(アプリ側の実オブジェクト)に対してハッシュ参照(dig 含む)で値取得。見つからなければ fallback_value があれば返す
  2. 型インスタンスのメソッド(resolver_method): QueryType や各 Object 内のインスタンスメソッド(デフォルトはフィールド名)を obj.public_send で呼ぶ
  3. inner_object が Hash の場合のキー参照: dig_keys やシンボル/文字列キーで参照
  4. アプリオブジェクトのメソッド(method_sym): inner_object.public_send(@method_sym) を呼ぶ
  5. フォールバック/エラー: fallback_value があれば返す。なければ実装不足エラーを投げる

1. hash_key/digを使う場合

条件: フィールド定義で hash_key: または dig:` を指定したとき

lib/graphql/schema/field.rb
field :title, String, null: false, hash_key: :title
field :city,  String, null: true,  dig: [:address, :city]

上は inner_object[:title]、下は inner_object.dig(:address, :city) を参照します。
外部APIレスポンスやJSONカラムなど、そのままHashとして扱うデータを素直にマッピングしたい場合に有効です。

2. 型インスタンスのメソッド(resolver_method)を呼ぶ場合

基本的にこのパターンを使うのが最も一般的です。

条件A: 型クラス(例: QueryType / UserType)に、フィールド名と同名(または resolver_method: で指定した)インスタンスメソッドを定義

app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :users, [UserType], null: false

    def users
      User.all
    end
  end
end

条件B: フィールドで resolver:/mutation:/subscription: を指定

app/graphql/types/query_type.rb
class QueryType < Types::BaseObject
  field :users, resolver: Resolvers::UsersResolver
end

class Resolvers::UsersResolver < GraphQL::Schema::Resolver
  type [Types::UserType], null: false
  def resolve(**args)
    User.all
  end
end

3. inner_objectがHashのときのキー参照

条件: inner_object 自体が Hash(親の解決が Hash を返した等)で、1や2に該当しないとき。

app/graphql/types/query_type.rb
class QueryType < Types::BaseObject
  field :user_info, UserType, null: false, hash_key: :user_info
  # 親で Hash を返す
  def user_info
    { id: 1, 'name' => 'Alice' }
  end
end
app/graphql/types/user_type.rb
# 子フィールド(id/name)は Hash キーで解決される
class UserType < Types::BaseObject
  field :id,   ID,     null: false
  field :name, String, null: false
end

4. アプリケーションオブジェクトのメソッド(method_sym)を呼ぶ場合

条件: 1, 2 に該当せず、inner_object がそのメソッドに respond_to? するとき

app/graphql/types/user_type.rb
# User#name を呼ぶ(フィールド名 = メソッド名)
field :name, String, null: false
# 別名フィールドで User#name を呼ぶ
field :full_name, String, null: false, method: :name    # この場合、`User#name` が呼ばれるため、`name`と`full_name`は等価

UserType などのオブジェクト型で型側にメソッドを定義しない場合、モデル(inner_object)の同名メソッド/属性に自然にフォールバックするため、最小の実装で動かしやすいです。

まとめ

  • Type、Query、Mutation どれも、GraphQL::Schema::Object を継承している
  • field DSL で宣言したフィールドは、GraphQL::Schema::Field に変換され、GraphQL::Schema::Object に追加される
    • 実行時に呼び出されるインスタンス変数として定義される
    • 値解決時に呼び出すメソッドもこのインスタンス作成時に定義される
  • フィールド値は、hash_key / dig → 型インスタンスのメソッド → inner_object が Hash の場合のキー参照 → アプリオブジェクトのメソッド → フォールバック/エラーの順に解決される

感想

普段よくわからず使っているライブラリの中身を知ると、単に業務コード書いているだけでも楽しい気持ちになれますね。
実行エンジン、データローダー、スキーマ生成などなど、まだまだ調べてみたいことがたくさんあり、また記事にしたいと思います。


次は、新卒エンジニア3人目の平林さんです!お楽しみに!!

Discussion