Nest.js + GraphQL のインピーダンスミスマッチと戦う
前置き
これは、個人的な違和感から Nest.js + GraphQL のインピーダンスミスマッチにどう対応したかという記録です。公式の記載に一部反してしまっていますので、そのまま参考にはしないでください。
また、Nest.js で GraphQL を利用したことがある人が対象として記載しています。
利用したスタックとしては yarn プロジェクトで
Nest.js v8.4.7
GraphQL v16.5.0
prisma v4.0.0
です。
例としては、
Course ... レッスンのコース
Chapter ... レッスンのコースに複数属するチャプター
というシンプルな構造を例にします。
前提
GraphQL はQueryとしてREADのエントリポイントを定義します。
例えば、CourseというTypeを取得するQueryは GraphQL のスキーマ上で以下のように定義されます。
type Chapter {
id: ID!
title: String!
}
type Course {
id: ID!
titile: String!
chapters: [Chapter!]!
}
type Query {
course(id: ID!): Course!
}
実装
この構造を再現するために、サーバーサイドの実装として、実際にQueryなどの挙動を決定するResolverを記述します。
公式のやり方に従うと以下のようになります。
@ObjectType("Chapter")
class Chapter {
@Field(() => ID)
id: string;
@Field()
title: string;
}
@ObjectType("Course")
class Course {
@Field(() => ID)
id: string;
@Field()
title: string;
@Field(() => [Chapter], { defaultValue: [] })
chapters: Chapter[];
}
@Resolver(() => Course)
export class CoursesResolver {
constructor(
private readonly coursesService: CoursesService,
private readonly chaptersService: ChaptersService,
) {}
@Query(() => Course)
async course(@Args("id", { type: () => ID }) id: string) {
return await this.coursesService.findOne({ id: id });
}
@ResolveField(() => [Chapter])
async chapters(@Parent() course: Course) {
return await this.chaptersService.findManyByCourseId({ courseId: course.id });
}
ここで、Queryを定義する@Queryというデコレータだけでなく、chaptersという名前の@ResolveFieldというデコレーターが存在します。これは GraphQL のその強力なグラフ理論の力によって、Resolver をQueryにだけでなく、Typeにも書くことができるという性質のためです。
具体的には、この Apollo のドキュメントを読んでいただくのがわかりやすいと思います。
では、実際に上のResolverで何が起きるかというと、以下のようなchaptersの含まれないクエリを投げた場合は、async chapters()の部分は呼ばれず、
query {
course {
id title
}
}
次のようなchaptersの含まれるクエリを投げた場合は、async chapters()が呼ばれます。
query {
course {
id title
chapters {
id title
}
}
}
違和感
ここで、自分は2つ違和感を覚えました。
① Queryの返り値の型がない。
公式のサンプルでもQueryのResolver関数の返り値の型がないのですが、これは意図的なのかなと思います。
@Query(() => Course)
+ async course(@Args("id", { type: () => ID }) id: string): Promise<Course> {
return await this.coursesService.findOne({ id: id });
}
当然、Courseが返って欲しいと思い試しに追加すると、これはエラーになりました。というのも、Courseクラスのchaptersは、このメソッドで取得せずに、@ResolveFieldによって取得しているからです。
CourseをOmit<Course, "chapters">とすることで、ここで型的に回避することはできるのですが、そもそも Resolver と Service 間を繋ぐ DTO オブジェクトとしてEntity(ここではCourse) が機能していないことがわかりました。
② そもそも@Queryと@ResolveFieldを同じ場所に置くのはどうなのか
Nest.js において@ResolveFieldの機能がわかりづらくなってる理由だと思うのですが、エントリポイントとなるQueryと個々のType内のフィールドは(👇 スキーマを再参照してもらうとわかる通り)階層的には異なるはずです。そのため、ただ同じ Service を呼び出すからという理由で、同じクラス(course.resolver.ts)にまとめるのはどうなのかと感じました。
type Course {
id: ID!
titile: String!
chapters: [Chapter!]!
}
type Query {
course(id: ID!): Course!
}