TypeORMを使用したNestJSアプリケーションにおいて、エンティティ間のリレーションシップ設計は避けて通れない重要なトピックです。本記事では、@OneToMany@ManyToOneなどのリレーションデコレータの適切な使い方から、開発者を悩ませるN+1問題の原因と解決策まで、実践的なコード例とともに解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Node.js 20以上
npm 10以上
NestJS 11.x
TypeORM 0.3.x
MySQL 8.0以上
OS Windows / macOS / Linux
エディタ VS Code(推奨)

事前に以下の準備を完了してください。

  • NestJS CLIのインストール済み(npm install -g @nestjs/cli
  • NestJSプロジェクトの作成済み
  • TypeORMとMySQLの接続設定完了

TypeORMの導入と接続設定についてはNestJSとTypeORMによるMySQL接続を、Repositoryの基本操作についてはNestJSのRepository実装を参照してください。

TypeORMリレーションシップデコレータの種類

TypeORMでは、エンティティ間の関連を表現するために4種類のリレーションシップデコレータが提供されています。

デコレータ 関連の種類
@OneToOne 1対1 ユーザーとプロフィール
@OneToMany 1対多 著者と書籍
@ManyToOne 多対1 書籍と著者
@ManyToMany 多対多 記事とタグ

本記事では、実務で最も頻繁に使用される@OneToMany@ManyToOneの双方向関連を中心に解説します。

サンプルドメインモデルの設計

本記事では、書籍管理システムを題材にします。著者(Author)と書籍(Book)のエンティティを定義します。

erDiagram
    AUTHOR ||--o{ BOOK : "writes"
    AUTHOR {
        number id PK
        string name
        string email
    }
    BOOK {
        number id PK
        string title
        string isbn
        number authorId FK
    }

著者は複数の書籍を執筆でき、書籍は必ず1人の著者に紐づくという1対多の関係です。

@ManyToOneデコレータで多対1関連を定義する

@ManyToOneは、多側のエンティティから1側のエンティティへの参照を定義します。データベースでは外部キーを持つ側がリレーションのオーナーとなります。

Bookエンティティの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// src/books/entities/book.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  ManyToOne,
  JoinColumn,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm';
import { Author } from '../../authors/entities/author.entity';

@Entity('books')
export class Book {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 200 })
  title: string;

  @Column({ length: 13, unique: true })
  isbn: string;

  @Column({ type: 'text', nullable: true })
  description: string;

  @ManyToOne(() => Author, (author) => author.books, {
    nullable: false,
    onDelete: 'CASCADE',
  })
  @JoinColumn({ name: 'author_id' })
  author: Author;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

@ManyToOneの主要なオプション

オプション 説明 デフォルト値
eager 親エンティティ取得時に自動的にロード false
nullable 関連が必須かどうか true
onDelete 親削除時の動作(CASCADE、SET NULL等) NO ACTION
onUpdate 親更新時の動作 NO ACTION

@JoinColumnの役割

@JoinColumnは外部キーカラムの詳細を指定します。省略した場合、TypeORMはpropertyName + Idという命名規則で外部キーカラムを生成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// @JoinColumnを省略した場合
@ManyToOne(() => Author, (author) => author.books)
author: Author;
// → 外部キーは "authorId" になる

// @JoinColumnで明示的に指定
@ManyToOne(() => Author, (author) => author.books)
@JoinColumn({ name: 'author_id' })
author: Author;
// → 外部キーは "author_id" になる

データベースの命名規則(snake_case)とTypeScriptの命名規則(camelCase)を一致させるために、@JoinColumnで明示的に指定することを推奨します。

@OneToManyデコレータで1対多関連を定義する

@OneToManyは、1側のエンティティから多側のエンティティへの参照を定義します。@OneToManyは常に@ManyToOneと対になり、@ManyToOne側がリレーションのオーナーとなります。

Authorエンティティの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// src/authors/entities/author.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  OneToMany,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm';
import { Book } from '../../books/entities/book.entity';

@Entity('authors')
export class Author {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 100 })
  name: string;

  @Column({ unique: true })
  email: string;

  @Column({ type: 'text', nullable: true })
  bio: string;

  @OneToMany(() => Book, (book) => book.author, {
    cascade: ['insert', 'update'],
  })
  books: Book[];

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

@OneToManyの主要なオプション

オプション 説明 デフォルト値
cascade カスケード操作の種類(insert、update、remove等) なし
eager 自動的にリレーションをロード false

cascadeオプションの詳細

cascadeオプションを使用すると、親エンティティの操作が子エンティティにも自動的に適用されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@OneToMany(() => Book, (book) => book.author, {
  cascade: true, // すべてのカスケード操作を有効化
})
books: Book[];

// または特定の操作のみ指定
@OneToMany(() => Book, (book) => book.author, {
  cascade: ['insert', 'update'], // insert と update のみ
})
books: Book[];
カスケード種別 説明
insert 親を保存するときに新しい子も保存
update 親を更新するときに子も更新
remove 親を削除するときに子も削除
soft-remove 親を論理削除するときに子も論理削除
recover 親を復元するときに子も復元

双方向リレーションの整合性を保つヘルパーメソッド

双方向リレーションでは、両側の参照を同期させる必要があります。ヘルパーメソッドを用意することで、データの整合性を保ちながら安全に関連を操作できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/authors/entities/author.entity.ts
@Entity('authors')
export class Author {
  // ...他のフィールド

  @OneToMany(() => Book, (book) => book.author, {
    cascade: ['insert', 'update'],
  })
  books: Book[];

  // ヘルパーメソッド:書籍を追加
  addBook(book: Book): void {
    if (!this.books) {
      this.books = [];
    }
    this.books.push(book);
    book.author = this;
  }

  // ヘルパーメソッド:書籍を削除
  removeBook(book: Book): void {
    const index = this.books?.indexOf(book);
    if (index !== undefined && index > -1) {
      this.books.splice(index, 1);
      book.author = null as unknown as Author;
    }
  }
}

ヘルパーメソッドを使用することで、片側だけ設定する不整合を防げます。

1
2
3
4
5
// 良い例:ヘルパーメソッドを使用
author.addBook(book);

// 悪い例:片側だけ設定すると不整合が発生する可能性
author.books.push(book);  // book.author は設定されない

Eager LoadingとLazy Loadingの違いと使い分け

TypeORMでは、関連エンティティをいつ取得するかを2つの方法で制御できます。

フェッチ戦略の比較

戦略 動作 メリット デメリット
Eager Loading 親エンティティと同時に取得 常に関連データが利用可能 不要なデータも取得、N+1問題の原因
Lazy Loading 関連エンティティにアクセスしたときに取得 必要なときだけ取得 Promiseの扱いが必要

Eager Loadingの設定

eager: trueを設定すると、親エンティティを取得する際に自動的に関連エンティティも取得されます。

1
2
3
4
@ManyToOne(() => Author, (author) => author.books, {
  eager: true, // 自動的にAuthorを取得
})
author: Author;

ただし、eager: trueの使用は推奨されません。以下の理由があります。

  1. パフォーマンスへの影響: すべてのクエリで関連データが取得される
  2. 制御の難しさ: 特定のケースでのみ関連データが必要な場合に対応できない
  3. N+1問題の誘発: 複数のエンティティを取得する際にN+1問題が発生しやすい

Lazy Loadingの使用(TypeScript)

TypeORMのLazy LoadingはPromiseを返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// エンティティでPromise型を使用
@Entity('books')
export class Book {
  @ManyToOne(() => Author, (author) => author.books)
  author: Promise<Author>;
}

// 使用時はawaitが必要
const book = await bookRepository.findOneBy({ id: 1 });
const author = await book.author; // Promiseを解決
console.log(author.name);

Lazy Loadingの注意点として、TypeORMのLazy Loadingは初回アクセス時に追加のクエリを発行するため、ループ内で使用するとN+1問題を引き起こします。

推奨されるアプローチ

実務では、eager: false(デフォルト)のまま、必要に応じてクエリ時に明示的に関連を取得する方法が推奨されます。

1
2
3
4
5
6
7
8
9
// relationsオプションで明示的に指定
const books = await bookRepository.find({
  relations: { author: true },
});

// または relations を配列で指定
const books = await bookRepository.find({
  relations: ['author'],
});

N+1問題とは何か

N+1問題は、ORMを使用したアプリケーション開発で最も頻繁に発生するパフォーマンス問題の1つです。

N+1問題の発生メカニズム

N+1問題とは、1件の親エンティティ一覧を取得するクエリ(1)に加えて、各親エンティティに対する関連エンティティ取得クエリがN回発生する現象です。

sequenceDiagram
    participant App as アプリケーション
    participant DB as データベース
    
    App->>DB: 1. SELECT * FROM books(書籍一覧取得)
    DB-->>App: 書籍10件を返却
    
    loop 各書籍に対して
        App->>DB: 2. SELECT * FROM authors WHERE id = 1
        App->>DB: 3. SELECT * FROM authors WHERE id = 2
        App->>DB: ...
        App->>DB: 11. SELECT * FROM authors WHERE id = 10
    end
    
    Note over App,DB: 合計11回のクエリ(1 + 10 = N+1)

N+1問題を再現するコード

以下のコードでN+1問題を確認できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/books/books.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Book } from './entities/book.entity';

@Injectable()
export class BooksService {
  constructor(
    @InjectRepository(Book)
    private readonly booksRepository: Repository<Book>,
  ) {}

  async demonstrateN1Problem(): Promise<void> {
    // 1回目のクエリ:書籍一覧を取得
    const books = await this.booksRepository.find();
    
    // 書籍ごとにN回のクエリが発生(Lazy Loadingの場合)
    for (const book of books) {
      // authorにアクセスするたびにSELECTが実行される
      const author = await book.author;
      console.log(`${book.title} by ${author.name}`);
    }
  }
}

SQLログの確認方法

TypeORMの設定でSQLログを有効化すると、実行されるSQLを確認できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// src/app.module.ts
TypeOrmModule.forRoot({
  type: 'mysql',
  host: 'localhost',
  port: 3306,
  username: 'root',
  password: 'password',
  database: 'bookstore',
  entities: [Author, Book],
  synchronize: true,
  logging: true, // SQLログを有効化
})

実行結果のログ例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 1回目:書籍一覧取得
SELECT `Book`.`id`, `Book`.`title`, `Book`.`isbn`, `Book`.`author_id` 
FROM `books` `Book`

-- 2回目以降:各書籍の著者を個別に取得(N+1の原因)
SELECT `Author`.`id`, `Author`.`name`, `Author`.`email` 
FROM `authors` `Author` WHERE `Author`.`id` = 1
SELECT `Author`.`id`, `Author`.`name`, `Author`.`email` 
FROM `authors` `Author` WHERE `Author`.`id` = 2
SELECT `Author`.`id`, `Author`.`name`, `Author`.`email` 
FROM `authors` `Author` WHERE `Author`.`id` = 3
-- ...N回繰り返し

relationsオプションでN+1問題を解決する

最もシンプルな解決策は、find()系メソッドのrelationsオプションを使用することです。

relationsオプションの使用

1
2
3
4
5
6
7
async findAllWithAuthors(): Promise<Book[]> {
  return this.booksRepository.find({
    relations: {
      author: true,
    },
  });
}

または配列形式でも指定できます。

1
2
3
4
5
async findAllWithAuthors(): Promise<Book[]> {
  return this.booksRepository.find({
    relations: ['author'],
  });
}

実行されるSQL

1
2
3
4
5
SELECT 
  `Book`.`id`, `Book`.`title`, `Book`.`isbn`, `Book`.`author_id`,
  `Author`.`id` AS `Author_id`, `Author`.`name` AS `Author_name`, `Author`.`email` AS `Author_email`
FROM `books` `Book`
LEFT JOIN `authors` `Author` ON `Author`.`id` = `Book`.`author_id`

1回のクエリで書籍と著者をすべて取得でき、N+1問題が解決します。

ネストしたリレーションの取得

複数階層のリレーションも取得できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 著者とその所属出版社も取得
async findAllWithAuthorAndPublisher(): Promise<Book[]> {
  return this.booksRepository.find({
    relations: {
      author: {
        publisher: true, // ネストしたリレーション
      },
    },
  });
}

QueryBuilderによるJOIN FETCHの活用

より複雑なクエリや条件付きのリレーション取得には、QueryBuilderを使用します。

leftJoinAndSelectの基本

1
2
3
4
5
6
async findAllWithAuthorsQueryBuilder(): Promise<Book[]> {
  return this.booksRepository
    .createQueryBuilder('book')
    .leftJoinAndSelect('book.author', 'author')
    .getMany();
}

条件付きJOIN

特定の条件に一致する関連エンティティのみを取得できます。

1
2
3
4
5
6
7
8
9
// アクティブな著者の書籍のみ取得
async findBooksWithActiveAuthors(): Promise<Book[]> {
  return this.booksRepository
    .createQueryBuilder('book')
    .innerJoinAndSelect('book.author', 'author', 'author.isActive = :isActive', {
      isActive: true,
    })
    .getMany();
}

複数のリレーションを同時に取得

1
2
3
4
5
6
7
8
async findBooksWithAllRelations(): Promise<Book[]> {
  return this.booksRepository
    .createQueryBuilder('book')
    .leftJoinAndSelect('book.author', 'author')
    .leftJoinAndSelect('book.categories', 'category')
    .leftJoinAndSelect('book.reviews', 'review')
    .getMany();
}

1対多リレーションの取得(著者と書籍一覧)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// src/authors/authors.service.ts
async findAuthorWithBooks(id: number): Promise<Author | null> {
  return this.authorsRepository
    .createQueryBuilder('author')
    .leftJoinAndSelect('author.books', 'book')
    .where('author.id = :id', { id })
    .getOne();
}

// すべての著者とその書籍を取得
async findAllAuthorsWithBooks(): Promise<Author[]> {
  return this.authorsRepository
    .createQueryBuilder('author')
    .leftJoinAndSelect('author.books', 'book')
    .orderBy('author.name', 'ASC')
    .addOrderBy('book.title', 'ASC')
    .getMany();
}

集計と組み合わせる

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 各著者の書籍数を取得
async findAuthorsWithBookCount(): Promise<
  { authorId: number; authorName: string; bookCount: number }[]
> {
  return this.authorsRepository
    .createQueryBuilder('author')
    .select('author.id', 'authorId')
    .addSelect('author.name', 'authorName')
    .addSelect('COUNT(book.id)', 'bookCount')
    .leftJoin('author.books', 'book')
    .groupBy('author.id')
    .addGroupBy('author.name')
    .getRawMany();
}

innerJoinAndSelectとleftJoinAndSelectの使い分け

JOINの種類によって取得結果が異なります。

JOINの種類と違い

JOIN種類 説明 使用場面
leftJoinAndSelect 関連が存在しなくても親を取得 書籍がない著者も含めて取得したい場合
innerJoinAndSelect 関連が存在する親のみ取得 書籍がある著者のみ取得したい場合

使用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// LEFT JOIN: 書籍がない著者も取得
async findAllAuthors(): Promise<Author[]> {
  return this.authorsRepository
    .createQueryBuilder('author')
    .leftJoinAndSelect('author.books', 'book')
    .getMany();
}

// INNER JOIN: 書籍がある著者のみ取得
async findAuthorsWithBooks(): Promise<Author[]> {
  return this.authorsRepository
    .createQueryBuilder('author')
    .innerJoinAndSelect('author.books', 'book')
    .getMany();
}

パフォーマンス計測による効果の検証

N+1問題の解決効果を数値で確認します。

テストデータの準備

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// src/database/seeders/author-book.seeder.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Author } from '../../authors/entities/author.entity';
import { Book } from '../../books/entities/book.entity';

@Injectable()
export class AuthorBookSeeder {
  constructor(
    @InjectRepository(Author)
    private readonly authorsRepository: Repository<Author>,
    @InjectRepository(Book)
    private readonly booksRepository: Repository<Book>,
  ) {}

  async seed(): Promise<void> {
    // 100人の著者、各著者に10冊の書籍を登録
    for (let i = 1; i <= 100; i++) {
      const author = this.authorsRepository.create({
        name: `Author ${i}`,
        email: `author${i}@example.com`,
      });
      await this.authorsRepository.save(author);

      const books = [];
      for (let j = 1; j <= 10; j++) {
        books.push(
          this.booksRepository.create({
            title: `Book ${j} by Author ${i}`,
            isbn: `978-${String(i).padStart(4, '0')}-${String(j).padStart(4, '0')}`,
            author,
          }),
        );
      }
      await this.booksRepository.save(books);
    }
  }
}

パフォーマンス比較の実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// src/performance/performance.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Author } from '../authors/entities/author.entity';

@Injectable()
export class PerformanceService {
  private readonly logger = new Logger(PerformanceService.name);

  constructor(
    @InjectRepository(Author)
    private readonly authorsRepository: Repository<Author>,
  ) {}

  async comparePerformance(): Promise<void> {
    // N+1問題が発生するパターン
    const startN1 = Date.now();
    const authorsN1 = await this.authorsRepository.find();
    let totalBooksN1 = 0;
    for (const author of authorsN1) {
      // booksがLazy Loadingの場合、ここで追加クエリが発生
      const books = await Promise.resolve(author.books);
      totalBooksN1 += books?.length ?? 0;
    }
    const endN1 = Date.now();
    this.logger.log(`N+1パターン: ${endN1 - startN1}ms, 書籍総数: ${totalBooksN1}`);

    // QueryBuilderで最適化したパターン
    const startOptimized = Date.now();
    const authorsOptimized = await this.authorsRepository
      .createQueryBuilder('author')
      .leftJoinAndSelect('author.books', 'book')
      .getMany();
    let totalBooksOptimized = 0;
    for (const author of authorsOptimized) {
      totalBooksOptimized += author.books?.length ?? 0;
    }
    const endOptimized = Date.now();
    this.logger.log(
      `最適化パターン: ${endOptimized - startOptimized}ms, 書籍総数: ${totalBooksOptimized}`,
    );
  }
}

期待される結果

パターン クエリ数 実行時間(目安)
N+1問題あり 101回 500-1000ms
JOIN FETCH使用 1回 50-100ms

実際のパフォーマンス改善効果は、データ量やネットワーク遅延によって異なりますが、N+1問題を解決することで10倍以上の高速化が期待できます。

実践的なAPIエンドポイントの実装例

学んだ内容を活かして、実践的なAPIエンドポイントを実装します。

DTOの定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// src/authors/dto/author-response.dto.ts
export class BookSummaryDto {
  id: number;
  title: string;
  isbn: string;
}

export class AuthorWithBooksDto {
  id: number;
  name: string;
  email: string;
  books: BookSummaryDto[];
}

Serviceの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// src/authors/authors.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Author } from './entities/author.entity';
import { AuthorWithBooksDto, BookSummaryDto } from './dto/author-response.dto';

@Injectable()
export class AuthorsService {
  constructor(
    @InjectRepository(Author)
    private readonly authorsRepository: Repository<Author>,
  ) {}

  async findAllWithBooks(): Promise<AuthorWithBooksDto[]> {
    const authors = await this.authorsRepository
      .createQueryBuilder('author')
      .leftJoinAndSelect('author.books', 'book')
      .orderBy('author.name', 'ASC')
      .addOrderBy('book.title', 'ASC')
      .getMany();

    return authors.map((author) => this.toAuthorWithBooksDto(author));
  }

  async findOneWithBooks(id: number): Promise<AuthorWithBooksDto> {
    const author = await this.authorsRepository
      .createQueryBuilder('author')
      .leftJoinAndSelect('author.books', 'book')
      .where('author.id = :id', { id })
      .orderBy('book.title', 'ASC')
      .getOne();

    if (!author) {
      throw new NotFoundException(`Author with ID ${id} not found`);
    }

    return this.toAuthorWithBooksDto(author);
  }

  private toAuthorWithBooksDto(author: Author): AuthorWithBooksDto {
    return {
      id: author.id,
      name: author.name,
      email: author.email,
      books: author.books?.map((book) => this.toBookSummaryDto(book)) ?? [],
    };
  }

  private toBookSummaryDto(book: { id: number; title: string; isbn: string }): BookSummaryDto {
    return {
      id: book.id,
      title: book.title,
      isbn: book.isbn,
    };
  }
}

Controllerの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// src/authors/authors.controller.ts
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
import { AuthorsService } from './authors.service';
import { AuthorWithBooksDto } from './dto/author-response.dto';

@Controller('authors')
export class AuthorsController {
  constructor(private readonly authorsService: AuthorsService) {}

  @Get()
  async findAll(): Promise<AuthorWithBooksDto[]> {
    return this.authorsService.findAllWithBooks();
  }

  @Get(':id')
  async findOne(@Param('id', ParseIntPipe) id: number): Promise<AuthorWithBooksDto> {
    return this.authorsService.findOneWithBooks(id);
  }
}

循環参照とシリアライズの問題への対処

双方向リレーションをJSONにシリアライズする際、循環参照が発生する場合があります。

問題の発生

1
2
3
4
5
6
// Authorが参照するBookがAuthorを参照する → 無限ループ
const author = await authorsRepository.findOne({
  where: { id: 1 },
  relations: { books: true },
});
console.log(JSON.stringify(author)); // エラーまたは無限ループ

解決策1: DTOへの変換

エンティティをそのまま返さず、必要なフィールドのみを含むDTOに変換します(前述の例を参照)。

解決策2: class-transformerの@Exclude()

1
2
3
4
5
6
7
8
import { Exclude } from 'class-transformer';

@Entity('books')
export class Book {
  @ManyToOne(() => Author, (author) => author.books)
  @Exclude() // JSONシリアライズ時に除外
  author: Author;
}

解決策3: toJSON()メソッドのオーバーライド

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Entity('books')
export class Book {
  // ...フィールド

  toJSON() {
    return {
      id: this.id,
      title: this.title,
      isbn: this.isbn,
      authorId: this.author?.id,
      // authorオブジェクト全体は含めない
    };
  }
}

まとめ

本記事では、NestJSとTypeORMにおけるエンティティ間リレーションシップの設計と、N+1問題の解決方法を解説しました。

主なポイントを振り返ります。

  1. リレーションデコレータ: @OneToMany@ManyToOneで双方向関連を定義
  2. ヘルパーメソッド: 双方向リレーションの整合性を保つためのメソッドを実装
  3. フェッチ戦略: eager: trueは避け、必要に応じて明示的にリレーションを取得
  4. N+1問題の解決: relationsオプションまたはQueryBuilderのleftJoinAndSelectを使用
  5. パフォーマンス: JOINを使用することで10倍以上の高速化が期待できる

複雑なデータ構造を持つAPIを効率的に実装するために、これらのテクニックを活用してください。

参考リンク