Fastify ベストプラクティス #3: データベースアクセス

公開日 2025年6月22日
更新日 2025年6月26日
30 分で読める
TypeScript

現代のWebアプリケーションにおいて、データベース層は名実ともに「エンジンルーム」です。そのパフォーマンス、安定性、そして保守性は、アプリケーション全体の能力の上限を直接決定します。Node.jsとTypeScriptが主流となっている今日、私たちは古参のTypeORM、新進気鋭のPrisma、そして究極の型安全性を追求するKyselyなど、数多くの優れたデータベースツールを手にしています。

どのツールを選択するかは、開発効率に影響を与えるだけでなく、本番環境でのアプリケーションの振る舞い、特に問題のトラブルシューティングやパフォーマンス最適化に深く関わってきます。本記事では、これら3つのツールを徹底的に分析し、見過ごされがちでありながら極めて重要な基準である「可観測性(Observability)」に基づき、なぜ私が最終的にFastifyプロジェクトの第一選択としてKyselyを推奨するのかを明らかにします。

3つの主要ツールの横断比較

まず、TypeORM、Prisma、Kyselyの主な特徴と違いを一覧表で直感的に理解しましょう。

項目 TypeORM Prisma Kysely
データモデル定義 コードファースト (Code-First):TypeScriptのクラスとデコレータ(@Entity, @Column)を使用してデータモデルを定義します。コードが唯一の信頼できる情報源(Source of Truth)です。 スキーマファースト (Schema-First):専用の.prismaファイル内で独自のSDL(Schema Definition Language)を用いてモデルを定義します。このファイルが唯一の信頼できる情報源です。 モデル管理なし:データモデルを直接管理しません。提供されたTypeScriptのinterfaceを通じてデータベースの構造を認識し、完璧な型ヒントを提供します。
型安全性 良好だが抜け穴あり。基本的なCRUD操作は型安全ですが、複雑なリレーション、ネイティブクエリ、またはQueryBuilderの特定のメソッドチェーンを扱う際に型推論が失われ、any型が返されがちです。 究極。クライアントは.prismaモデルに基づいて自動生成され、フィールドの選択やネストされたリレーションを含む、書くすべてのクエリで正確なエンドツーエンドの型安全性が保証されます。 究極。型安全性はクエリの構造そのものから直接生まれます。selectしたフィールドやjoinしたテーブルに応じて、返り値のTypeScript型が曖昧さなく正確に推論されます。
データベースマイグレーション 組み込み。エンティティ(Entity)の変更に基づいてマイグレーションファイルを自動生成できます(migration:generate)。しかし、複雑なプロジェクトでは、自動生成されたファイルは手動での確認や調整が必要なことが多く、問題が発生することもあります。 組み込みかつ非常に強力prisma migrate devコマンドはスムーズな開発体験を提供し、.prismaファイルの変更に基づいてSQLマイグレーションファイルを確実に生成・適用できます。これはPrismaの核心的な利点として広く認識されています。 非組み込み、公式ツールが必要。マイグレーション管理には、公式が提供するkysely-ctlツールを別途使用する必要があります。機能は充実していますが、追加の設定が必要です。
学習コスト 中程度。ORMの多くの概念(エンティティ、リポジトリ、データマッパー、アクティブレコードパターン、N+1問題など)を理解する必要があり、API体系も広大です。 低い。APIは非常に直感的で、うまく設計されています。GUIツールのPrisma Studioにより、データの閲覧や操作が非常に簡単になり、入門のハードルを大幅に下げています。 低い(SQLを理解している場合) / 高い(SQLを理解していない場合)。APIがSQLステートメントとほぼ1対1で対応しているため、SQLに詳しければ非常に自然で強力だと感じられます。そうでなければ、まずSQLを学ぶ必要があります。
クエリの制御とパフォーマンス 中程度。高度な抽象化により、最適でないSQLクエリが生成されることがあります。N+1クエリ問題が発生しやすく、開発者が意識的にrelationsQueryBuilderを使用して最適化する必要があります。 高い。API設計が多くのORMでよくある落とし穴を巧みに回避しています。例えば、.include.selectによってクエリ範囲を正確に制御できます。生成されるSQLは通常、予測可能で効率的です。 最高。KyselyはSQLを型でラップしたものに過ぎないからです。複雑なJOIN、サブクエリ、CTE、ウィンドウ関数など、あらゆるクエリを作成できます。パフォーマンスの上限も下限も、完全にあなたのSQLスキルに依存します。

重要な考慮事項:なぜ最終的にPrismaを諦めたのか?

上記の比較を見ると、多くの人がPrismaに傾くでしょう。学習コストが低く、マイグレーションツールは強力で、型安全性も究極のレベルに達しており、「現代のORM」の模範と言えます。

しかし、本番環境では、一見些細な設計上の選択が、巨大なメンテナンスコストにつながることがあります。Prisma(バージョン6.9.0時点)には、まさにそのような問題が存在します。それは「ロギングと非同期コンテキストの分断」です。

保守性の高いバックエンドサービスを構築する際、「可観測性(Observability)」は中心的な概念です。システムに入るすべてのHTTPリクエストに、一意の「トレースID(Trace ID)」を付与することが望まれます。リクエストのライフサイクル全体、つまりコントローラへの到達から、サービスの呼び出し、データベースクエリに至るまで、生成されるすべてのログにこのIDが含まれるべきです。これにより、膨大なログの中から特定のリクエストの全足跡を正確に追跡でき、オンラインでの問題を迅速に特定・解決するための生命線となります。

Node.jsでは、通常AsyncLocalStorageを使用して、非同期呼び出しチェーン全体でこのようなコンテキスト情報を伝搬させます。しかし、Prismaのロギングシステムはここで問題に突き当たります。

// Prismaのログ購読方法
const prisma = new PrismaClient({
  log: [{ emit: 'event', level: 'query' }],
});

prisma.$on('query', (e) => {
  // このコールバック関数はNode.jsのEvent Emitterに基づいてトリガーされる
  // 独立した非同期コンテキストで実行されるため、
  // 現在のHTTPリクエストのAsyncLocalStorageにアクセスできない
  const traceId = getTraceIdFromContext(); // これはundefinedを返す

  console.log(`[TraceID: ${traceId}] Query: ${e.query}`); // TraceIDはundefinedになる
  console.log('Params: ' + e.params);
  console.log('Duration: ' + e.duration + 'ms');
});

$onメソッドはNode.jsのEventEmitterに基づいており、そのイベントコールバックはリクエストの非同期フローから独立しています。これは、PrismaがSQLを実行しqueryイベントを発行した時点で、元のリクエストのコンテキストがすでに失われていることを意味します。SQLログにトレースIDを注入できないことは、本格的で観測可能な本番システムを構築する上で、受け入れがたい妥協です。

このため、私はPrismaがもたらす多くの利便性を喜んで手放すことにしました。

Kysely:TypeScriptとSQL職人のための究極のツール

そこで自然と主役として登場するのがKyselyです。

KyselyはTypeORMやPrismaのような完全なORMではなく、型安全なSQLクエリビルダ(Query Builder)です。この位置付けが、前述の問題をすべて見事に解決します。

  1. シームレスなログ追跡:そのログ出力はEventEmitterを使用していないため、AsyncLocalStorageと互換性があります。
  2. 究極のパフォーマンスと制御:KyselyはあなたとSQLとの間の、薄く型安全な「接着剤」に過ぎません。書いたコードはほぼそのまま実行されるSQLとなり、隠れたパフォーマンスのオーバーヘッドや魔法のような挙動はありません。パフォーマンスは完全にあなたのSQLスキル次第です。
  3. 真の型安全性:その型推論能力は驚異的です。返り値の型は、固定されたエンティティモデルからではなく、あなたのselectjoinといった操作に基づいて動的に計算されます。

したがって、コードの品質、アプリケーションのパフォーマンス、そして長期的な保守性を追求するチームにとって、Kyselyがより優れた選択であると私は確信しています。

実践ガイド:FastifyにKyselyを統合する

それでは、Kyselyをプロジェクトに一歩ずつ統合していきましょう。

1. インストールと型定義

まず、Kyselyと対応するデータベースドライバ(ここではPostgreSQLを例にとります)をインストールします。

npm i kysely pg

次に、データベースの型定義ファイルを作成します。これがKyselyの型マジックの核心です。

// src/database/types/user.type.ts
import { CamelCasePlugin, Kysely, ParseJSONResultsPlugin, ColumnType, PostgresDialect, Pool } from 'kysely';

// `user_tbl` テーブルのインターフェースを定義
export interface UserTbl {
  id: ColumnType<string, string, never>;
  username: string;
  address: string;
  email: string;
  birthday: ColumnType<Date, Date | string, Date | string>;
  // データベースではtimestamptz型だが、コード上ではDate型として扱いたい
  createdAt: ColumnType<Date, Date | string | undefined, never>;
  updatedAt: ColumnType<Date | null, Date | string | null, Date>;
}

ColumnType<Select, Insert, Update> の役割を説明します:

  • Select: データベースから読み取る際の列の型(例:createdAtはDate)
  • Insert: データを挿入する際に受け入れ可能な型(例:birthdayはDateオブジェクトまたはISO文字列)
  • Update: データを更新する際に受け入れ可能な型(例:idやcreatedAtは通常ユーザーが更新時に提供しないためnever)
// src/database/types/index.ts
import { UserTbl } from './user.type';

// データベース全体の構造
export interface Database {
  userTbl: UserTbl;
}
// src/database/index.ts
import { Database } from './types';
import { CamelCasePlugin, Kysely, ParseJSONResultsPlugin, PostgresDialect, Pool } from 'kysely';

const dialect = new PostgresDialect({
  pool: new Pool({
    database: process.env.DB_NAME,
    host: process.env.DB_HOST,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    port: Number(process.env.DB_PORT),
    max: 10,
  }),
});

// Kyselyインスタンスを作成
export const db = new Kysely<Database>({
  dialect,
  plugins: [
    new CamelCasePlugin(), // スネークケース(snake_case)のカラム名を自動的にキャメルケース(camelCase)に変換
    new ParseJSONResultsPlugin(), // JSON文字列を自動的にパース
  ],
});

クエリの例:

const result = await db.selectFrom('userTbl').selectAll().where('id', '=', id).executeTakeFirstOrThrow();

2. kysely-ctl を用いたデータベースマイグレーション

データベース構造の変更を体系的に管理するために、マイグレーションツールが必要です。

npm i -D kysely-ctl dotenv-cli

package.jsonscriptsにコマンドを追加します。

"scripts": {
  "db:init": "kysely-ctl init",
  "db:migrate:make": "kysely-ctl migrate make",
  "db:migrate:latest": "dotenv -e .env -- kysely-ctl migrate latest",
  "db:migrate:down": "dotenv -e .env -- kysely-ctl migrate down",
  "db:seed:make": "kysely-ctl seed make",
  "db:seed:run": "dotenv -e .env -- kysely-ctl seed run"
}

注意:ここではdotenv-cliを使用して環境変数を読み込み、コマンド名を一般的な慣習に合わせて簡略化しています。

A. 初期化

npm run db:initを実行すると、.config/kysely.config.tsファイルが生成されます。これを設定します。

// kysely.config.ts
import { PostgresDialect } from 'kysely';
import { defineConfig } from 'kysely-ctl';
import { Pool } from 'pg';

export default defineConfig({
  dialect: new PostgresDialect({
    pool: new Pool({
      database: process.env.DB_NAME,
      host: process.env.DB_HOST,
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      port: Number(process.env.DB_PORT),
      max: 10,
    }),
  }),
  migrations: {
    migrationFolder: '.config/migrations',
  },
  seeds: {
    seedFolder: '.config/seeds',
  },
});

B. マイグレーションファイルの作成

npm run migrate:make -x=ts init_tblを実行すると、.config/migrationsディレクトリにタイムスタンプ付きのマイグレーションファイルが生成されます。

ヒント:Kysely<any>は必須です。マイグレーションファイルは特定の時点でのデータベースのスナップショットであり、将来変更される可能性のあるDatabaseインターフェースに依存すべきではないためです。

// <timestamp>_init_tables.ts
import { Kysely, sql } from 'kysely';

export async function up(db: Kysely<any>): Promise<void> {
  await db.schema
    .createTable('user_tbl') // テーブル名とカラム名にはスネークケースの使用を推奨します
    .addColumn('id', 'uuid', (col) => col.primaryKey())
    .addColumn('username', 'varchar(100)', (col) => col.notNull())
    .addColumn('address', 'varchar(255)', (col) => col.notNull())
    .addColumn('email', 'varchar(100)', (col) => col.notNull().unique())
    .addColumn('birthday', 'timestamptz', (col) => col.notNull())
    .addColumn('created_at', 'timestamptz', (col) => col.defaultTo(sql`now()`).notNull())
    .addColumn('updated_at', 'timestamptz')
    .execute();
}

export async function down(db: Kysely<any>): Promise<void> {
  await db.schema.dropTable('user_tbl').execute();
}

C. マイグレーションの実行

npm run db:migrate:latestを実行すると、Kyselyがデータベースにこのテーブルを作成します。

3. kysely-ctl を用いたシードデータの投入

A. Seedファイルの作成

npm run seed:makeを実行し、シードファイルを作成します。

B. Seedロジックの記述

// <timestamp>_init_user.ts
import type { Database } from '@/database/types';
import type { Kysely } from 'kysely';

// DBの型をインポート

export async function seed(db: Kysely<Database>): Promise<void> {
  const user1Id = '123e4567-e89b-12d3-a456-426614174000';

  await db
    .insertInto('userTbl')
    .values({
      id: user1Id,
      username: 'Shukang',
      email: 'shukang@example.com',
      address: '123 Main St',
      birthday: new Date('1990-01-01T00:00:00Z'),
    })
    .execute();
}

C. Seedの実行

npm run db:seed:runを実行すると、初期データがデータベースに正常に挿入されます。

CRUDのサンプル

より完全なCRUD(作成、読み取り、更新、削除)操作については、オープンソースプロジェクトにまとめていますので、参考にしてください。

fastify-best-practice on GitHub

結論

データベースツールの選択は、戦略的な意味合いを持つ技術選定です。Prismaは比類なき開発者体験を提供し、新規プロジェクトや内部システムの迅速な立ち上げに非常に適しています。しかし、プロジェクトが成熟し、本番環境の複雑な課題に直面する際には、可観測性低レベルな制御力が特に重要になります。

Kyselyはまさにそのようなシナリオのために生まれました。それは開発者を信頼し、力付け、完全な型安全性を享受しながら、最も効率的で制御可能なSQLクエリを作成できるようにします。KyselyはあなたにSQLへの深い理解を求めますが、それ自体が価値ある投資です。Kyselyを採用することで、あなたは単なるツールを選ぶだけでなく、より厳格で、効率的で、制御性の高いバックエンド開発哲学を選択することになるのです。

概要

技術的洞察、経験、思考を共有する個人ブログ

クイックリンク

お問い合わせ

  • Email: hushukang_blog@proton.me
  • GitHub

© 2025 CODE赤兎. 無断転載禁止