Feedforce Developer Blog

フィードフォース開発者ブログ

GraphQLのmutationをユースケース単位で定義したらいろいろスッキリした件

この記事で伝えたいこと

  • GraphQLのmutation、特にモデルの一部を更新するものは、ユースケース単位にするといい
  • ユースケース単位のmutationがビジネスロジックに基づくエラーを返すなら、専用のエラー型があるとなおいい
  • mutation の名前付けにおいては、「何のためにそのリソースを操作するのか」を表現するといい

対処前の状況

この記事は、EC BoosterというSaaSを開発する中で実施した、 GraphQLのインターフェースの設計改善についてのお話です。

まず、EC Boosterがどんなサービスなのかというと、ECサイトを運営している方に向けて、Google検索広告を簡単に出稿・運用する手段を提供するサービスです。

lp.ecbooster.jp

技術スタックにおいては、サーバサイドに Ruby On Rails 、クライアントにReactを採用していて、クライアントとサーバサイドの間の通信はGraphQLで行っています。

次に、登場するデータモデルのご紹介です。 今回は、 GoogleAdSetting というモデルを中心とした話です。GoogleAdSetting はその名が示す通り、Google広告に関する設定値を保持するためのオブジェクトです。契約主体であるECサイトを運営している個々のショップさんごとに、 GoogleAdSetting を用意して、主に広告のON/OFF(ONにしたら広告が表示される)、一日あたりの広告予算(日予算)を管理しています。

ShopとGoogleAdSettingに関するクラス図

EC Boosterのお客さまは、好きなタイミングで予算額や広告のON/OFFを変更できます。つまり、 GoogleAdSetting の変更リクエストは、クライアントから GraphQLを通じてリクエストされます。

EC Boosterのサービス開始からしばらく、日予算と広告ON/OFFの変更は、一つのmutationで実現していました。このmutationは、新しい日予算・出稿状態を引数に取り、 ActiveRecordの GoogleAdSetting モデルを更新できる形になっていました。 こんな感じの定義です。

type Mutation {
    googleAdSetting(
      input: GoogleAdSettingInput!
    ): GoogleAdSettingPayload!
}

input GoogleAdSettingInput {
  daily_budget: Int!
  status: AdPublishmentStatus!
}

enum AdPublishmentStatus {
  active
  prepare
  stop
}

type Mutation の箇所で内部に定義されている googleAdSetting が、 "GoogleAdSetting を更新する mutation" です。この mutation が返すものは"GoogleAdSettingPayload"と定義されていますが、これは GoogleAdSetting とバリデーションエラーを示す型 ValidationErrors によるunionで、返り値としてどちらの型もありうることを示すものです。

バリデーションのエラーメッセージは、ActiveRecordの errors メソッドで得られるオブジェクトの構造を引き継いでいて、エラーが発生した属性の名前をキー、エラーの内容を示す文字列を値とするオブジェクトでした。1

というわけで、GoogleAdSettingを更新するmutationの戻り値の型、 GoogleAdSettingPayload の定義は、このようになっていました。

type GoogleAdSettingPayload {
  google_ad_setting: ResponseGoogleAdSetting!
}

union ResponseGoogleAdSetting = GoogleAdSetting | ValidationErrors

type GoogleAdSetting {
  id: String!
  shopId: String!
  dailyBudget: Int!
  status: AdPublishmentStatus!
}

type ValidationErrors {
  validationError: [ValidationError!]!
}

type ValidationError {
  attribute: String!
  messages: [String!]!
}

困った

このような定義にしたところ、ちょっとした困りごとが発生しました。 ValidationError のフィールドに注目してもらいたいのですが、 attribute はバリデーションエラーが発生した属性の名前(カラム名)で、 messages はエラーメッセージです。そして ValidationError はエラーが発生したときにのみ返されます。

ここまで書くとフロントエンドや何らかのAPIを使ったアプリケーションの実装に慣れた方にはおおかた想像がつくかと思いますが、このような型定義だと、エラーを表すオブジェクトがどんな情報を持っているか、エラーが発生するまでわからないのです。クライアントでは「広告予算を変更したい」というユースケースに基づいて、一つのカラムを変更したいだけなのに、"GoogleAdSetting を更新する mutation" は他のカラムも更新できる能力があるので、汎用的なエラー型を返さざるを得なくなってしまっています。

もう一段上の構造を見てみると、レスポンスのデータ構造の上では、バリデーションエラーは ValidationError の配列として返されています。したがって、「広告予算を変更したい」ときにエラーが返されたら、 ValidationError 配列の要素を全部調べて、日予算に関係する要素だけ抜き出してユーザーに表示するメッセージを組み立てる、ということが行われていました。

少し前のJavaScriptなら、こんな時にreduce()してmap()してみたいなことは当たり前でしたが、世の中の流れにならってEC BoosterでもTypeScriptを採用しています。reduce()やmap()で得られるオブジェクトの形が不定形だと、その後ずっと Any 型を引きずることになってしまいます。

もしできるならここでも型の表現力を借りて、読みやすくて安全なコードを書きたいものです。

GraphQLサーバを書いて感じた、GraphQLインターフェースの表現力の豊かさ

話は変わりますが、GraphQLとRESTful APIとを比べてみると、インターフェースの柔軟性とでも言うべきものがGraphQLの特徴として際立っていると感じています。

RESTfulなAPIを実装するときは、あるデータモデルをRESTインターフェース上のリソースとし、POST, PUT, DELETEなどのHTTPメソッドに対応する処理を書くことで、サーバ上のデータに対する操作を表現していました。その中では、たとえばデータモデルの一部分に対する更新とかをRESTインターフェースで提供することは、なんとなく忌避されていたような気がします。言い換えると、データモデルとして存在しないものに対してURLのパスを定義することが、嫌われていたように思います。2

そうした意味では、GraphQLのインターフェースはRESTに比べるとかなり柔軟に定義できます。「リソース名 + HTTPメソッド」という決まりがないからこそ、データモデルの実体から離れたインターフェースを作ることに抵抗感が薄いのです。

更新系のインターフェースを設計するときには、この特性をより強く感じられます。GraphQLでは、「データ書き込みが発生するリクエストはmutationで書くべし」とされていて、RESTfulなインターフェースでいうPOST, PUT, DELETEもすべて"mutation"でひとまとめにされています。「更新系はすべてmutation」ということになると、RESTful APIとの比較で言えば、HTTPメソッドに相当するものをmutationの名前に含めてようやく同等の表現力を得られるということになるでしょう。さらに言うと、mutationの名前付けにおいて、語彙が限られるHTTPメソッドを借用する意味はあまりありません。mutationである以上、mutate = 変更するのは明らかなのですから、「何の目的で変更するか」を表した名前を与えることで、どのような副作用があるか伝わりやすいインターフェースを作ることができます。

mutationの名前に強い規約がないのは不安になる向きもあろうかと思いますが、名前を自分でつけなければいけなくなったことで、「何のためにそのリソースを操作するのか」を表現できるということが重要なように思います。

先に挙げたような、リソースの一部分だけを更新するようなケースを考えてみます。EC Boosterの例で言えば、GoogleAdSetting という一つのデータモデルが日予算と広告ON/OFFという属性を持っていて、どちらか一つを更新する場合です。RESTfulな発想で素朴にmutationを考えると「GoogleAdSettingを更新する」という風になります(実際そう考えて実装したのでしょう)。しかし、GraphQLでは「日予算を変更する」「広告をONにする」「広告をOFFにする」といったように、一つのデータモデルに対してユースケースごとにmutationを分ける、といったことが違和感なくできます。

ユースケースごとにmutationを定義することで、インターフェースとしての表現力が向上するほかに、ソースコードで表現される部分にもメリットがありました。Rails アプリケーションで GraphQLを喋るために利用しているgem "graphql-ruby"では、 mutationごとににそれぞれリゾルバとなるメソッドを実装していく形になっています。このリゾルバの実装およびテストコードにおいても、モデル全体を更新するより、ユースケース単位に分かれていた方が見通しがよくなった、というオマケもついてきました。

ここで例に挙げた GoogleAdSetting に限らず、他のモデルでもこういったケースがあり、EC Boosterではいつからか「ユースケース単位でmutationを定義する」という取り組みが定着するようになりました。

ユースケース単位に書き直した

話が長くなりましたが、そういうわけで GoogleAdSettingを丸ごと更新するmutationから、日予算の更新・広告ON/OFFというユースケースに対応する、それぞれ独立したmutationに変更しました。以下に日予算を変更するmutationの例を示します。

type Mutation {
  """
  Google ショッピング広告 の日予算を変更する
  """
  googleShoppingAdsChangeBudget(
    """
    Parameters for GoogleShoppingAdsChangeBudget
    """
    input: GoogleShoppingAdsChangeBudgetInput!
  ): GoogleShoppingAdsChangeBudgetPayload!
}

input GoogleShoppingAdsChangeBudgetInput {
  """
  Google ショッピング広告の日予算
  """
  dailyBudget: Int!
}

"""
日予算を更新するMutationの戻り値の型定義。
実態は GoogleShoppingAdsChangeBudgetResponseUnion に
"""
type GoogleShoppingAdsChangeBudgetPayload {
  result: GoogleShoppingAdsChangeBudgetResponseUnion!
}

union GoogleShoppingAdsChangeBudgetResponseUnion =
    GoogleAdSetting
  | ValidationErrors

type GoogleAdSetting {
  id: String!
  shopId: String!
  dailyBudget: Int!
  status: AdPublishmentStatus!
}

type ValidationError {
  attribute: String!
  messages: [String!]!
}

この変更の結果、バリデーションエラーの扱いに困っていた問題も解消しました。どういうことかというと、今回定義したユースケースでは、更新したい属性はそれぞれ一つだったので、バリデーションエラーが発生する可能性のある属性も、それぞれのmutationで一つに絞られることになったのです。したがって、クライアントではエラーハンドリングのためにエラーメッセージの配列を全部調べる必要がなくなり、属性の名前を決めうちで対応できるようになりました。

以下にmutationの実行とエラーハンドリングを行う箇所のコードを示します。関数 changeBudget() はmutationを実行するPromiseを返す関数で、チェーンに続く関数で、レスポンスを引数に取ってエラーハンドリングを行っています。

// GoogleAdSettingを丸ごと更新する場合
changeBudget({
  input: {
    google_ad_setting: {
      daily_budget: value,
      published_within_ninety_days: publishedWithinNinetyDays,
    },
  },
}).then(({ data }) => {
  const googleAdSetting = data?.googleAdSetting.google_ad_setting || {};
  if (googleAdSetting.__typename === "ValidationErrors") {
    // ValidationError の配列を、属性別に並べ替えている
    const errors = googleAdSetting.validationError.reduce(
      (acc, { attribute, messages }) => ({
        ...acc,
        [attribute]: messages,
      }),
      {},
    );
    return bags.setErrors({
      // 日予算に関するエラーのみ画面表示に用いる
      budget: errors.daily_budget.join("\n"),
    });
  }
  onBudgetChange(t("money_amount", { money: value }));
  return undefined;
});
// GoogleAdSettingの日予算だけを更新する場合
changeBudget({
  input: {
    dailyBudget: value,
  },
})
  .then(({ data }) => {
    const result = data?.googleAdDsaChangeBudget.result;
    if (result.__typename === "ValidationErrors") {
      // 日予算に関するエラーメッセージしか発生しえないので、単純なmapでよくなった
      const message = result.validationError
        .map(e => e.messages)
        .join("\n");
      return bags.setErrors({
        budget: message,
      });
    }
    onBudgetChange(t("money_amount", { money: value }));
    return undefined;
  })

「GoogleAdSetting を丸ごと更新する場合」では、日予算以外の属性についてもバリデーションエラーが発生する可能性があったので、バリデーションエラーを属性ごとにまとめ直してから、日予算に関係するものだけ取り出していました。それが「GoogleAdSettingの日予算だけを更新する場合」では、バリデーションエラーは日予算についてしか発生しえなくなったので、エラーメッセージを単純に連結するだけでよくなりました。

ただ、この状態では、エラーが起きうる属性の名前(ここでは日予算 )についてはコード外のお約束に過ぎません。できればGraphQLのスキーマ、そこからApolloが生成するTypeScriptの型で、エラーオブジェクトの形についても伝えたいところです。

これを解決するには、mutationをユースケースごとに分けたのだから、エラーもユースケース固有のエラー型を定義するとよいのではないかと考えています。そうすると、このユースケースに必要な入力と戻り値、エラーの型定義が一通り揃って、TypeScriptによるクライアント開発がますます捗ることでしょう。

おわりに

以上、GraphQLのmutationをユースケース単位で定義したらいろいろ見通しが良くなったという話でした。

この記事を書くにあたって、GraphQLのスキーマ設計を論じたテキストがないか軽く調べたところ、"Principled GraphQL"や、"Production Ready GraphQL"の存在を知りました。後者は特にGraphQLのスキーマ設計とサーバの実装・運用に紙幅が割かれていて、本記事と似た内容も収録されているようです。本来はそれを読んで記事を見直せればよかったのですが、生憎時間が取れず、その内容をお届けするのは次の機会にできればと思います。

この記事がみなさまのGraphQLサーバ実装のお役に立つことがあれば幸いです。


  1. 参考: Active Record バリデーション#1.5 errors[] - Railsガイド
  2. 無論、REST APIはインターフェースであるので、REST APIで表現するリソースとデータベース上の構造とを、必ず一対一で対応させなくても構いません。データモデルには存在しない概念を、RESTインターフェースで提供することだって可能なはずです。