🐯

NestJS(TypeORM)でRepositoryクラスを跨いだトランザクション処理を実現する

2023/04/03に公開

こんにちは、今回は NestJS のトランザクション処理についての記事です。

実現したいこと

表題の通り、Reposiotry クラス間を跨いだトランザクション処理を実現したいと思います。

NestJS の公式ドキュメントの方にも、トランザクション処理の方法についての記載はされているのですが、弊社で採用している Repository パターンを利用したトランザクションについての記述がなかったため、あるユースケースの中で手続き的に呼ばれる複数の Reposioty クラスに跨ったトランザクションの実装をしていこうと思います。

実装

今回は例として以下のユースケースを想定します。

  1. ユーザーが作成される
  2. 作成されたユーザーに紐づくプロフィールが作成される

このようなユースケースを想定して、一旦トランザクションを利用しない状態で実装します。
なお、前提としてUsecaseServiceに依存し、ServiceRepositoryに依存するようにしています。

ユーザー作成用の Usecase クラス

import { Inject, Injectable } from "@nestjs/common";

import { UserService } from "./service/user.service";
import { UserProfileService } from "./service/user-profile.service";

@Injectable()
export class CreateUserUsecase {
  constructor(
    @Inject(UserService) private readonly userService: UserService,
    @Inject(UserProfileService)
    private readonly userProfileService: UserProfileService
  ) {}

  async execute(): Promise<void> {
    const user = await this.userService.create({ name: "ユーザー1" });

    const userProfile = await this.userProfileService.create({
      userId: user.id,
      introduction: "はじめまして",
    });
  }
}

ユーザー作成用の Service クラス

import { Inject, Injectable } from "@nestjs/common";
import { UserRepository } from "./repository/user.repository";
import { User } from "./entity/user.entity";

export class UserService {
  constructor(
    @Inject(UserRepository) private readonly userRepository: UserRepository
  ) {}

  async create(args: { name: string }): Promise<User> {
    return this.userRepository.create(args);
  }
}

ユーザー作成用の Repository クラス

import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";

import { User } from "./entity/user.entity";

@Injectable()
export class UserRepository {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>
  ) {}

  async create(args: { name: string }): Promise<User> {
    const user = new User();
    user.name = args.name;

    return this.userRepository.save(user);
  }
}

実装としては非常に簡潔なもので、CreateUserUsecase.execute が呼ばれると、UserService.create によってユーザーが作成され、その後 UserProfileService.create によってプロフィールが作成されるというものです。

なお、プロフィール作成用の Service クラス、Repository クラスはユーザー作成用のものと同様のため今回は割愛させていただきます。

このような実装の場合、仮にプロフィール作成時にエラーが発生した場合、作成されたユーザーだけが DB にコミットされて残り、関連先のプロフィールが存在しないという不整合な DB 状態となります。

トランザクションを利用した実装

では、上記の実装にトランザクションを張っていきます。

ユーザー作成用の Usecase クラス(修正)

import { Inject, Injectable } from "@nestjs/common";
+ import { InjectEntityManager } from '@nestjs/typeorm';
+ import { EntityManager } from 'typeorm';

+ import { DB_CONNECTION_NAME } from '../../common/typeorm/db-connection-name';
import { UserService } from "./service/user.service";
import { UserProfileService } from "./service/user-profile.service";

@Injectable()
export class CreateUserUsecase {
  constructor(
+   @InjectEntityManager(DB_CONNECTION_NAME) private readonly manager: EntityManager,
    @Inject(UserService) private readonly userService: UserService,
    @Inject(UserProfileService)
    private readonly userProfileService: UserProfileService
  ) {}

  async execute(): Promise<void> {
+   await this.manager.transaction(async (manager) => {
      const user = await this.userService.create(
        { name: "ユーザー1" },
+       manager
      );

      const userProfile = await this.userProfileService.create(
        { userId: user.id, introduction: "はじめまして" },
+       manager
      );
+   });
  }
}

ユーザー作成用の Service クラス(修正)

import { Inject, Injectable } from "@nestjs/common";
+ import { EntityManager } from 'typeorm';

import { UserRepository } from "./repository/user.repository";
import { User } from "./entity/user.entity";

export class UserService {
  constructor(
    @Inject(UserRepository) private readonly userRepository: UserRepository
  ) {}

  async create(
    args: { name: string },
+   manager?: EntityManager
  ): Promise<User> {
-   return this.userRepository.create(args);
+   return this.userRepository.create(args, manager);
  }
}

ユーザー作成用の Repository クラス(修正)

import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
- import { Repository } from "typeorm";
+ import { EntityManager, Repository } from "typeorm";

import { User } from "./entity/user.entity";

@Injectable()
export class UserRepository {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>
  ) {}

  async create(
    args: { name: string },
+   manager?: EntityManager
  ): Promise<User> {
    const user = new User();
    user.name = args.name;

-   return this.userRepository.create(args);
+   return manager
+    ? manager.getRepository(User).save(user)
+    : this.repository.save(user);
  }
}

実装としては、以上になります。

簡単に説明すると、

  • Service クラス、Repository クラスでは、manager というオプショナルな引数を追加します。

  • Usecase クラスでは、@InjectEntityManager() デコレータを利用して EntityManagerをインジェクトし、トランザクションを開始後、各Service クラスに manager を渡します。

  • Repository クラスでは、manager が存在する場合は manager から Repository を生成し、そうでない場合はインジェクトされた Repository を利用するようにしています。

以上のような実装で、manager を受け渡された DB 処理に関してはトランザクション処理の対象となり、もしトランザクション内で何らかのエラーが発生した場合は、manager を介して実行した DB 処理はロールバックされる仕組みとなります。

実行結果

試しに強制的にエラーを発生させてみます。

  async execute(): Promise<void> {
    await this.manager.transaction(async (manager) => {
      const user = await this.userService.create(
        { name: "ユーザー1" },
        manager
      );

      const userProfile = await this.userProfileService.create(
        { userId: user.id, introduction: "はじめまして" },
        manager
      );

+     throw new Error("テスト");
    });
  }
query: ROLLBACK
INSERT INTO `users`(`id`, `name`) VALUES (DEFAULT, ?) -- PARAMETERS: ["ユーザー1"]
INSERT INTO `user_profiles`(`id`, `user_id`, `introduction`) VALUES (DEFAULT, ?, ?) -- PARAMETERS: [1, "はじめまして"]

ユーザー、プロフィールの作成がロールバックされていることが確認できます。
また、DB の方を確認しても、ユーザー、プロフィールの作成は行われていませんでしたので、期待通りの動作となっていそうです。

おまけ:コミット前のデータを参照する場合

トランザクション内で、実行した処理はそのトランザクションが完了するまではコミットされませんが、コミットされる前に、それらのデータを参照したいケースがあると思います。その場合も同様に manager から取得できる Repository を介して、コミット前のデータを取得することが可能です。

@Injectable()
export class UserRepository {
  // 省略

  async findOneByName(
    args: { name: string },
    manager?: EntityManager
  ): Promise<User> {
    return manager
      ? manager.getRepository(User).findOne({ where: { name: args.name } })
      : this.userRepository.findOne({ where: { name: args.name } });
  }
}

まとめ

いかがでしたでしょうか。manager を上位クラスから引き渡していかなければなりませんが、NestJS での Repository クラス間を跨いだトランザクション処理を実現することができました。もしかするとより良い方法があるかもしれませんが、今回はここまでとさせていただきます。もし、より良い方法があれば、コメントいただけると幸いです。

弊社では、GraphQL や NestJS を利用した技術刷新にも積極的に取り組んでおります。これらの技術を用いた開発に興味がある方は、ぜひ弊社の採用ページをご覧ください。

https://www.wantedly.com/projects/1113570
https://www.wantedly.com/projects/1113544
https://www.wantedly.com/projects/1061116
https://spacemarket.co.jp/recruit/engineer/

スペースマーケット Engineer Blog

Discussion