Drizzle ORMでスキーマとドメインモデルをどう対応させるか

Drizzle ORMはTypeScriptの型推論を活かしてSQLクエリを書けるライブラリで、AlcogyではCloudflare D1と組み合わせて全プロジェクトで採用しています。ただし、Drizzleのスキーマ定義はあくまでDBのテーブル構造を表すものであり、そのままアプリケーションのドメインモデルとして使うと、業務ロジックとDBの都合が混ざり合ってしまいます。ここではAlcogyがDrizzleスキーマとドメインモデルをどう対応させているかを整理します。

スキーマ定義はDBの都合、ドメインモデルは業務の都合

例えば「顧客」を表すテーブルを考えます。

// db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const customers = sqliteTable('customers', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull(),
  tier: text('tier', { enum: ['standard', 'premium', 'vip'] })
    .notNull()
    .default('standard'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});

このスキーマから推論される型(typeof customers.$inferSelect)をそのままドメイン層で使ってしまうと、「emailの形式が正しいか」「tierをVIPに昇格できる条件は何か」といった業務ルールを書く場所がなくなり、結局UIのコンポーネントやAPIハンドラに散らばってしまいます。Alcogyでは、DrizzleのRow型を「DBとの受け渡し用の型」と割り切り、ドメインロジックを持つクラスや関数を別に用意する方針を取っています。

Row型からドメインモデルへの変換を1箇所に集約する

// domain/customer.ts
import type { customers } from '../db/schema';

type CustomerRow = typeof customers.$inferSelect;

export class Customer {
  private constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: string,
    public readonly tier: 'standard' | 'premium' | 'vip'
  ) {}

  static fromRow(row: CustomerRow): Customer {
    return new Customer(row.id, row.name, row.email, row.tier);
  }

  canUpgradeToVip(totalPurchaseAmount: number): boolean {
    return this.tier === 'premium' && totalPurchaseAmount >= 500_000;
  }
}
// application/upgradeCustomer.ts
import { db } from '../db/client';
import { customers } from '../db/schema';
import { eq } from 'drizzle-orm';
import { Customer } from '../domain/customer';

export async function tryUpgradeToVip(customerId: string, totalPurchaseAmount: number) {
  const row = await db.query.customers.findFirst({ where: eq(customers.id, customerId) });
  if (!row) throw new Error('customer not found');

  const customer = Customer.fromRow(row);
  if (!customer.canUpgradeToVip(totalPurchaseAmount)) {
    return { upgraded: false as const };
  }

  await db.update(customers).set({ tier: 'vip' }).where(eq(customers.id, customerId));
  return { upgraded: true as const };
}

fromRowのような変換関数を用意することで、DBのカラム名や型がそのままドメインロジックに漏れ出すのを防いでいます。逆にドメインモデルからDBへ書き込む際も、更新用のプレーンオブジェクトを組み立てる変換を挟み、ドメインオブジェクトのインスタンスをDrizzleのクエリビルダーに直接渡さないようにしています。

enum・型安全性はDrizzleに寄せる

一方で、text('tier', { enum: [...] })のようなDrizzleのenum機能は積極的に活用しています。DB側とアプリケーション側で許容値の定義が二重管理にならず、マイグレーション時にもTypeScriptの型チェックで不整合に気づけるためです。バリデーションの「入り口」はDrizzleの型に任せ、「業務ルールとしての妥当性」はドメイン層に任せるという役割分担にしています。

責務分離のラインをどこに引くか

Drizzleは型安全性が高いぶん、ついスキーマの型をそのままアプリケーション全体で使い回したくなります。しかし、それを続けるとテーブル構造の変更がドメインロジックの変更を強制するようになり、業務システムのように仕様変更が頻繁なプロジェクトでは保守コストが上がります。AlcogyではRow型とドメインモデルの変換ポイントを明示的に用意することで、DBスキーマの変更とビジネスルールの変更を独立して行えるようにしています。