ORMの技術選定で迷ったらPoEAAの設計パターンを思い出す
TypeScriptのプロジェクトでORMを選定するとき、選択肢の多さに困った経験はないでしょうか。Prisma、TypeORM、Drizzle、MikroORM、Sequelize…それぞれ人気があり、どれを選んでも動くものは作れます。
しかし「なんとなく流行っているから」で選んでみて後になって「思っていたのと違う」となるのは避けたいですね。ORMごとに設計思想が異なり、向いているユースケースも違います。
ORMを雰囲気ではなく理由で選ぶために、ORMの設計パターンを理解しましょう。
本記事はMartin FowlerのPoEAAで紹介されている4つのパターンを振り返り、各ORMが「なぜこういう設計になっているのか」が分かり、プロジェクトに合った技術選定をサポートすることを狙います。
PoEAAとは
PoEAA(Patterns of Enterprise Application Architecture)は、Martin Fowlerが2002年に出版した書籍です。エンタープライズアプリケーション開発における設計パターンを体系的にまとめたもので、20年以上経った今でも多くのフレームワークやライブラリの設計に影響を与えています。
この本には多くのパターンが収録されていますが、今回はORMに直接関係する4つのパターンに絞って紹介します。
4つのパターンを理解する
Active Record
ドメインオブジェクト自身がデータベースアクセスのロジックを持つパターンです。Ruby on RailsのActiveRecordが代表例で、多くの開発者にとって馴染み深いパターンでしょう。
// TypeORM(Active Recordモード)の例
@Entity()
class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@Column()
email: string
}
// エンティティ自身がfindやsaveのメソッドを持つ
const user = await User.findOne({ where: { id: 1 } })
user.name = "Miki Nonaka"
await user.save()
メリット
- 直感的で学習コストが低い
- コード量が少なくて済む
- シンプルなCRUD操作に適している
デメリット
- ドメインロジックとDBアクセスが密結合になる
- テストでDBのモックが必要になりやすい
- 複雑なドメインロジックには向かない
向いているケース: 管理画面、シンプルなCRUD中心のアプリケーション、プロトタイプ開発
Data Mapper
ドメインオブジェクトとデータベースを完全に分離し、専用のマッパー(またはリポジトリ)が変換を担当するパターンです。
// MikroORMの例
@Entity()
class User {
@PrimaryKey()
id: number
@Property()
name: string
@Property()
email: string
// ドメインロジックはここに書くが、DB操作のメソッドは持たない
changeName(newName: string) {
if (newName.length < 2) {
throw new Error('名前は2文字以上必要です')
}
this.name = newName
}
}
// EntityManagerやRepositoryが操作を担当
const user = await em.findOne(User, { id: 1 })
user.changeName("Maria Makino")
await em.flush() // 変更をDBに反映(Unit of Workでまとめて永続化)
メリット
- ドメインモデルがDBを意識しない
- テストしやすい(ドメインロジックを単体でテスト可能)
- 複雑なドメインロジックを表現しやすい
- DDDとの相性が良い
デメリット
- コード量が増える
- 学習コストが高い
- シンプルなCRUDには過剰になりがち
向いているケース: 複雑なビジネスロジックを持つアプリケーション、ドメイン駆動設計を採用するプロジェクト
Table Data Gateway
テーブルごとにゲートウェイクラスを用意し、そこにCRUD操作をまとめるパターンです。戻り値はドメインオブジェクトではなく、レコードセットやプレーンなオブジェクトになります。
// Prismaの例
// Prisma Clientがテーブル単位の操作を提供
const user = await prisma.user.findUnique({
where: { id: 1 }
})
// userはUserエンティティではなく、プレーンなオブジェクト
await prisma.user.update({
where: { id: 1 },
data: { name: "Sakura Oda" }
})
メリット
- シンプルで理解しやすい
- SQLに近い感覚で操作できる
- 型安全性を確保しやすい
デメリット
- ドメインロジックの置き場所を別途考える必要がある(サービス層に寄せる、ドメイン層を別途作る等)
- 複雑なオブジェクトグラフの操作には向かない
向いているケース: CRUD中心だが型安全性が欲しいアプリケーション、サービス層でビジネスロジックを実装する設計
Row Data Gateway
テーブルの1行を表すオブジェクトがDB操作メソッドを持つパターンです。Active Recordに似ていますが、通常はドメインロジック(業務ルール)を持たず、あくまで「行のラッパー」に徹します。
// 概念的な例(このパターンを純粋に実装したTypeScript ORMは少ない)
class UserRow {
id: number
name: string
email: string
async update(): Promise<void> {
await db.query(
'UPDATE users SET name = ?, email = ? WHERE id = ?',
[this.name, this.email, this.id]
)
}
async delete(): Promise<void> {
await db.query('DELETE FROM users WHERE id = ?', [this.id])
}
}
Active Recordとの違いは、「ドメインロジックをどこに置くか」です。Active Recordは「ドメイン+永続化」が同居しやすい一方、Row Data Gatewayは「永続化だけ」を担当し、業務ルールは別の層(ドメイン層やサービス層)に置きます。
一方で現代のORMは、Active Recordのように体験を単純化するか、Data Mapperのように分離を徹底するか、どちらかに振り切る傾向が強いです。そのためRow Data Gatewayは中間形態として扱われやすく、TypeScriptの主要ORMで前面に出てくることは多くありません。
TypeScript ORMの分類
ここまでの4パターンを踏まえて、主要なTypeScript ORMを分類してみます。
| ORM/ライブラリ | パターン | 特徴 |
|---|---|---|
| TypeORM(Active Recordモード) | Active Record | Rails風、エンティティが操作メソッドを持つ |
| Sequelize | Active Record | 10年以上歴史あるORM、マイグレーション機能が充実 |
| TypeORM(Data Mapperモード) | Data Mapper | リポジトリパターン、DDDと相性が良い |
| MikroORM | Data Mapper | Unit of Work、Identity Map完備、本格的なDDD向け |
| Prisma | Table Gateway寄り | スキーマ駆動、型安全性が高い |
| Drizzle | Table Gateway寄り | 軽量、SQLに近い記法、型安全 |
| Knex / Kysely[1] | Query Builder | ORMというよりクエリビルダ |
この分類を見ると、各ORMの設計意図が見えてきます。
- TypeORMやSequelizeは、Rails的な開発体験を実現しようとしている
- MikroORMは、Hibernateのような本格的なData Mapperを目指している
- PrismaやDrizzleは、型安全性とシンプルさを重視し、独自のアプローチを取っている
選定の判断軸
パターンを理解した上で、どのORMを選ぶべきかの判断軸を整理します。
ドメインロジックの複雑さ
複雑なビジネスロジックがあり、DDDを採用するならData Mapper系(MikroORM、TypeORM Data Mapperモード) が向いています。
反対にシンプルなCRUD中心なら Active Record系やTable Gateway系で十分要件を満たすでしょう。
型安全性の重要度
TypeScriptを使う以上、型安全性は重視したいところです。
PrismaとDrizzleは型推論が強力で、スキーマから自動生成される型により安全なコードが書けます。
SQLの制御粒度
パフォーマンスチューニングのために生SQLに近い制御が必要ならDrizzleやKyselyが適しています。
SQLを意識せずに開発したいケースはPrismaが適していると思われます。
既存の開発体験との親和性
RailsのActiveRecordに慣れているチームなら、マイグレーションの仕組みが充実しているORMを選ぶとスムーズです。Prismaはスキーマファイルからマイグレーションを生成する点で、スキーマ駆動の体験に寄せられます。
チームの学習コスト
Data Mapper系は学習コストが高めです。チームのスキルセットや、新メンバーのオンボーディングを考慮して選ぶ必要があります。
ORMを使わない選択肢
ORMについて述べてきましたが、ソフトウェア開発において必ずORMを使わなくてはいけない決まりはありません。ORMを使わないで開発するにはどうすればいいかも考えてみます。
Query Builderのみを使う
KnexやKyselyはPoEAAのパターンに直接対応しませんが、十分に有力な候補になると思います。
// Kyselyの例
const users = await db
.selectFrom('users')
.where('status', '=', 'active')
.orderBy('created_at', 'desc')
.limit(10)
.execute()
SQLの構造を保ちながら型安全性を得られます。オブジェクトへのマッピングやリレーション管理は自前で行う必要がありますが、その分クエリの挙動が予測しやすくなります。
生SQL + 型定義
最もシンプルなアプローチです。
// pg + 手動型定義の例
interface User {
id: number
name: string
email: string
}
const result = await pool.query<User>(
'SELECT id, name, email FROM users WHERE id = $1',
[userId]
)
const user = result.rows[0]
sqlcやPgTypedを使えば、SQLファイルから型を自動生成することもできます。
// PgTypedの例:SQLファイルから型が生成される
import { findUserById } from './queries/users.queries'
const user = await findUserById.run({ id: 1 }, pool)
// userの型はSQLのSELECT句から推論される
ORMを使わなくてもよさそうなケース
- 読み取り中心: 分析ダッシュボードやレポート生成など複雑な集計クエリが多い
- パフォーマンス超重要: ORMが生成するSQLよりも生のSQLで表現したほうがパフォーマンスがよくなることはよくあります
- スキーマが頻繁に変わらない: ORMのマイグレーション機能やスキーマ同期の恩恵が薄い
- 生のSQLのほうが扱い慣れていて、ORMに縛られるのがつらい
ORMの恩恵を受けられそうなケース
- CRUD操作でリレーションを多用する
- マイグレーション管理を統一したい
- N+1の検出やEager LoadingをORMに任せたい
さいごに
私はORMを利用しているアプリケーションも、していないアプリケーションも業務で扱った経験があります。
いずれにせよ、SQLを読み書きでき、ボトルネックを特定し、スロークエリがある場合には実行計画を読んでインデックスを適切に利用し非効率なSQLの記述を修正する能力が求められると思っています。
本記事が、今置かれている状況と照らし合わせて最適な選択ができる一助となれば幸いです。
-
KnexとKyselyは「テーブルや行を表すオブジェクトが永続化を担う」という意味でのORMパターン(PoEAAの分類)に綺麗には乗りません。アプリ側がデータアクセスの設計(どこにロジックを置くか、トランザクション境界をどうするか)を決める自由度が高い代わりに、責務分割を自前で整える必要があります。 ↩︎
Discussion
設計パターンからORMを整理する視点が分かりやすく、参考になりました。
一点だけ、Active Recordの「テストでDBのモックが必要になりやすい」という表現が気になりました。
DBに依存する処理はテストDBで確認する方法もあるので、ここは「ドメインロジックをDBから切り離して単体テストしにくくなりやすい」という意味でしょうか。
もしそうであれば、その表現の方がデメリットを正確に伝えられるように思いました。