Open2

Nest.js + GraphQL のインピーダンスミスマッチと戦う

FooliShellFooliShell

前置き

これは、個人的な違和感から Nest.js + GraphQL のインピーダンスミスマッチにどう対応したかという記録です。公式の記載に一部反してしまっていますので、そのまま参考にはしないでください。
また、Nest.js で GraphQL を利用したことがある人が対象として記載しています。

利用したスタックとしては yarn プロジェクトで
Nest.js v8.4.7
GraphQL v16.5.0
prisma v4.0.0
です。

例としては、
Course ... レッスンのコース
Chapter ... レッスンのコースに複数属するチャプター
というシンプルな構造を例にします。

FooliShellFooliShell

前提

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を記述します。
公式のやり方に従うと以下のようになります。

chapter.entity.ts
@ObjectType("Chapter")
class Chapter {
  @Field(() => ID)
  id: string;

  @Field()
  title: string;
}
course.entity.ts
@ObjectType("Course")
class Course {
  @Field(() => ID)
  id: string;

  @Field()
  title: string;

  @Field(() => [Chapter], { defaultValue: [] })
  chapters: Chapter[];
}
corses.resolver.ts
@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 のドキュメントを読んでいただくのがわかりやすいと思います。
https://apollographql-jp.com/tutorial/resolvers/#write-resolvers-on-types

では、実際に上のResolverで何が起きるかというと、以下のようなchaptersの含まれないクエリを投げた場合は、async chapters()の部分は呼ばれず、

query {
  course {
    id title
  }
}

次のようなchaptersの含まれるクエリを投げた場合は、async chapters()が呼ばれます。

query {
  course {
    id title
    chapters {
      id title
    }
  }
}

違和感

ここで、自分は2つ違和感を覚えました。

Queryの返り値の型がない。

公式のサンプルでもQueryResolver関数の返り値の型がないのですが、これは意図的なのかなと思います。

ts
  @Query(() => Course)
+ async course(@Args("id", { type: () => ID }) id: string): Promise<Course> {
    return await this.coursesService.findOne({ id: id });
  }

当然、Courseが返って欲しいと思い試しに追加すると、これはエラーになりました。というのも、Courseクラスのchaptersは、このメソッドで取得せずに、@ResolveFieldによって取得しているからです。
CourseOmit<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!
}