AIエージェント在りし今、ORMは窓から投げ捨てろ
Codexでの一連の作業(Drizzle ORMを除いてSQLだけを使うようにした)を振り返り、煽り記事をAIに書いてもらいました。読みにくいと思うので、AIに要約させてください。どうぞ!
AIエージェント在りし今、ORMは窓から投げ捨てろ
ORMを使う理由のかなりの部分は、「SQLを人間が安全に書き続けるのはつらい」という現実への対処だった。
テーブル定義から型を作り、条件を組み立て、プレースホルダーを管理し、結果をアプリケーションの型へ変換する。変更のたびにSQLとTypeScriptの整合性を人間が確認するくらいなら、その面倒をライブラリへ押し込んだほうがよい。これは長い間、きわめて合理的な判断だった。
しかし、コードベースを横断してSQL、型、テスト、ドキュメントを同時に更新できるAIエージェントが実用になった今、その前提は少し変わったのではないか。
今回、SvelteKitをCloudflare Workers上で動かし、TiDB Serverlessを永続化先とするアプリケーションからDrizzle ORMを段階的に撤去した。結論から言えば、ORMが担っていた安全性を捨てたわけではない。安全性を「production実行時の抽象化」から「SQLスキーマ、明示的な型、実DBを使う契約テスト、エージェントの作業規約」へ移した。
この記事では、なぜ撤去したのか、AIエージェントが何を変えたのか、実際にどの順序で移行したのか、そしてエージェントへどのようなルールを与えたのかをまとめる。
ORMを外したかった理由
発端は、認証済みリクエストのたびに呼ばれる getUserFromId だった。この関数はセッション内のIDから利用者を取得するだけだが、そのためにORMを経由していた。
ORMと直接SQLの差をローカルで測ると、劇的な差ではない。ネットワーク越しのDBアクセスでは通信時間のほうが支配的になりやすく、ORMだけを外せば何十ミリ秒も速くなる、という話ではなかった。
それでも、このアプリケーションの実行環境はCPU時間を意識する必要があるCloudflare Workersだ。ローカルでは誤差に見える処理でも、productionでは1ミリ秒前後の差になる可能性がある。しかも getUserFromId は一部の重い管理処理ではなく、広い範囲のリクエストが通る経路にある。小さな固定費ほど、ホットパスでは無視しにくい。
もう一つの理由は、コードベースを調べるとORMの高度な機能をあまり使っていなかったことだ。すでに生SQLが混在し、複雑な集計や認可条件はSQLで表現していた。ORMは永続化層全体を統一する中心的な抽象化というより、単純なCRUDの一部を包む依存になっていた。
つまり、負担しているものは次のようになっていた。
- Worker bundleへ入る依存と初期化コスト
- ORMのスキーマ定義と実DBスキーマを二重管理するコスト
- 生成されるSQLを推測または確認する認知負荷
- 生SQLとORMが混在することで生じる二つの流儀
一方、得ていた恩恵は限定的だった。このバランスなら、直接SQLへ寄せる実験には十分な理由がある。
AIエージェントが変えたのは「SQLを書くコスト」だけではない
手書きSQLへの典型的な反論は正しい。列名を間違える。引数の順序を間違える。nullableな列をnullableでない型として扱う。mutationの件数を確認し忘れる。transactionの途中で別connectionを使う。そして、スキーマ変更とクエリ変更が簡単にずれる。
AIエージェントも、放っておけば同じ間違いをする。むしろ大量のコードを短時間で変更できるため、規約がなければ間違いを広範囲へ複製する危険がある。
重要なのは、AIがSQLを生成できることではない。AIに次の一連の作業を毎回要求できることだ。
- 正本のSQLスキーマを読む
- クエリと専用の結果型を同じdata-access moduleへ置く
- 実TiDBへクエリを流す
- 返却列、JavaScript上の型、nullability、件数を検査する
- mutation後の値を読み戻す
- transactionのcommitとrollbackを試す
- typecheck、test、lint、production buildを通す
- 実装と同じ変更で設計・運用文書を更新する
以前なら、この確認をすべて手作業で続けるコストがORM導入の強い根拠になった。AIエージェントは、その反復コストをかなり引き下げる。ORMが不要になる可能性は、「AIなら正しいSQLを書ける」ことよりも、「面倒だが機械的に検証できる作業を、変更のたびに完遂させられる」ことから生まれる。
型安全性をproductionのCPUで買わない
直接SQLへ移行するとき、最初に悩んだのは結果行の型だった。DBドライバの返却値を as unknown as UserRow[] と書くだけなら簡単だが、根拠のない二重castは単なる願望である。
かといって、productionの全リクエストで汎用schema validatorを使い、全行をparseするのも今回の目的に合わない。そもそもの動機がWorkerのCPU負荷とbundleを小さくすることだからだ。
そこで、安全性を事前検証へ移した。
export type MemberByIdRow = {
id: number;
email: string;
n: string | null;
role: string;
groupId: number | null;
disabled: number;
};
各クエリには、選択する列とaliasに正確に対応した名前付きrow型を定義する。DB結果のcastは、そのSQLを所有するserver-onlyのquery module内だけで許可する。そして対応する契約テストで、実TiDBが返す次の内容を確認する。
- keyが完全に一致すること
- 各値のJavaScript上の型
- nullable列が実際に
nullになるケース - aggregateや計算列の型
- zero-or-one、one、manyといったcardinality
- アプリケーション型へ正規化した結果
特に注意したのが LEFT JOIN である。右側テーブルの列がスキーマ上 NOT NULL でも、対応する行がなければクエリ結果は null になる。したがって、結果型は元テーブルの列定義ではなく、クエリ全体の式から決めなければならない。
SELECT
co.id,
m.id AS memberId,
m.email AS memberEmail
FROM Checkouts AS co
LEFT JOIN Members AS m ON m.id = co.memberId
この場合、Members.id や Members.email がテーブル上 NOT NULL であっても、memberId の結果型は number | null、memberEmail は string | null になる。COALESCE や確実にnullを除外する条件がSQLに明示されている場合だけ、非nullableな型にできる。この判断も推測では済ませず、JOIN先が存在しないfixtureを用意して実DBの返却値を確認する。
これでcastは「たぶん合っている」ではなく、「このdriverとこのTiDBに対して検証済み」という意味になる。SQL、alias、型、driver decoderのどれかを変更したら、同じ契約テストも更新して再実行する。
もちろん、runtime checkをすべてなくしたわけではない。rowsAffected、lastInsertId、安全な整数範囲、期待する行数など、処理の正しさに関わる安価な検査はproductionでも行う。省いたのは、すでに契約テストで確かめた全結果行に対する汎用的なschema parsingである。
直接SQLの書き心地を整える
直接SQL化の後、複数の ? と離れた引数配列は読み間違えやすいと感じた。そこで、値だけを受け取る小さなtagged templateを追加した。
const result = await executeSql(executor, sql`
UPDATE Members
SET role = ${role}
WHERE id = ${memberId}
`);
${role} や ${memberId} がSQL本文へ展開されるわけではない。tagはSQLを ? へ変換し、値を別の引数配列としてdriverへ渡す。生のSQL断片や識別子を受け付けない、値専用の仕組みである。
一方、何でもtagged templateにすればよいわけではない。パラメータが一つで対応が明らかなSQLは従来の ? と配列で十分だ。LIMIT 1 やboolean flagのような固定値は直接書く。可変長 IN や一括INSERTは、専用のplaceholder生成処理を使う。この小さな使い分けによって、新しいORMもどきを作らずに可読性だけを改善できた。
AIエージェントへ与えたルール
この移行で最も重要な成果物は、個々のSQLよりも作業ルールだった。エージェントに毎回長い会話の経緯を説明しなくても、同じ安全基準で変更させるためである。
別のプロジェクトで再利用するなら、少なくとも次の内容をリポジトリのエージェント向け指示へ入れたい。
## Direct SQL rules
- SQL schema file is the source of truth. Do not infer production schema
from application types or former ORM definitions.
- Execute SQL only in focused server-only query modules.
- Accept an executor that represents either a connection or transaction.
Never escape the active transaction by opening another connection.
- Select explicit columns with stable aliases. Never use SELECT *.
- Bind every runtime value. Never interpolate request data into SQL text.
- Allow dynamic identifiers and sort expressions only through closed allowlists.
- Give every distinct result shape a named TypeScript row type.
- Type columns from the nullable side of a LEFT JOIN as nullable even when the
underlying columns are NOT NULL. Narrow them only when the SQL expression
eliminates null and the real-DB contract test proves it.
- Permit a DB-result cast only beside its owning query and only when a real-DB
contract test verifies exact keys, JS types, nullability, aliases and cardinality.
- Do not run general-purpose row schema parsing in production hot paths.
- Verify mutations with rowsAffected or lastInsertId and read stored values back.
- Test every changed statement against the real test database; mocks and SQLite
are not substitutes for SQL syntax, driver decoding, constraints or transactions.
- Test both commit and forced rollback for transaction changes.
- Keep the canonical schema and ordered migrations in sync.
- Agents may run DDL/DML only against a proven test database.
They must never run production DDL or apply production migrations.
- Run unit tests, integration tests, typecheck, lint and production build before push.
さらに、テストDBを壊してよい条件は文章だけでなくスクリプトでも強制した。接続先database名が明確にtest用であること、productionの接続先と一致しないこと、URLを解析して安全性を証明できない場合は停止すること。この種の安全策は「エージェントが守るはず」ではなく、守らなければ実行できない形にするべきだ。
ORMを捨てれば自動的に速く、安全になるわけではない
この記事のタイトルは命令形だが、ORMを窓から投げ捨てるべき条件はある。
ORMが複雑な検索条件の組み立て、複数DBへの対応、schema generation、relation管理などで十分な価値を出しているなら、無理に外す理由はない。実DBのintegration環境を用意できず、契約テストを継続的に回せないプロジェクトでも、手書きSQLへの移行は危険だ。チームがSQLをレビューできないのに、AIの出力だけを信じるのもよくない。
今回うまくいったのは、もともと生SQLが多く、ORM固有機能への依存が薄く、TiDBという対象DBが明確で、段階的な移行と実DBテストに時間を使えたからである。
そして、性能改善を主張するなら測定が必要だ。ORM撤去はbundleとCPUの固定費を減らす可能性があるが、DB通信やクエリ計画のほうが支配的な場面も多い。ホットパス、productionに近い件数、成功・失敗の両方を比較し、感触だけで大きな効果を断言してはいけない。
おわりに
AIエージェントが登場したから、ORMの価値がゼロになったわけではない。しかし、ORMを採用する理由の一つだった「生SQLを正しく保守するための人件費」は確実に変わった。
SQLスキーマを正本にする。クエリの境界を狭くする。結果型を明示する。実DBで契約を検証する。mutationとtransactionの意味を確認する。本番DBへの権限を渡さない。そして、これらを毎回実行するルールをエージェントへ与える。
そこまで整えれば、ORMを外すことは型安全性を諦めることではない。抽象化の置き場所を、runtimeから開発プロセスへ移すことだ。
ORMが実際に助けているのか、それとも昔からそこにあるだけなのか。一度その問いをAIエージェントと一緒にコードベースへぶつけてみる価値はある。答えによっては、窓を開ける準備をしてもよい。