awesome-hacks
Docs

NestJS TypeORM QueryRunnerでトランザクション制御

TypeORMのQueryRunnerを使い、複数テーブルを跨ぐ処理を明示的なトランザクションで安全にまとめる

最終更新:2026/06/16

NestJSでCRUD APIを作る(Prisma + SQLite)まで終えている前提で進める。
この記事では、Prismaとは別のORM選択肢である TypeORM を使い、QueryRunner で明示的なトランザクション制御を実装する。

この記事でやること

  • TypeORM + SQLite を NestJS に追加する
  • Account(口座)と Transfer(振替履歴)の 2 テーブルをエンティティで定義する
  • AccountsService に CRUD を実装する
  • QueryRunner を使い「A 口座から引き落とし → B 口座に入金」を 1 トランザクション で処理する
  • エラー時のロールバックを確認する

ファイル構成(今回触る場所)

api-sample/
├─ .env                             ← TypeORM用のDB設定を追加
└─ src/
   ├─ app.module.ts                 ← TypeOrmModuleを追加(編集)
   └─ accounts/
      ├─ entities/
      │  ├─ account.entity.ts      ← Accountエンティティ(新規)
      │  └─ transfer.entity.ts     ← Transferエンティティ(新規)
      ├─ dto/
      │  ├─ create-account.dto.ts  ← 作成用DTO(新規)
      │  └─ transfer.dto.ts        ← 振替用DTO(新規)
      ├─ accounts.service.ts       ← CRUD+トランザクション実装(新規)
      ├─ accounts.controller.ts    ← ルーティング(新規)
      └─ accounts.module.ts        ← Moduleの定義(新規)

accounts は Prisma の users とは独立した機能モジュールとして追加する。

サンプル実装手順

1. TypeORMをインストールする

# npmの場合
npm install @nestjs/typeorm typeorm better-sqlite3
npm install --save-dev @types/better-sqlite3

# pnpmの場合
pnpm add @nestjs/typeorm typeorm better-sqlite3
pnpm add -D @types/better-sqlite3

TypeORMはNode.js 18以上で動作する。@nestjs/typeorm はNestJSとの統合パッケージで、TypeOrmModule を提供する。

2. .env にTypeORM用の設定を追加する

既存の DATABASE_URL(Prisma用)とは別に、TypeORM用のDBファイルパスを追記する。

DATABASE_URL="file:./prisma/dev.db"
TYPEORM_DB_PATH="./typeorm.db"

SQLiteでは、別のパスを指定することでPrismaとTypeORMが別ファイルで共存できる。

3. AppModule に TypeOrmModule を追加する

src/app.module.ts を編集して TypeOrmModule.forRootAsync() を追加する。

import "dotenv/config";
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { PrismaModule } from "./prisma/prisma.module";
import { UsersModule } from "./users/users.module";
import { AccountsModule } from "./accounts/accounts.module";
import { Account } from "./accounts/entities/account.entity";
import { Transfer } from "./accounts/entities/transfer.entity";

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "better-sqlite3",
      database: process.env.TYPEORM_DB_PATH ?? "./typeorm.db",
      entities: [Account, Transfer],
      synchronize: true,  // 開発中はtrue。本番ではマイグレーションを使う
    }),
    PrismaModule,
    UsersModule,
    AccountsModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

synchronize: true はEntityの変更をDB起動時に自動反映する設定。開発時は便利だが、本番環境では必ず false にしてTypeORMのマイグレーションを使う

4. Account / Transfer エンティティを定義する

TypeORMでは、テーブルの定義を Entityクラス で行う。Prismaの model 定義に相当する。

今回作る2テーブルの関係は次の通り。1つの口座は複数の振替履歴を持つ(送金元・送金先それぞれ)。

erDiagram
    accounts {
        int id PK
        string name
        int balance
        datetime createdAt
        datetime updatedAt
    }
    transfers {
        int id PK
        int fromAccountId FK
        int toAccountId FK
        int amount
        datetime createdAt
    }
    accounts ||--o{ transfers : "送金元(fromAccount)"
    accounts ||--o{ transfers : "送金先(toAccount)"

src/accounts/entities/account.entity.ts

import {
  Column,
  CreateDateColumn,
  Entity,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from "typeorm";
import { Transfer } from "./transfer.entity";

@Entity("accounts")
export class Account {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ unique: true })
  name!: string;

  @Column({ type: "int", default: 0 })
  balance!: number;

  @OneToMany(() => Transfer, (transfer) => transfer.fromAccount)
  sentTransfers!: Transfer[];

  @OneToMany(() => Transfer, (transfer) => transfer.toAccount)
  receivedTransfers!: Transfer[];

  @CreateDateColumn()
  createdAt!: Date;

  @UpdateDateColumn()
  updatedAt!: Date;
}

各デコレータの役割は次のとおり。

デコレータ役割
@Entity("accounts")クラスをDBテーブルに対応付ける(テーブル名を指定)
@PrimaryGeneratedColumn()自動採番の主キー(INTEGER PRIMARY KEY AUTOINCREMENT相当)
@Column()通常のカラム。オプションで型・制約を指定
@OneToMany()1対多リレーション(この口座から出た振替の一覧)
@CreateDateColumn()レコード作成時に自動セットされる日時カラム
@UpdateDateColumn()レコード更新時に自動更新される日時カラム

src/accounts/entities/transfer.entity.ts

import {
  Column,
  CreateDateColumn,
  Entity,
  ManyToOne,
  PrimaryGeneratedColumn,
} from "typeorm";
import { Account } from "./account.entity";

@Entity("transfers")
export class Transfer {
  @PrimaryGeneratedColumn()
  id!: number;

  @ManyToOne(() => Account, (account) => account.sentTransfers)
  fromAccount!: Account;

  @ManyToOne(() => Account, (account) => account.receivedTransfers)
  toAccount!: Account;

  @Column({ type: "int" })
  amount!: number;

  @CreateDateColumn()
  createdAt!: Date;
}

@ManyToOne() は多対1リレーション。振替は「1つの送金元口座」と「1つの送金先口座」に属する。

5. DTO を定義する

src/accounts/dto/create-account.dto.ts

import { IsInt, IsString, Length, Min } from "class-validator";

export class CreateAccountDto {
  @IsString()
  @Length(1, 50)
  name!: string;

  @IsInt()
  @Min(0)
  initialBalance!: number;
}

src/accounts/dto/transfer.dto.ts

import { IsInt, IsPositive } from "class-validator";

export class TransferDto {
  @IsInt()
  fromAccountId!: number;

  @IsInt()
  toAccountId!: number;

  @IsInt()
  @IsPositive()
  amount!: number;
}

6. AccountsService(CRUD + QueryRunnerトランザクション)

ここがこの記事の中心。まず CRUD を実装し、続いて transfer メソッドで 明示的なトランザクション を組む。

src/accounts/accounts.service.ts

import {
  BadRequestException,
  Injectable,
  NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { DataSource, Repository } from "typeorm";
import { Account } from "./entities/account.entity";
import { Transfer } from "./entities/transfer.entity";
import { CreateAccountDto } from "./dto/create-account.dto";
import { TransferDto } from "./dto/transfer.dto";

@Injectable()
export class AccountsService {
  constructor(
    @InjectRepository(Account)
    private readonly accountRepo: Repository<Account>,
    @InjectRepository(Transfer)
    private readonly transferRepo: Repository<Transfer>,
    private readonly dataSource: DataSource,
  ) {}

  // ── CRUD ──────────────────────────────────────────

  findAll() {
    return this.accountRepo.find();
  }

  async findOne(id: number) {
    const account = await this.accountRepo.findOneBy({ id });
    if (!account) throw new NotFoundException(`Account #${id} not found`);
    return account;
  }

  create(dto: CreateAccountDto) {
    const account = this.accountRepo.create({
      name: dto.name,
      balance: dto.initialBalance,
    });
    return this.accountRepo.save(account);
  }

  async remove(id: number) {
    const account = await this.findOne(id);
    return this.accountRepo.remove(account);
  }

  // ── トランザクション(QueryRunner)──────────────────

  async transfer(dto: TransferDto): Promise<Transfer> {
    const { fromAccountId, toAccountId, amount } = dto;

    // QueryRunnerを取得してトランザクションを開始する
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // トランザクション内ではqueryRunner.managerを使う
      const from = await queryRunner.manager.findOneBy(Account, {
        id: fromAccountId,
      });
      const to = await queryRunner.manager.findOneBy(Account, {
        id: toAccountId,
      });

      if (!from) throw new NotFoundException(`Account #${fromAccountId} not found`);
      if (!to) throw new NotFoundException(`Account #${toAccountId} not found`);
      if (from.balance < amount) {
        throw new BadRequestException(
          `Insufficient balance: ${from.balance} < ${amount}`,
        );
      }

      // 残高を更新する(両方の更新が1トランザクションにまとまる)
      from.balance -= amount;
      to.balance += amount;

      await queryRunner.manager.save(Account, from);
      await queryRunner.manager.save(Account, to);

      // 振替履歴を記録する
      const transfer = queryRunner.manager.create(Transfer, {
        fromAccount: from,
        toAccount: to,
        amount,
      });
      const saved = await queryRunner.manager.save(Transfer, transfer);

      // ここまで例外がなければコミットする
      await queryRunner.commitTransaction();

      return saved;
    } catch (e) {
      // 何か失敗したらロールバックして元に戻す
      await queryRunner.rollbackTransaction();
      throw e;
    } finally {
      // 成功・失敗にかかわらずQueryRunnerを解放する
      await queryRunner.release();
    }
  }
}

transfer メソッドの流れを整理すると次のとおり。

sequenceDiagram
    participant S as AccountsService
    participant QR as QueryRunner
    participant DB as Database

    S->>QR: createQueryRunner() / connect()
    S->>QR: startTransaction()

    S->>QR: manager.findOneBy(from口座)
    QR->>DB: SELECT
    DB-->>S: from口座データ

    S->>QR: manager.findOneBy(to口座)
    QR->>DB: SELECT
    DB-->>S: to口座データ

    Note over S: 残高チェック(不足なら BadRequestException)

    S->>QR: manager.save(from口座 残高-=amount)
    QR->>DB: UPDATE accounts SET balance = ...
    S->>QR: manager.save(to口座 残高+=amount)
    QR->>DB: UPDATE accounts SET balance = ...
    S->>QR: manager.save(振替履歴)
    QR->>DB: INSERT INTO transfers ...

    alt 全て成功
        S->>QR: commitTransaction()
        QR->>DB: COMMIT(変更が確定)
    else どこかで例外
        S->>QR: rollbackTransaction()
        QR->>DB: ROLLBACK(全操作がなかったことに)
    end

    S->>QR: release()(必ず実行)

finally 内の release() は、例外が発生してもリソースリークを防ぐために必須。release() なしだとコネクションプールが枯渇するリスクがある。

7. AccountsController を実装する

src/accounts/accounts.controller.ts

import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from "@nestjs/common";
import { AccountsService } from "./accounts.service";
import { CreateAccountDto } from "./dto/create-account.dto";
import { TransferDto } from "./dto/transfer.dto";

@Controller("accounts")
export class AccountsController {
  constructor(private readonly accountsService: AccountsService) {}

  @Get()
  findAll() {
    return this.accountsService.findAll();
  }

  @Get(":id")
  findOne(@Param("id", ParseIntPipe) id: number) {
    return this.accountsService.findOne(id);
  }

  @Post()
  create(@Body() dto: CreateAccountDto) {
    return this.accountsService.create(dto);
  }

  @Delete(":id")
  remove(@Param("id", ParseIntPipe) id: number) {
    return this.accountsService.remove(id);
  }

  @Post("transfer")
  transfer(@Body() dto: TransferDto) {
    return this.accountsService.transfer(dto);
  }
}

ParseIntPipe はURLパラメータの文字列を数値に変換するNestJSのビルトインPipe。/accounts/1"1"number として受け取る。

8. AccountsModule を定義する

src/accounts/accounts.module.ts

import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Account } from "./entities/account.entity";
import { Transfer } from "./entities/transfer.entity";
import { AccountsController } from "./accounts.controller";
import { AccountsService } from "./accounts.service";

@Module({
  imports: [TypeOrmModule.forFeature([Account, Transfer])],
  controllers: [AccountsController],
  providers: [AccountsService],
})
export class AccountsModule {}

TypeOrmModule.forFeature([...]) でこのModule内で使うEntityを登録する。これにより @InjectRepository(Account) が解決できるようになる。

9. 動作確認

起動する。

pnpm run start:dev

npmの場合は npm run start:dev

起動ログに TypeORM が accounts / transfers テーブルを作成するSQL(synchronize: true の場合)が流れれば成功。

口座を作成する

curl -s -X POST http://localhost:3000/accounts \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","initialBalance":1000}'

curl -s -X POST http://localhost:3000/accounts \
  -H "Content-Type: application/json" \
  -d '{"name":"Bob","initialBalance":500}'

一覧で残高を確認する

curl -s http://localhost:3000/accounts

Alice: 1000、Bob: 500 が返ってくれば OK。

振替を実行する(正常系)

AliceからBobへ300円振替する(Alice=1, Bob=2 の場合)。

curl -s -X POST http://localhost:3000/accounts/transfer \
  -H "Content-Type: application/json" \
  -d '{"fromAccountId":1,"toAccountId":2,"amount":300}'

再度一覧を確認すると Alice: 700、Bob: 800 になっていれば、トランザクションが正しく機能している。

残高不足でロールバックを確認する(異常系)

curl -s -X POST http://localhost:3000/accounts/transfer \
  -H "Content-Type: application/json" \
  -d '{"fromAccountId":1,"toAccountId":2,"amount":9999}'

次のような 400 レスポンスが返り、Alice・Bobの残高が変わっていなければロールバックが機能している。

{
  "statusCode": 400,
  "message": "Insufficient balance: 700 < 9999",
  ...
}

NestJSの例外処理 を設定済みであれば、GlobalExceptionFilter がエラーを整形して返す。未設定の場合は NestJS のデフォルトエラーレスポンスになる。

QueryRunner の try / catch / finally パターン

トランザクション制御の定型パターンをまとめると次のようになる。

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

try {
  // queryRunner.manager 経由で操作する
  await queryRunner.manager.save(SomeEntity, data);

  await queryRunner.commitTransaction();
} catch (e) {
  await queryRunner.rollbackTransaction();
  throw e;   // 呼び出し元に例外を伝播させる
} finally {
  await queryRunner.release();  // 必ず解放する
}

このパターンを押さえておくと、「複数テーブルを跨ぐ処理」「条件によって途中でロールバック」「外部API呼び出しの前後でDB操作」などのシナリオに応用しやすい。

実装タスク(チェックリスト)

  • @nestjs/typeorm / typeorm / better-sqlite3 をインストール
  • AppModuleTypeOrmModule.forRoot() を追加(synchronize: true は開発時のみ)
  • Account / Transfer エンティティを作成
  • CreateAccountDto / TransferDto を作成
  • AccountsServicefindAll / findOne / create / remove / transfer を実装
  • transfer 内で QueryRunner の startTransaction / commitTransaction / rollbackTransaction / release を使う
  • AccountsControllerAccountsModule を作成
  • curl で口座作成・振替(正常系・残高不足)を確認

参考