🔩

実践GraphQLエラーハンドリング with NestJS

2024/06/30に公開

こちらの記事でGraphQLのエラーハンドリングについて整理しました。本記事では、NestJSで実際にどのようにエラーハンドルを実装するか、もう一歩踏み込んでまとめてみます。
https://zenn.dev/minamiso/articles/994e56830e42e1

ドメインエラーをいい感じに書きたい

まず前提として、以下のようにerrors配列をもつPayloadをGraphQLレスポンスとして採用しています。Unionでもいいんですが、現時点ではPayloadのほうがより柔軟に対応できると考えています。

type CategoryPayload {
    category: Category
    errors: [CategoryError]
}

union CategoryError = NotFoundError | InputError | ValidationError | ApplicationError

このとき、以下のようなロジックを書くことになります。

service.ts
  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が用意されています。これらを使ってエラーハンドリングを切り出してしまえば、上記の気持ち悪さを解消できそうです。

具体的には、以下のような形を目指そうと思います。

service.ts
  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リクエストでできるだけ多くの情報を渡したい。

ということで、ちょっと不格好ですが①と②が両方できるような形で実装していきます。

エラー定義はこんな感じ。

error.ts
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。

filter.ts
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。

main.ts
...
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