12 July 2026

上一篇把「為什麼離開 ORM」講完了,這篇來講怎麼做。完整的範例程式放在 demo-immutable-sql-crud,domain 是個迷你的部落格系統:AccountArticleComment,大家都熟。Stack 是 Java 25 (record) + Spring Boot + MyBatis + PostgreSQL,不過 pattern 跟語言無關,你換別的也一樣成立。

每一層有固定的 side-effect (副作用) 預算

先看整體長什麼樣。分層本身沒什麼新鮮的,Controller → Service → DAO → Mapper,老掉牙。新鮮的是每一層分到多少 side-effect 預算

Layer Side effects 職責
Model ,pure 建構、複製、計算。沒 I/O,不讀時鐘 (Clock)
DAO 只有資料庫 持久化,還有 (等下會講) entity 的變異跟製造
Service 全部 transaction、時鐘、編排 DAO、寄信、外部服務、Thread/Async
Controller HTTP decode request → 一個 service call → serialize DTO

這張表 Service 塞滿了 side effect,平常大家也是寫得很類似,只是對 side effect 沒有特別的篩選。像是 Clock 如果不是特別注意的話,會到處亂寫。Transaction (TX) 很多人也不管,覺得 TX 不是資料庫的事嗎?那我也可以寫在 DAO 吧?

是沒有人硬性規定怎麼才是對的。但沒規則就是隨便注入,你確定要這樣寫?維護性怎麼辦?相信要求品質的團隊都會另外設立分派規則,我這裡提供一個思路 ——

用副作用的種類去區分,按預算分配。

這個規則很簡單,容易判斷好執行。需求出現了你沒分類過的副作用,那就是先塞在 service,不要覺得能放入 DAO 或是 Model。等 Service 肥大了,受不了了,可以開始重構拆子項,這時也是用副作用去分拆,例如 @Async、Thread 之類的非同步操作,全部獨立拆出去。至於 TX,對終端用戶來說是個不可分割的單一工作,它綁定使用案例 (use case),使用案例是 service 層的職責,所以 TX 的管理只能出現在 Service 層,不能歸 DAO 管理。

層級拆好了,純邏輯全部歸 model 管,它被限定不准有副作用,用最普通的 unit test 就測到滿了,沒有副作用的程式好測到不行。而 SQL 全部沉到 mapper (DAO),最終 Service 讀起來就是一份短短的使用案例腳本。

Entity:immutable,並且具備生命週期

上一篇說過,「沒了 ORM,domain 物件會弱化」的問題。我的改良版的 entity 長這樣:

public record Article(
    long id,
    long authorId,
    String title,
    String content,
    ArticleStatus status,
    Instant createdAt,
    Instant publishedAt) {

  // canonical constructor 擁有 invariant:
  // 每一條建構路徑都走這裡,所以「壞掉的 Article」根本無法存在
  public Article {
    title = Texts.clean(title);
    content = Texts.clean(content);
    if (title.isBlank()) {
      throw new IllegalArgumentException("article title must not be blank");
    }
    if (status == ArticleStatus.PUBLISHED && publishedAt == null) {
      throw new IllegalStateException("a PUBLISHED article must carry publishedAt");
    }
    if (status == ArticleStatus.DRAFT && publishedAt != null) {
      throw new IllegalStateException("a DRAFT article must not carry publishedAt");
    }
  }

  // domain verb:不是 setStatus,是一個有業務語意的動作,guard 就寫在裡面
  Article publish(Instant now) {
    if (status != ArticleStatus.DRAFT) {
      throw new IllegalStateException("only a DRAFT article can be published");
    }
    return ArticleBuilder.builder(this)
        .status(ArticleStatus.PUBLISHED)
        .publishedAt(now)
        .build();
  }

  // 純決策(資料已在手上)也放 model —— service 只負責問
  public boolean acceptsComments() {
    return status == ArticleStatus.PUBLISHED;
  }
}

Model 層,尤其是 Entity,是整個 Domain 的核心,它們不是一群資料的集合,它是有生命週期、負責業務邏輯的。上面的 Article,你單單解讀它能做的事 ——「一篇文章,它可以發佈 (需指定時間),也可以判斷能不能留言」,這就是需求,是可以給 PM/用戶看的規格。沒給的就是不支援

如果你拿 Entity 當純資料操作,也就是傳統 JPA 的寫法,那你會給一堆 setter:setStatus(newStatus)setPublishedAt(time)。然後需求就會解讀成「一篇文章可以任意切換發佈狀態,有沒有發佈時間無所謂,隨便設定」。想當然爾這個很怪,所以讀程式的人要去 service 層挖資料是怎麼設定的,這種寫法就是會散落在各處。

範例程式裡的 Article,我們會說它昇華為 Domain Model:它具備領域的操作和判斷,而不是一個只有資料、沒有行為的資料堆。

接下來看幾個重點:

1. Invariant (不變式) 收在 canonical constructor

record 有個殺手級特性:所有建構路徑,不管是 static factory、withX 複製、還是 MyBatis 從 DB 撈回來的 hydration,全部匯流到同一個 canonical constructor。資料清洗消毒和保護就寫在這裡,意思是每一個存在過的 Article instance,都保證是乾淨的、合法的。

你仔細推敲,會發現這個 Entity 你無論如何都創建不出有發佈時間,但停在草稿的狀態。也創建不出內文沒有被消毒的 (例如帶 <script>alert...) Article。

不是「記得在 service 檢查」,是讓「壞掉的狀態」根本表達不出來。表達不出來的 bug,你不用費心思再去處理。

你的 Domain Model 被 Invariant 完全保護,你在其他層級做任何操作都不用再另外檢查,當你拿到 Article 就是直接用了,什麼 if 都不用寫。你要是像傳統的方式把驗證寫在某個 service method 裡,那其他建構路徑 (另一個 service、測試、migration script) 隨時可以繞過去,做一個髒掉的 entity 塞進 DB。

要達到這種效果,就是讓 Model (Entity) 變成 Immutable:異動任何欄位只能重新建構一個 instance,逼它一定要經過 canonical constructor。未來需求怎麼變,你都不會踩到破損資料的 bug。

2. 異動資料是含業務邏輯的操作 (domain verb),不是 setter

publish(now) 是一個業務動作。它自帶前置條件 (只有草稿能發佈),而且 status 跟 publishedAt 兩個欄位是同時改變的。沒有 setStatus(),也沒有 setPublishedAt(),所以根本不存在「改到一半」的中間態。

範例的程式在重新建構 record 時用了 RecordBuilder 這類 compile-time APT 來處理,如果是 Kotlin 直接有免費的 copy() 可用。

3. Model 沒有副作用,尤其是 Clock (時鐘)

publish(Instant now) 的時間是參數傳進來的。時鐘是 side effect,那是 Service 的事 (注入一個 Clock)。這樣 model 就純到底了,測試不用 mock 任何東西:

@Test
void publishGuardsItsOwnPrecondition() {
  Article published = draft().publish(NOW);

  assertThatThrownBy(() -> published.publish(NOW))
      .isInstanceOf(IllegalStateException.class);
}

沒有 Spring、沒有 DB、沒有 mock,跑起來毫秒級。整個 entity 的行為,invariant、guard、verb,全部這樣測完。

4. Model 含有核心業務邏輯,具備高度內聚 (Cohesion)

試想你現在要加一個新狀態 DELETED,這是很常見的 soft delete 做法。enum 就這麼加一個新的值進去。然後呢?在傳統的 JPA Entity 寫法,開始排查所有相關的程式了,誰會動到 status?誰需要驗 status……blah blah。你就查吧,看什麼時候會查完,看你的 AI Agent 會不會漏查某一個 service 或甚至 controller (有人亂寫)。

而你看這個具備 Domain 邏輯的 Article,你直接加一個 delete 的 verb (加點邏輯判斷,例如只有草稿能刪除...etc),然後掃一圈這個 class 就全部查完了。因為架構上只允許你在 Model 層做異動,程式碼自然具備高度內聚的特性。AI Agent 很厲害沒錯,但最怕掃不到相關的程式碼。現在只要掃同一個檔案就好,token 還花得超少,天差地遠。

5. Immutable 帶來的額外好處

Entity 會到處跑,你在每一層都看得到它。在 service 層,還會忽然被丟到另一個 thread 裡 (@Async)。你的 Entity 如果可以隨便 mutate,頭就大了,誰能夠拍胸脯說自己能寫好 multithread 的 code?有人會記得下 synchronized 嗎?會 dead lock 嗎?程式碼維護成本直接暴增。

你如果堅守 object 都是 immutable 的話,在 thread 間隨便傳都不會壞。service 只有直白白的傳遞,不會看到各種和業務不相關的程式碼。

還有,你知道 hashCode() 要穩定嗎?如果你將 entity 丟入 HashMap,然後半途去改 hashCode() 用到的狀態,bug 就來了。平常誰會注意這種小事?但等到發生了就查不出來了,放幾包乖乖都沒用的。

Immutable 將很多奇奇怪怪的病灶直接消滅,顧好業務邏輯就好。

Keystone:Entity 異動只發生在 DAO 層級

好,entity 是 immutable 的,publish() 回傳一個新 instance。那問題來了,誰負責把這個新 instance 存進 DB?這就是整套做法的 keystone,也是跟 Hibernate 世界完全相反的一條規則:

Entity 的異動只發生在 DAO 裡,跟持久化完全熔接且不可分割。

先回憶一下 Hibernate 的世界:service 把 entity 撈出來,就地 mutate (article.setStatus(...)),然後 dirty checking 在 commit 的時候自動 flush。變異在 service,持久化是隱式的。所以才會有那個經典恐怖故事:「你以為你 save 了,其實沒有」。

新世界把這個關係整個翻過來:

@Repository
public class ArticleDAO {

  // domain verb 只在這裡被呼叫,貼著它的 UPDATE
  public Article publish(Article article, Instant now) {
    Article published = article.publish(now);  // 異動:產生新 instance
    mapper.updatePublished(published);         // 持久化:特化並單一用途的 UPDATE
    return published;                          // 回傳與 DB 同步的 entity
  }
}
// MyBatis Mapper —— 這個 domain verb 改哪幾個欄位,UPDATE 就只碰哪幾個欄位
@Update("""
    UPDATE article
       SET status = #{status}, published_at = #{publishedAt}
     WHERE id = #{id}
    """)
int updatePublished(Article article);

沒有 dirty checking,資料要進 DB 只有一條路:一個有名字的 DAO method,對應一條特定的 SQL。「到底存了沒?」看 code 就知道。沒呼叫 DAO 就是沒存,呼叫了就是存了,結構上根本沒有第三種可能。而且一個 state change = 一個 DAO verb = 一條 SQL = 一個可測試的單位,連命名都自動對齊業務語言 (dao.publish(...),而不是 dao.update(entity))。

為什麼要熔接在一起,而且這樣好多重覆的程式碼不是?真笨!如果是我就抽個共用的……

  • 就是要這個笨。每一種資料庫異動就是配一條客製獨一的 SQL 給它,改多少欄位、多少 where 完全是精準打擊,不多也不少。你可以 audit 所有的 SQL、可以獨立優化每一條而不受干擾,你不會不小心改到不該改的欄位。所有 SQL 全部攤開,你的 AI Agent 一條都不會漏查。
  • 前面提到,當你拿到一個 Article,你可以完全相信它是完整的狀態。但 Entity 背後其實還有一個狀態:「持久化」。完整狀態應該連它一起算 —— service 層拿到的 instance,除了自己的欄位外,持久化狀態也要完全同步到資料庫。所以異動跟持久化熔接在一起是必要條件,不可違反。
  • 將兩者合併在同個 method 內還有個好處:model 剛才改了什麼欄位,它的精準 SQL 要怎麼下?就在上一行,不用特別去找。Mapper 裡的 SQL 就 1:1 照做,維護上沒有困難。

DAO 也是 factory

同一條規則也管到「生產物件」這件事:別讓半成品 entity 在 DAO 外面亂跑

public Article create(long authorId, ArticleEntry entry, Instant now) {
  Article toInsert = Article.create(authorId, entry, now);      // 剛建構完,id = -1,transient 半成品
  return toInsert.withId(mapper.insertReturningId(toInsert));   // INSERT … RETURNING id
}

Service 傳進來的是欄位值 (Entry 加時間),拿回去的是一個帶著真 id 的 persisted entity。那個 id == -1 的 transient 半成品,只是為了 INSERT 的 bind 而存在,生命週期不會跑出這個 method —— 跟 Keystone 是同一個精神,所以連生成 entity 都鎖進 DAO。DAO 不再是純 repository,它是 repository + factory 的混合體,製造跟入庫是同一件事。

這樣就推得出一件很划算的事:

Service 手上的任何 entity,由建構保證是合法的、而且與 DB 同步的。

你想拿到一個 entity,只有一條路:DAO 回傳給你。而 DAO 回傳的東西不外乎三種:剛異動+持久化完的、剛 INSERT 完的、或是從 DB 撈出來過了一次 canonical constructor 的。反正每一種都乾淨,而且都跟 DB 同步。這份信任是架構給你的,不是靠紀律硬撐的。

原料 → 成品 → 包裝

Model 層的型別不是只有 entity 一種,它其實是一條產線:

型別 角色 特徵
ArticleEntry 原料 資料袋,帶驗證 annotation (@NotBlank@Size),controller 收貨
Article 成品 immutable record,domain verb + invariant,上面講的主角
ArticleDto 包裝 純資料出貨:時間轉 epoch millis、由 article.toDto() 生產

Entry 進來,entity 的 constructor 負責製造 (sanitize 就是在這一步做的),Dto 出去。Controller 兩頭都不碰業務:

@PostMapping
public ArticleDto create(@RequestParam long authorId,
    @Valid @RequestBody ArticleEntry entry) {
  return articleService.createArticle(authorId, entry);
}

Dto 就只是資料,沒有 session,沒有 proxy。上一篇講的那整類 detached-entity bug,在這個世界裡根本活不下去。

那 Service 還剩什麼?

剩編排,就這樣,很瘦:

@Service
@Transactional(rollbackFor = Exception.class)
public class ArticleService {

  public CommentDto addComment(long articleId, long commenterId, CommentEntry entry) {
    Article article = articleDao.getById(articleId);      // I/O:service 的事
    if (!article.acceptsComments()) {                     // 決策:model 的事
      throw new IllegalStateException("comments are only open on a published article");
    }
    return commentDao.create(article, commenterId, entry, clock.instant()).toDto();
  }
}

欄位驗證?entity constructor 的事。業務 guard?model verb 的事。SQL?mapper 的事。Service 只做它獨佔的那幾種 side effect:transaction 邊界、時鐘、跨 DAO 編排。每個 method 讀起來,就是那個使用案例本身。

下集預告

實作的細節講完了,下一篇來實戰:每條 SQL 對真資料庫的測試怎麼寫 (而且只要一層測試就夠)、怎麼用 jsonb_agg 一次 round trip 撈回整個 object graph,還有這套架構在我那個二十年的 production 系統上,換來的實際數字。


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