11 July 2026

TL;DR 這三篇系列文講解 native SQL 如何搭配 Immutable Entity 使用。最快就是直接看程式:demo-immutable-sql-crud,裡面也有 TypeScript 版可以參考。

覺得太長的話,將網址丟給你最信任的 Agent,叫它總結這三篇的精華。

前陣子我在 threads 上發了一則貼文

個人極度偏好 DDD 和 rich domain model
然後我全部寫 native sql, 一點問題也沒有
寫的 entity 還全部是 immutable
FP+DDD+SQL 爽的咧

引來了一些討論和疑問。一則貼文塞不下完整的說明,我想還是寫成文章,把這套寫法好好的整理出來。先說了,我已經這樣寫快十年了,所以不是什麼很先進厲害的方法,這也是這篇文一直難產的原因之一,因為價值偏低。

都 2026 年了,還在談 CRUD?

CRUD 大概是這個行業被寫到最爛的題目。每個 framework 的 getting started 是一個 CRUD,每個 ORM 的文件是一個 CRUD,每個 bootcamp 的第一個作業還是一個 CRUD。現在都 AI Agent 時代了,程式多半是 agent 在寫,這時候還討論一個 CRUD 的寫作風格和 pattern,呃……吃飽太閒?

是這樣,但不是這樣。

AI 模型寫不出它認知以外的程式。模型的能力來自訓練資料,你叫它寫一個 Java CRUD,它產出的八成就是 JPA、@Entity@OneToMany、lazy loading 那一套 —— 因為公開世界的程式碼就長那樣。模型不會發明新的寫法,它只會重組它看過的東西。這套 immutable + native SQL 的寫法在 ORM 盛行的年代很少見,少見,就表示不在訓練集裡。

少見但有效的寫法,對這個世界還是有助益的:它會變成 AI 模型的新養料。你不寫出來 (成 blog),模型永遠不會這樣寫。

另一個方向想:當年為什麼會有 ORM?因為工程師不想重複做工。同一張 table,要寫 insert、寫 update、寫十幾種 select,打字打字再打字。ORM 把這些 boilerplate 全部吃掉,代價是引進一層又厚又充滿魔法的間接層。在這十幾年裡,這個交易是划算的。

但現在打字的是 agent。boilerplate 的成本趨近於零,那我們還需要那層魔法嗎?以前「太費工、不可能」的選項,最近一個一個被重新打開 —— TypeScript compiler 用 Go 重寫了,Vite 把 bundler 換成了 Rust 的 Rolldown,連 Bun 都開始用 Rust 整個重寫。工具鏈可以整個推倒重來,那肥大複雜的 ORM 是不是也該被重新審視一次?

十二年前的遺憾

2014 年我寫過一篇 Move away from Hibernate,當時我把公司專案的 Hibernate 拔掉,改用 Spring 的 JdbcTemplate 寫純 SQL。那篇的結尾是這樣的:

沒了 ORM,缺點是 domain 物件弱化了。…… 我很想念 Hibernate 帶來的便利與完整的物件模型,不過我不後悔。

當時的認知是:要純 SQL,就得犧牲物件模型,domain 物件退化成資料的搬運工。不過沒多久我就開始導入 Kotlin 了,Kotlin 的思維還是比較傾向 Functional Programming,它的 API 預設都是不可變 —— Immutability everywhere。但查了一圈,大家都跟你說 JPA + Kotlin,你的 entity 就要 mutable。

我就不死心,在那邊揉啊搓的,想辦法將 FP 的精神,套用在 Spring 這種傳統的框架內。我想好好管理 side effect,又要 native SQL,又要好測試,又要……

最後總算生出一套規範和寫法,一用就是好幾年,那個 Kotlin 專案現在也長到 100 個 table 了。也不算小了吧?經過長年的實戰應該確定能套用在複雜的專案上了。

今年上半年,Claude 大爆發,我把手上維護了二十年的 side project (純 Java,非 Kotlin),裡面的 Hibernate 整個拔掉,換成 MyBatis + 純 SQL。這次不一樣了:entity 不但沒有弱化,還全部變成 immutable 的 record,有完整的 domain 行為、有 self-enforced invariant,是一個貨真價實的 Rich Domain Model。FP + DDD + SQL 同時成立,一點都不衝突。

十二年前欠的物件模型,這次要回來了,而且不用 ORM。

為什麼離開 ORM

準確來說是 JPA/Hibernate。ORM 也是有很輕量好用的,但 Hibernate 是功能最強大、也因此是最難維護的 ORM。

1. 隱式行為 (implicit behavior),而且會隨版本變

Hibernate 的 flush 時機、dirty checking、cascade、lazy loading、L1/L2 cache、fetch strategy,全部是隱式的,而且歷代版本都改過預設值。同一份 source code,升個版,跑出來的 SQL 就不一樣了。這種變化在 code review 裡看不見,unit test 也常抓不到,因為它要跑在真的資料庫上、遇到真的 flush 時機才會現形。

你以為你 save 了,其實沒有。它是隱式的,而且哪一版開始行為改變,你不知道。

2. 可攜性的前提死了

ORM 誕生 (Hibernate, 2001 年) 的核心賣點是跨資料庫可攜。但現在很少人在換資料庫了,雲端時代,產品都是做成 SaaS,跟你收流量和訂閱費。買斷產品回去讓你換不同資料庫裝?不是說沒有,但就算是買斷,很多也是給你整包 docker 或 VM image,內含資料庫了。選了 PostgreSQL 就是 PostgreSQL,哪有輕易再切換的。而且營運到一定規模,誰敢換不同的資料庫?就算是 AI 時代你也不敢動。

但 ORM 的抽象稅你還是 100% 在繳,可攜性的紅利,領到 0%。然後 ORM 要優化的時候 (全文搜尋、materialized view、JSONB...etc) 大家都跟你說回去寫 native SQL 就好,那到底在可攜什麼?我還不是要手寫客製的 SQL,那用你幹嘛?

3. ORM 隱藏了 I/O 的成本

N+1 是經典案例。你的程式只是寫個 for-loop,在 loop 中讀取某個 association,然後 N+1 就藏在後面跑出來了。你不仔細去算實際產生的 SQL,不太容易發現,尤其是你接手別人的程式。改成寫純 SQL,你就會看到有菜鳥在 loop 裡面呼叫 DAO,code review 一眼就看到;ORM 的 lazy loading 立意很好,但 code base 一大就難以管理,難以察覺。

不要以為 AI 時代這件事就會解決。Agent 的 thinking effort 你調得夠高嗎?不夠高它跟人一樣會漏掉。context 快炸了嗎?context 快滿了 AI 的思考會下降的。你的程式碼抽象隱藏的東西越少,你和 AI 就越容易發現問題。ORM 把成本藏起來的同時,把這個訊號也一起刪掉了。

4. 深關聯 (deep association) 是 anti-pattern,而 ORM 誘導你寫深關聯

因為 ORM 讓深關聯「很容易」,你就會去設計深關聯。看看這行你就懂了:

ShopOrder.getItem().getProduct().getVendor().getAddress()

很漂亮的物件關係,ORM 真方便啊!剛學完 Hibernate 第一年的我對這個功能讚嘆不已。

然後過一段時間你才會學到 model 之間太過耦合
→ 隱式 query 在背後亂飛
→ 設 lazy 來優化 → LazyInitializationException
→ 有些地方要 eager fetch、有些要 lazy……呃,你發現你在和 framework 對抗,它不是來幫你,是扯你的後腿。

最後因為耦合度太高,測試超難寫,然後你就不寫了。叫 AI 寫也是花很多 token 浪費在建立 fixture,寫得又臭又長,誰想 review?

你手寫 SQL 很快就會發現高耦合的 SQL 寫起來笨重,非報表類的居然寫的這麼複雜,聞到這股異味自然就會放棄這種設計。

5. ORM 會幫你檢查型別,保證 field 和 column 對齊。手寫 SQL 你只能靠 Test 補

這是 ORM 派,對手寫 SQL 的標準反駁:schema 改了你會忘記改程式中欄位的映射。這缺點是真的,但 ORM 有一模一樣的問題,ORM 也會有個地方在設定欄位映射,你設錯了還是一樣錯,也是要等 query 真的跑了才知道。結論殊途同歸:

每一條 SQL 操作,都需要一個對真資料庫的測試。ORM 不會讓你免掉這件事,它只是讓你以為可以。

我早期信任 ORM 產生的 SQL,所以有一陣子 DAO 都是用 mock 在測,結果好幾次上了 production 後才爆掉。persistence 的測試沒真的去跑 SQL 真的是白寫了,那個測試根本不能抓到 bug,也不能當防護網,完全是無效測試。

還有一個流派是測試用 SQLite,正式用 PostgreSQL/Oracle...etc。這個我也歸在 anti-pattern,你不用同一版的資料庫跑測試,是哪來的信心正式環境會過啊?更何況就算用了 ORM,還是有一堆優化的 native SQL 要寫,那些用 SQLite 跑?當然這個雷我也踩過了,很痛啊。

ORM 給你的型別檢查是有幫助的,但省不了多少工。database 是一個巨大的 side effect 機器,只有實測才能驗證你的 CRUD 是不是真的對。

手寫是苦工,但是我們現在有 Agent 了,你看過 Claude Code 怎麼查程式嗎?下 grep 去查啊:username|Username|user_name 嘩啦啦的拉出一大串,你寫的 native SQL 隨便也命中,漏改映射的機率很低。假設命中了三條,AI Agent 可以直接深入分析,看看修改後的 SQL 對功能和效能上的影響。改完了也只動到這三條,其他完全不變。你用 ORM 就要開始賭這次修改的影響範圍了。

總結一句,AI 時代的成本結構,又一次站在顯式 (explicit SQL) 這邊。

下集預告

「為什麼」講完了。下一篇進入「怎麼做」:immutable record entity 怎麼設計、invariant 怎麼收進 constructor 讓壞狀態根本無法存在,以及整套做法的 keystone ——

entity 的變異只發生在 DAO 裡;DAO 不只是 repository,它同時是 factory。

範例會用 Java (record + MyBatis + PostgreSQL) 示範,不過這套 pattern 跟語言無關 —— immutable entity、invariant 進 constructor、mutation 鎖進 DAO,在 TypeScript、Go、Kotlin 上一樣成立。demo 的程式也會有一份 TypeScript 版。


本文與 Claude (Fable) 共同撰寫 / co-authored with Claude Fable。