13 July 2026

第一篇講為什麼,第二篇講如何實作,這篇來算帳:測試怎麼寫、讀取怎麼變快、以及上了 production 之後最後獲得了什麼。

每條 SQL 都要對真資料庫測 —— 而且只要一層

第一篇的結尾說過:每一條 SQL 操作,都需要一個對真資料庫的測試,ORM 不會讓你免於這件事,它只是讓你以為可以。那這些測試怎麼寫才不會變成負擔?

先看舊世界的標準答案,是兩層:service test 把 DAO mock 掉,測商業邏輯;再一層 DAO test 對真 DB,測查詢。寫過的人都知道這有多虛。mock DAO 的 service test,測的是「假如 DAO 回傳這個,那麼……」,一整片 stub 設定,測完只證明你的假設跟你的假設一致。CRUD 的應用程式,SQL 就是本體,你把本體 mock 掉是在測什麼?

為求避免重覆的無效作業,最大化測試的效果,收斂成一層:只寫 Integration Test,針對每一個 use case (service) 來寫,service 裡面到底在做什麼我們不管,只在乎它的結果 (blackbox test)。直接對真的 PostgreSQL 測,真 DAO、真 mapper、真 SQL,@Transactional 讓每個測試自動 rollback,測試之間互不污染:

@SpringBootTest
@Transactional
public abstract class DbIntegrationTests {

  // 每個測試都跑在這個瞬間 —— service 讀注入的 Clock,
  // 所以固定住 bean 就固定住所有 timestamp
  public static final Instant TEST_NOW = Instant.parse("2026-07-11T10:00:00Z");

  @TestConfiguration
  public static class FixedClockConfig {
    @Bean
    @Primary
    Clock fixedTestClock() {
      return Clock.fixed(TEST_NOW, ZoneOffset.UTC);
    }
  }
}
class ArticleServiceTest extends DbIntegrationTests {

  @Test
  void createPublishCommentThenReadDetailInOneQuery() {
    ArticleDto draft = articleService.createArticle(
        alice, new ArticleEntry("My great post!", "Hello world"));

    // 草稿不能留言 —— model 的規則,穿過 service 浮出來
    assertThatThrownBy(() ->
        articleService.addComment(draft.id(), coby, new CommentEntry("first!")))
        .isInstanceOf(IllegalStateException.class);

    articleService.publishArticle(draft.id());
    articleService.addComment(draft.id(), coby, new CommentEntry("頭香"));

    ArticleDetailDto detail = articleService.getDetail(draft.id());
    assertThat(detail.authorName()).isEqualTo("Alice");
    assertThat(detail.comments()).hasSize(1);
    assertThat(detail.comments().get(0).createdAt())
        .isEqualTo(TEST_NOW.toEpochMilli());
  }
}

一個測試同時涵蓋 service 邏輯 + DAO + mapper + SQL + schema。唯一的代價就是測試很慢,但換來的是更高的信心、測試程式碼的減量。而核心邏輯是在 model,它是純的無副作用,可以用毫秒級的 unit test 全面覆蓋。

所以整體的測試組合就是:一大票 model test,加一層對真 DB 的 service test。沒有 mock 地獄,也沒有兩層測試互相重覆抄襲。

這裡也 demo 了 Clock 的使用,將時間當作依賴注入,在測試期間就能固定,也能自由變換 (例如直接往後調一小時)。你將程式裡的副作用經過妥善的整理,在對的層級使用,事半功倍。

AI Agent 時代的測試

在這個 AI 的時代,寫測試已經不是難事了。叫 AI 全部生成啊!全部覆蓋啊!還講究什麼方法?是沒錯,但也因為 AI,現在的困難點轉移了 ——

你會去 review 測試嗎?

憑良心講,正式的程式碼都懶得看了,何況是測試,要求全部寫,那個量真的大到無法承受。更重要的是,AI 很容易寫出無效的測試,很多時候 Agent 都會走捷徑,能跳過、能唬弄它就會這樣寫,因為它收到的 prompt 是要寫測試,測試要過。它就只會滿足這個目標。

所以怎麼辦?大家就是忙於寫 skill、改 CLAUDE.md,要求測試該怎樣那樣,避免 Agent 犯錯。然後 Agent 不調用 skill,忘記 CLAUDE.md 的內容,關鍵的測試寫成無效,大家就整天抱怨今天模型又降智了,A社退我錢……blah blah。

Agent 不知道寫測試是為了提升維護性,這個人類背後的動機。在它有限的 context 下只會生成要求的程式碼。所以與其在 skill 裡約束,不如在程式碼的語法和架構這個層級上直接限制,讓 Agent 無法作弊。強化語言層面的約束就是要求具備強型別,並且將 lint 調高檢查項目,而架構上就是要 夠硬,讓它很難造假。

上一篇介紹的 Immutable model 就是超硬的寫法 —— 你沒辦法構造任何破損的物件來唬弄結果。而這一篇使用真正跑 SQL 的 service 測試,測試要跑得動必須在 DB 建構真實的資料,也無法輕易跳過 (mock 整個都是假的,真的要少用)。

當然啦,凡事有例外:如果你使用 Mythos 等級的 Agent,現在就是 Fable 5。我不知它怎麼辦到的,它寫的測試品質很高,似乎它已經知道人們 為什麼 要寫測試了,也許 Fable 還是沒有這層意識,但它表現出來的就是這樣。

BONUS:一次 round trip 撈回整個 object graph

回到我們的主題,當你接受自由使用 native SQL 後,可以優化的空間會開闊許多。

ORM 在 2001 年賣的核心價值是 object graph:撈一個 Articlearticle.getComments() 簡單的呼叫,整顆物件圖會自己長出來,很方便。但代價前面講過了:lazy loading、N+1、session……。

現在是 2026 年,PostgreSQL 自己就會組 object graph:

// 文章頁:article + 作者 + 全部留言,一條 SQL,一次 round trip
@Select("""
    SELECT a.id,
           acc.name AS author_name,
           a.title,
           a.content,
           a.status,
           a.created_at,
           a.published_at,
           COALESCE(
             jsonb_agg(
               jsonb_build_object(
                 'id', c.id,
                 'commenterName', com.name,
                 'content', c.content,
                 'createdAt', c.created_at
               )
               ORDER BY c.id
             ) FILTER (WHERE c.id IS NOT NULL),
             CAST('[]' AS jsonb)
           ) AS comments
      FROM article a
      JOIN account acc ON acc.id = a.author_id
      LEFT JOIN comment c ON c.article_id = a.id
      LEFT JOIN account com ON com.id = c.commenter_id
     WHERE a.id = #{id}
     GROUP BY a.id, acc.id
    """)
Optional<ArticleDetailDto> findDetailById(long id);

jsonb_agg 把一對多的留言在 DB 端聚合成一個 jsonb 陣列,整個查詢回來是一列,直接 map 進一個扁平的 DTO (jsonb 欄位過一個十行的 MyBatis TypeHandler 反序列化成 List<CommentView>,完整程式在 demo repo)。

你寫了 native SQL,你的思考範圍就會擴大到 SQL 資料庫給你的能力,而不是只會用程式的物件去思考,像這樣優化到只剩一個 round trip 不在話下。

一個頁面需要幾次 round trip,是設計時決定的,不是 runtime 憑感覺長出來的。

這 SQL 很長,跟 article.getComments() 相比,耗工與難度也差太多吧?沒錯,所以 ORM 會變成顯學不是沒原因的。相對的,ORM 寫出來的 CRUD 效能差也是這樣來的,魚與熊掌不能兼得。

但我們現在有 Agent 了啊,我們可以全都要!重覆的苦工讓 Agent 去做就好了。再加上前面提到的真實 SQL 測試守護,效能、快速開發、高維護性好處全拿。

註:文章內的 SQL 程式碼只是範例,完整的 SQL 請見 demo repo。另外文章直接撈出所有的留言,正常的系統不會這樣設計 (留言太多會爆),這裡只是示意 jsonb_agg 的使用。

上線成績單

理論講完了,實績如何呢?我的 20 年老專案,今年上半年完成 Hibernate → MyBatis 的整套遷移。採用的方式不是整個重寫直接換上新版,而是慢慢換,一次一個模組,換完就上線。期間 Hibernate 和 MyBatis 共存了一陣子。

本來我也希望設計一套 Agent 工作流程,讓它自主的將網站整個重寫,但實際上並沒有做到,最終成品慘不忍睹。只好跟 Agent 一起工作,手把手漸進式的移植 (怎麼安全地走這條路,值得另外寫一篇)。

換完後的尖峰時刻,同一台機器、同一批流量,前後對照:

  • node iowait:23.6% → 1.4%
  • load average (1m):5.74 → 1.65
  • JVM heap 尖峰:177MB (給了 2G,只用掉 8.6%),GC overhead 0.06%

iowait 和 load 整個大降。當初上線前擔心得要命,因為 ORM,Hibernate 有神奇的 L2 cache 可以吸收大量的 query,而且在還沒轉移 MyBatis 前,就已經和 Agent 一起優化過 cache 設定,還有針對 Hibernate 產生的 SQL 也優化了好幾輪。

最終得到的結果壓倒性的好,逐條優化 native SQL 的成果斐然。Agent 很厲害的,SQL 全部攤開後,它可以自由去模擬去壓測,取得很好的成績。

事後分析,ORM 那種遍歷 object graph 的操作,很容易在看不到的地方造成讀取的壓力,多了還會加成放大。然後再用 L2 cache 去補,越補越大洞。

換成扁平的 SQL mapping,cache 拔光光,效能居然反而大升。當然,這不是嚴格的 A/B benchmark,就是同一個系統遷移前後的營運數字。但量級擺在那:iowait 差十幾倍不是調參數調得出來的,是讀取模式根本上的不同。

轉移後,讓人意外的是那個 JVM 吃的記憶體,居然 256MB 就夠跑了 (這個網站的量級是 1M req/day)。這可是被罵翻的 Spring server 啊!Hibernate overhead 比想像中的大,將它去除掉後 JVM 變得超瘦。

我本來後續還想玩一下 GraalVM,或者是跟風移植到 Rust。看到這個記憶體用量就免了,換成 native AOT binary 就是再降個 100MB 而已,完全無感。

結語

回到 threads 上那則貼文:

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

三篇寫完,這四行背後的東西應該都交代了:

immutable record 讓 invariant 收進 constructor (FP);
domain verb 讓 entity 具備業務語言、生命週期 (DDD);
Entity 異動和 native SQL 熔接在 DAO 裡,在架構層面提升維護性 (SQL)。

程式碼簡潔、高度內聚、偏好顯式、有效測試 —— 這些軟體工程上的優點,在 Agent 時代依然受用。

「一點問題也沒有」不是我在吹,是我伺服器上的 iowait 說的。

文中完整的程式碼範例放在 demo-immutable-sql-crud,也備有 skill 直接供 agent 使用。

本文雖然用老古板的 Java 來解說,但這套思維其實不限 Java 平台。範例也寫了份 TypeScript 版的可以參照,當然啦,那個 ts 版寫得 Java 味很重,你如果是 ts 專家,吸收了本文的精神,改成 idiomatic ts 難不倒你的。

你選用的 ORM 越薄、越偏向 native SQL 就越好。而越自動、抽象越多層的 ORM,我的建議直接避開,現在選擇很多,又有 AI Agent 加持,沒必要再背負這個債務了。


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