NestJS TypeORM QueryRunnerでトランザクション制御
TypeORMのQueryRunnerを使い、複数テーブルを跨ぐ処理を明示的なトランザクションで安全にまとめる
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-sqlite3TypeORMは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:devnpmの場合は 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/accountsAlice: 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をインストール -
AppModuleにTypeOrmModule.forRoot()を追加(synchronize: trueは開発時のみ) -
Account/Transferエンティティを作成 -
CreateAccountDto/TransferDtoを作成 -
AccountsServiceにfindAll/findOne/create/remove/transferを実装 -
transfer内で QueryRunner のstartTransaction/commitTransaction/rollbackTransaction/releaseを使う -
AccountsControllerとAccountsModuleを作成 - curl で口座作成・振替(正常系・残高不足)を確認