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!
}