GraphQLのスキーマ設計において初期段階から考えておくべきこと ~ Mutation編 ~
これは CureApp Advent Calendar 2023 18日目の記事です。
はじめに
これまでGraphQLのスキーマ設計してきた経験の中で、これ最初から考えておくべきだったなーみたいなことをMutationに限定してまとめていきます。
(Mutationにしたのはパッと思いついたのがMutation周りが多かったからです。そのうちQueryやSubscriptionやEntityについても書くかも)
GraphQLのスキーマ設計にも細かい部分では様々なプラクティスがあり、実際のところはそれぞれのアプリケーションのユースケースやチームの体制に合うかどうかで決めるべきです。
なのでこうするべきという話よりは、自分のケースではこうすべきだったなーという反省をつらつらと述べていきます。
ご自身のケースに当てはめて取捨選択をする上での参考になれば幸いです。
本編
エラーをクライアントに伝える手段を決めておく
GraphQLがHTTPのレスポンスにエラーを表現する方法には、いくつかのプラクティスがあります。
まず前提としてGraphQLではその仕様の中にエラーを返す方法が定義されています。
しかしこのこのエラーに関してはスキーマの外側になるため、型情報やそもそもどんなエラーが発生しうるのかをSchema上で表現できず、クライアント側から扱いづらくなってしまうことを避けられません。
Production Ready GraphQLの中でも述べらていますが、このエラーフィールドに関してはシステムエラー(developer/client errors)を補足するのに使い、アプリケーションの関心によるレベルのエラーはSchema上で定義しておくというのが良いプラクティスとされています。
Schemaにエラーを定義する方法として、主に2つのタイプがあります。
1つはレスポンス(Payload)の中にエラー用のフィールドを定義しておく方法です。
type InviteMemberPayload {
inviteMemberError: [InviteMemberError!]!
member: Member
}
enum InviteMemberErrorCode {
INVALID_INPUT_VALUE
NEED_TO_UPGRADE_PLAN
}
type InviteMemberError {
message: String!
code: InviteMemberErrorCode!
}
もう1つはレスポンスに使う型にエラー情報を含めてUnion typesとして表現する方法です。
type Mutation {
inviteMember(email: string!): InviteMemberPayload
}
interface Error {
message: String!
path: [String!]!
}
type InvalidInputValueError implements Error {
message: String!
path: [String!]!
fields: [Field]!
}
type NeedToUpgradePlanError implements Error {
message: String!
path: [String!]!
}
union InviteMemberPayload =
InviteMemberSuccess |
InvalidInputValueError |
NeedToUpgradePlanError
この方法はそれぞれに利点があります。
1つ目のエラー用のフィールドで返す方法では、クライアント側でinviteMemberErrorのフィールドを追加しておけばサーバ側でエラー定義が追加されたとしても同じフィールドにデータが追加されるので、汎用的な処理であればクエリを変えずにハンドリングすることができます。
2つ目のunion typesを定義する方法は、Entityとしてエラーを定義しているのでより強力なSchemaの型サポートを受けることができます。
以下で詳しく見ていきます。
2つ目のエラーをunion typesで返す方法では、Schema上で表現されているため何か新しく取得できるエラー情報に従ってハンドリング処理を追加する場合にはQueryの変更も必要になります。
例えば InvalidInputValueError
にはどのfieldでエラーだったかを持たせたい場合に以下のように定義をしたとします。
type InvalidInputValueError implements Error {
message: String!
path: [String!]!
fields: [Field!]!
}
InvalidInputValueErrorのfieldsを取得するオペレーションは以下のようになるでしょう。
mutation {
inviteMember(email: "user1@example.com") {
... on InviteMemberSuccess {
id
}
... on InvalidInputValueError {
message
fields
}
... on NeedToUpgradePlanError {
message
}
}
}
InvalidInputValueErrorの詳細を取得するためにfieldsをクエリで取得する必要性が発生しました。
とはいえ、これは詳細に返せる情報として追加のフィールドを定義したということに過ぎないので、1の例のように汎用的な処理をしたければ単にErrorの型を取得すれば同じように扱えます。(なのでこういうエラーはinterfaceで共通のフィールドを定義しておくと良いです。)
mutation {
inviteMember(email: "user1@example.com") {
... on InviteMemberSuccess {
id
}
... on Error {
message
}
... on InvalidInputValueError {
fields
}
}
}
それぞれ良し悪しはありますが、union typesで定義する方法がより強力なSchemaのサポートが得られるので個人的には好みです。
どちらを採用するにせよ、一貫性を持たせてスキーマ共通のエラーハンドリングはこれを採用する!という決定を開発チームで行い、それを守っていくことが大事です。
(筆者には途中から導入したので、開発チーム全体で共通認識を合わせる部分で非常に苦労した過去があります..)
Mutationは必ずエラーをunion typesとして返すようにする
これは上で述べたエラー設計において、union typesを選択した場合の話です。
仮に実装時に想定されるアプリケーションレベルのエラーが1つもなかったとしても、全てのMutationがunion types型を返すようにしておくと楽です。
既存のGraphQLのMutationのレスポンスをunion types型に変更するのは破壊的変更なので、クライアント側の修正が必須になってしまいます。
これを徹底しておくと要件の追加によりエラーを返さなくてはいけなくなった時にスムーズに開発可能です。
type Mutation {
updateProfile(name: string!): UpdateProfilePayload
}
union UpdateProfilePayload = UpdateProfileSuccess | Error
MutationのレスポンスではEntityのtypeを直接返さない
Mutationのレスポンスの型には、Entityのtypeをそのまま指定するのではなくラップしておくと良いです。
例えば先ほどの例なら
union InviteMemberPayload =
InviteMemberSuccess |
InvalidInputValueError |
NeedToUpgradePlanError
のように成功した場合には InviteMemberSuccess
を返しています。
InviteMember
mutationが Member
というEntityを返す場合を考えます。
Member
を直接返してしまっても大抵の場合にはうまくいくのですが、例えば削除のMutationだったりしたときには、該当のEntityをデータベースから消すことになるので、resolverの実装次第では関連するオブジェクトの取得で失敗する可能性があります。
そこで、削除系のMutationの場合はクライアントへキャッシュの操作をさせるために、オブジェクトのIDのみを返却するレスポンスにしておくと安全です。
type DeleteMemberSuccess {
memberId: ID!
}
自分は実際にはIDのみを返すのではなく関連オブジェクトへのフィールドを取り除いたNode全体を返却していましたが、今までの経験上は削除時にクライアントで欲しいのはキャッシュ操作で使うIDだけだったなぁというのが自分の感覚です。
関連オブジェクトをどうしても取得したいユースケースもあると思います。
その場合はフィールドとして追加すると良いです。自由にユースケースに合わせてフィールドを追加し、それをスキーマで表現できるのがGraphQLの良いところですね。
type DeleteMemberSuccess {
memberId: ID!
assignedProject: Project!
}
ユースケースが異なる場合にはそれに合わせてMutationを実装することを躊躇しない
我々プログラマはとにかく汎用的に動く処理が好きです。
それもあって様々なユースケースに対応できる汎用的なMutationみたいなものを作成しがちですが、これを突き詰めていってもあまり良い結果にはなりませんでした。
新しいフィールドを追加した時、片方のユースケースではNullableだけどもう片方ではそうではない時に、スキーマを破壊しないためにNullableにしないといけない、みたいなのは非常にストレスが溜まりますし、クライアントにとってもわかりづらいでしょう。
Mutationを追加するときに、それがユースケースを考えた時に新しいものであれば、似たような処理が既にあったとしても新しいMutationを追加することを躊躇するべきではないです。
似た話として、Mutationではないですが複数形のQueryを定義した時には、単数系も定義しておくと良いというのが自分の中ではあります。
複数形のQueryは単数系のQueryの要件も大抵の場合は満たせるので、複数形だけで全てを賄えばいいじゃんとなりがちですが、実際には複数のデータが欲しいケースと単数のデータが欲しいケースではほとんどの場合において、ユースケースは異なります。
クライアントからすれば、単数のデータが欲しい時には、よりシンプルな単数のデータを取得するQueryを叩いてデータ取得できる方が望ましいでしょう。実装コストに関しても、特にクライアント側も同じチームで開発している場合では、クライアント側でのAPI呼び出しが簡易化できるので特に大きな変化はなく、むしろ減らせることが多いように感じます。
Mutationの引数の形式を決定しておく
Mutationの引数の形式はしっかりと決めておかないと、開発が進むに従って揺れていきがちです。
例えば、delete系のMutationは対象となるEntityのIDのみを要求することが多いですが、update系のMutationはそのパラメータも要求します。
type Mutation {
deleteMember(id: ID!): DeletedMember!
updateMember(id: ID!, nickname: String!, role: Role!): UpdatedMember!
}
GraphQLにはMutationのInputをまとめて定義できる仕様があるので、これを使いましょう。
input UpdateMemberInput {
nickName: String!
role: Role!
}
type Mutation {
deleteMember(id: ID!): DeletedMember!
updateMember(id: ID!, input: UpdateMemberInput!): UpdatedMember!
}
このとき、全てのMutationで以下のように1つの"input"というオブジェクトを受けようという派閥もあります。
input DeleteMemberInput {
id: ID!
}
input UpdateMemberInput {
id: ID!
nickName: String!
role: Role!
}
type Mutation {
deleteMember(input: DeleteMemberInput!): DeletedMember!
updateMember(input: UpdateMemberInput!): UpdatedMember!
}
conventionを保ちやすいという点では良く、筆者も昔はこの派閥でしたが、運用していてあまり利点を感じませんでした。
今は入力値はinputでまとめて、対象を示す値はinputの外に出しておくという最初に示した形式をよく使っています。
いずれが良いというより、プロジェクト内で統一されていることがより大事です。
このあたりが揺れていると、新しいメンバーが入った時にどっちに倣うべきなのか毎回聞かれることになります。(そして自分自身でもどっちが良いんだろう?と自問自答することになります..)
おわり
つらつらと今までの自分の経験をもとに後悔したことを述べてみました。
どなたかの参考になれば幸いです。
Discussion