実践GraphQLエラーハンドリング with NestJS
こちらの記事でGraphQLのエラーハンドリングについて整理しました。本記事では、NestJSで実際にどのようにエラーハンドルを実装するか、もう一歩踏み込んでまとめてみます。
ドメインエラーをいい感じに書きたい
まず前提として、以下のようにerrors
配列をもつPayloadをGraphQLレスポンスとして採用しています。Unionでもいいんですが、現時点ではPayloadのほうがより柔軟に対応できると考えています。
type CategoryPayload {
category: Category
errors: [CategoryError]
}
union CategoryError = NotFoundError | InputError | ValidationError | ApplicationError
このとき、以下のようなロジックを書くことになります。
async createCategory(input: CreateCategoryInput): Promise<CategoryPayload>
{
const errors: (typeof CategoryError)[] = [];
if (validationFailed) {
errors.push(
new ValidationError("You can't have more than 20 categories")
);
return { errors };
}
const category = await prisma.category.create({
data: input,
});
return { category };
}
...何か気になったでしょうか。個人的には、以下3点が気になります。
- DRYじゃない。配列の定義・要素の設定・エラー配列のreturnを毎回やるのは辛い
- ドメインエラーが1つしかなくても配列を定義する必要があり面倒くさい
- Errorと言いつつreturnしており違和感がある(かもしれない)
Exception Filterでエラーハンドリングを共通化する
NestJSには、Exceptionを捕捉して共通処理を施すException Filterや、リクエスト・レスポンスを捕捉するInterceptorが用意されています。これらを使ってエラーハンドリングを切り出してしまえば、上記の気持ち悪さを解消できそうです。
具体的には、以下のような形を目指そうと思います。
async createCategory(input: CreateCategoryInput): Promise<Category>
{
// ①配列である必要がないなら、ドメインエラーをそのままthrow
throw new ValidationError("You can't have more than 20 categories");
// ②配列が必要なら、ドメインエラーの配列をラップしてthrow
const errors: (typeof CategoryError)[] = [];
errors.push(new ApplicationError("App error", "APP_ERROR"));
errors.push(new ValidationError("You can't have more than 20 categories"));
throw new MultipleErrorBundler(errors);
...
}
そもそもドメインエラーを配列で返す必要性はあるのか?という疑問があるかもしれません。個人的には、ざっくり以下のような理由から、ドメインエラーは配列で返せるほうが良いと思っています。
- ID・PWなど複数の入力項目がユーザーから渡されたとき、複数のエラーが発生する可能性がある。複数エラーは同時にフィードバックすることでUXが向上する可能性がある。
- モバイルなどネットワークリソースが貧弱な場合、1リクエストでできるだけ多くの情報を渡したい。
ということで、ちょっと不格好ですが①と②が両方できるような形で実装していきます。
エラー定義はこんな感じ。
import { Field, ObjectType } from "@nestjs/graphql";
@ObjectType()
export class BaseError {
@Field()
message: string;
@Field()
code: string;
}
@ObjectType()
export class ValidationError extends BaseError {
constructor(message: string) {
super();
this.message = message;
this.code = "VALIDATION_ERROR";
}
}
...(同様にドメインエラーを定義)
// ドメインエラー配列をラップするクラスを追加
export class MultipleErrorBundler extends Error {
constructor(public readonly errors: BaseError[]) {
super("Multiple errors occurred");
}
}
次、Exception Filter。
import {
ArgumentsHost,
Catch,
Logger,
} from "@nestjs/common";
import { GqlArgumentsHost, GqlExceptionFilter } from "@nestjs/graphql";
import {
MultipleErrorBundler,
BaseError,
} from "src/common/errors";
@Catch()
export class GraphQLExceptionFilter implements GqlExceptionFilter {
private readonly logger = new Logger(GraphQLExceptionFilter.name);
catch(exception: Error, host: ArgumentsHost) {
const errors: BaseError[] = []
if (exception instanceof MultipleErrorBundler) { // ②のケースを処理
errors.push(...exception.errors);
} else if (exception instanceof BaseError) { // ①のケースを処理
errors.push(exception);
} else {
return exception;
}
return {
errors,
};
}
}
@Catch
デコレータに何も渡していないので、すべてのExceptionを捕捉します。
ドメインエラーとして定義したBaseError
, MultipleErrorBundler
だけハンドル(errors
フィールドにマッピングして返却)し、それ以外のExceptionは何もせずに返します(NestJSに処理を任せるため)。
errors
フィールドを持つオブジェクトを決め打ちで返す実装なので、レスポンスをerrors
をもつPayloadとして定義しているロジック内でのみ利用する想定です(responseTypeにerrorsが含まれるチェックを入れたほうが安心)。
あとはこのFilterをモジュールに登録すればOK。
...
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter()
);
app.useGlobalFilters(new GraphQLExceptionFilter());
await app.listen(3000);
}
bootstrap();
これで、throw new ValidationError
やらthrow new MultipleErrorBundler
を呼ぶだけでドメインエラーがマッピングできるようになりました。
まだ運用していないのでこれで問題ないかは分かりませんが、NestJSでドメインエラーをハンドルする際の参考になれば幸いです。お疲れ様でした。
Discussion