ADR-017: 框架 jar 不擁有 @Entity —— 判準零的反向套用
ADR 編號: 017 狀態: 已接受 (Accepted) 決策日期: 2026-07-15 決策者: Development Team 取代: 無(延伸 ADR-014 的判準零與規範一、三;補完 ADR-016 的機制唯一性) 被取代: 無
摘要
本 ADR 確立兩條原則,並據以推導 appfuse-server 的全部處置:
原則一:
@TenantId是多租戶的唯一實作。(ADR-016 的收尾——它換了機制,卻未宣告唯一) 原則二:框架 jar 不擁有@Entity。(ADR-014 判準零的反向套用——「Entity 的表結構 → schema 屬應用的資料庫」)
現況違反兩者:jar 有 6 個 @Entity(5 個 notification + 1 個 audit),其中 5 個帶裸 tenant_id(不經 @TenantId、可為 null)。
起因是一個實證的靜默失效(FU-56):jar 的 notification_outbox 以可為 null 的 tenant_id 當 unique 約束首欄,導致該約束在 ownership 專案上從未生效過(ict SIT 實測:69 列全部 tenant_id IS NULL),而在 SQL Server / Oracle 上則反向擋掉合法寫入。
診斷後,症狀由兩個不同的軸造成,兩者都須處理:
| 軸 | 病灶 | 症狀 | 本 ADR 的決策 |
|---|---|---|---|
| 歸屬軸 | jar 擁有 @Entity,連帶把裸 tenant_id 強加給所有下游 | 「擋太少」:ownership 的 uk 靜默失效;中性不成立 | 決策一~四 |
| 約束軸 | unique 約束含可為 null 的欄位(dedupe_key、locale),以 NULL 偷渡第二語意 | 「擋太多」:SQL Server / Oracle 上第二則無 dedupeKey 的通知被擋 | 決策五、六 |
兩點必須誠實記錄的推翻
① 兩軸曾被壓成一軸。 初稿誤以為「tenant_id 經 @TenantId 變 NOT NULL」就解決了兩個方向。不對——「擋太多」的兇手是 dedupe_key 而非 tenant_id(實測:('T1', NULL) 在 tenant_id 非 null 時,SQL Server / Oracle 仍擋第二列)。歸屬軸解不了約束軸。
② FU-56 本身有更便宜的解,但它違反原則一。 只要把租戶折進 composeDedupeKey 的組合字串、uk 只留 (dedupe_key) 單欄,兩個方向即可一次關掉,完全不必動 entity 歸屬。此案被否決:以字串組合表達租戶範圍,等於引入第二套租戶機制,正面違反原則一。
∴ 歸屬軸不是被 FU-56 逼出來的,是被原則二證成的。 FU-56 只是暴露違反的觸發器。本 ADR 刻意不讓昂貴的遷移搭 bug 的便車——它該以自己的理由被批准。
背景 (Context)
問題陳述
症狀:一個兩個方向都壞掉的 unique 約束
jar 的三個 notification 實體,都把「刻意可為 null」的欄位放進 unique 約束:
| 實體 | 約束 | 可為 null 的成員 |
|---|---|---|
NotificationOutbox | uk(tenant_id, dedupe_key) | tenant_id、dedupe_key(選填) |
NotificationTemplate | uk(tenant_id, notification_type, channel, locale) | tenant_id(null = 全域範本)、locale(null = 不分語系) |
NotificationPreference | uk(tenant_id, user_id, notification_type, channel) | tenant_id |
SQL 標準對「unique 約束中的 NULL」語意不一致,於是同一段宣告在不同 DB 上往兩個相反方向壞:
| 方向 | 條件 | 症狀 | 現況 |
|---|---|---|---|
| 擋太少 | NULL 互不相等(MySQL / PostgreSQL / H2) | 唯一性靜默失效 | live(ict SIT / UAT) |
| 擋太多 | NULL 視為相等(SQL Server / Oracle) | 合法寫入被擋 | 潛伏 |
「擋太多」是反直覺的重點:dedupeKey 選填(NotificationService.composeDedupeKey 在未給時回 null,是常態路徑),而 outbox 是 append-only。⇒ 在 SQL Server / Oracle 上,每個租戶史上只能存在一列 dedupe_key IS NULL 的 outbox,第二則不帶 dedupeKey 的通知即失敗。這不是 ownership 專屬,正中預設的 tenant 模式。
「擋太少」則不限 tenant_id:NotificationTemplate.locale 為 null(=「不分語系」)時,MySQL / PG 上同一租戶可存在兩筆「不分語系」範本;而 resolver 的四支查詢皆回 Optional ⇒ 真出現重複會拋 IncorrectResultSizeDataAccessException。
實證
| 主張 | 方法 | 等級 |
|---|---|---|
ict SIT(ownership + 真 MySQL 8.0.42)的約束從未生效 | 對真實部署庫唯讀查詢:uk_notification_outbox_dedupe (tenant_id, dedupe_key) 存在,total=69, tenant_id IS NULL=69, dedupe_key IS NULL=32,無重複 | ✅ live 實證 |
ownership + 標準 NULL 語意 ⇒ uk 失效 | @DataJpaTest 對真實 NotificationOutboxRepository:兩筆 (null,'k') 皆 INSERTED | ✅ 實跑 |
| app 層去重仍有效 | 同上:findByTenantIdAndDedupeKey(null,'k') → FOUND(Spring Data 對 null 參數生成 IS NULL) | ✅ 實跑 |
| SQL Server 語意 ⇒ tenant 模式第二則被擋 | @DataJpaTest + H2 MODE=MSSQLServer、框架真實 entity:兩筆 ('T1', null) → BLOCKED(ConstraintViolationException) | ✅ 實跑(H2 模擬) |
| 歸屬軸單獨解不了「擋太多」 | H2 四模式、以 tenant_id NOT NULL 的修訂後形狀實測:('T1', NULL) ×2 在 MSSQLServer / Oracle 仍擋 | ✅ 實跑(見決策五) |
| SQL Server 只准一個 NULL 列 | Microsoft 語意,權威來源佐證 | ✅ 文件 |
| Oracle 亦受影響 | Oracle Database Concepts:「Because of the search mechanism for unique key constraints on multiple columns, you cannot have identical values in the non-null columns of a partially null composite unique key constraint.」 | ✅ 官方文件 |
證據邊界(誠實聲明):SQL Server / Oracle 的結論來自 H2 相容模式模擬 + 官方文件雙重佐證,兩者獨立且一致,但未在真實 Oracle / SQL Server 實例上覆核。ict 的 live 證據只涵蓋「擋太少」這半。決策五落地後 uk 中不再有 nullable 欄位、NULL 語意差異不再適用,該覆核失去對象(見決策五)。
根因:不是約束寫錯,是既有決策的四個縫隙
① ADR-014 的判準零只被單向套用
ADR-014 立了歸屬判準,並明列:
| 落點 | 為何消費端不可失去 |
|---|---|
| Entity 的表結構 | schema 屬應用的資料庫 |
但它的判斷句是單向的——「這段碼若移進 jar,消費端會失去宣告權嗎?」——且明文「舉證責任在上移方,存疑即留 core slice」。其「已知的誤置」表因此全是 app → jar 的候選(TokenBlacklistService),風險表擔心的也是「判準零被用來把碼一律上移 jar」。
從來沒有人問反向的問題:jar 裡現在有什麼,是本來就不該在那裡的? ADR-014 全文唯一提到的 entity 是 app-server 的 MailSetting,處置還是「留 core slice」(=別上移)。jar 既有的 6 個 @Entity 落在這個盲點裡——它們若今天才要「移進 jar」,判準零會當場否決。
② ADR-014 規範一被違反,而 ADR-014 誤把它當良好範例
規範一要求「每個對外能力必須以介面暴露其組合點」,並讚許 notification 為良好範例(RecipientResolver、NotificationTemplateResolver 等皆為介面)。但:
// outbox/NotificationDeliveryListener.java —— 已發布的 SPI
void onAttempt(NotificationOutbox outbox, int attempt, DeliveryResult result);
具體 @Entity 出現在 SPI 簽章上,且三個下游的 NotificationConfig 都接線這個 bean。這是規範一的違反,ADR-014 撰寫時未察覺。
③ ADR-016 換了機制,但沒宣告「唯一」
ADR-016 把租戶隔離改採原生 @TenantId,其核心論證是「失效方向必須是明顯壞掉,而不是安靜地把別的租戶的資料端出來」。但它只換機制、未宣告該機制為唯一。於是 jar 的 5 個 notification 實體以裸 tenant_id 停留在機制之外——而 @TenantId 的世界裡 NULL 根本不存在(nullable=false + resolver 不接受 null + __root__ 哨兵 + @PrePersist 守衛)。
FU-56 的「擋太少」收斂到這個縫隙:因為欄位在機制外,才可能為 null;因為可能為 null,才有 NULL-in-unique。同一條「失效方向」論證,延伸到同一個結論。
opt-out 理由已經過期(outbox)
NotificationOutbox 的 javadoc 記載了兩個不繼承 TenantAwareEntity 的理由,兩個都已不成立:
| javadoc 理由 | 現況 |
|---|---|
| ①「輪詢器須跨租戶撈列,套上租戶 filter 會在無 context 時撈不到」 | app-server/.claude/rules/20-scheduling.md 明文標為錯的:「那個機轉從未存在……root session 跳過 filter ⇒ 排程緒讀 TenantAwareEntity 是安全且可行的」,並直接點名 Outbox「理由不是這個」 |
②「其 @PreUpdate 跨租戶守衛會誤拋」 | 現行 TenantAwareEntity 沒有 @PreUpdate(只有 @PrePersist rejectRootTenantOnPersist)。那是 ADR-016 之前舊機制的產物 |
唯一存活的理由是「tenantId 必須可為 null(ownership 相容)」。而那個「為了中性而讓欄位可為 null」的決定,正是兩邊都沒服務好的來源:ownership 拿到死欄位 + 死約束,tenant 在 SQL Server / Oracle 上被擋。
NotificationTemplate的 opt-out 理由則不過期——「解析需同時看 tenant 列與 global(null tenant)列,租戶 filter 會把 null 列濾掉」是真的。這是本 ADR 唯一擋在原則一前面的實質障礙,由決策六處理。
④ 規範三的 ArchUnit 涵蓋不到
ADR-014 唯一的機械保證是兩條:
noClasses().that().resideInAPackage("io.leandev.appfuse..")
.should().dependOnClassesThat().resideInAPackage("io.leandev.app..") // 框架不得 import app
// core slice 不得 import 業務 package
沒有任何一條擋得住 jar 長出 @Entity 或裸 tenant_id。 判準零與規範一是流程義務、靠 review 承擔——而 review 沒有攔住這 6 個 entity。
「框架 tenant neutral」目前不成立
方法論 ADR-004 與 m-server-common.md 宣稱:「框架對資料怎麼隔離不偏袒任何一種模式……非中性的不是框架碼,而是專案的選擇(defaultDataIsolation)。」
這對 primitive 層成立(TenantAwareEntity 與純稽核基類並存、平等)。但對 jar 自己的 notification 子系統不成立:5 張表無條件帶 tenant_id,下游沒有 opt-out。tenant feature 明明是 kind: optional、requires: [](可以不裝),但 notification 的 tenant_id 跟著 jar 走——ict 沒有 Tenant 這個概念、沒裝 tenant feature,仍然拿到 5 張帶 tenant_id 的表和 3 個死約束。
jar 內部自己也不一致:AuditEventRecord 完全不帶 tenant。同一個 jar、同樣是框架自有實體,兩套標準。
限制條件
- SPI 簽章屬公開 API(
30-public-api.md)。本 ADR 選擇徹底解決、接受破壞性變更;deprecation cycle 依03-versioning.md處理,但不因規避破壞而扭曲設計。 - jar 刻意不做 autoconfiguration(ADR-014),本 ADR 不改變此前提。
- Java 單一繼承:
@MappedSuperclass方案不得要求應用同時繼承兩個基類。
假設前提
@EntityScan已在應用層:三家下游的NotificationConfig各自列舉 basePackages;jar 內無@EntityScan/persistence.xml/AutoConfiguration.imports。⇒ 註冊機制本身不需改動,只需替換 basePackages。- dispatcher 的 claim / retry 邏輯是真正的框架能力:無專案會想重寫,依判準零的反向(「純委派、無選擇的機制實作應上移 jar」)應留在 jar。
- 五個實體的耦合不對稱(見落地節奏),故分階段。
考量的方案 (Options Considered)
評比範圍:以下 A / B / C 比的是歸屬軸。約束軸(uk 不得含 nullable 欄位)是另一個軸,見決策五、六。
方案 A: 只修約束、不動歸屬
說明:維持 jar 擁有 entity,只把 uk 改成在所有 DB 上語意一致——filtered index、或把租戶折進組合鍵讓 uk 只剩 (dedupe_key)、或索性拿掉 DB uk 只靠 app 層。
優點:
- ✅ 足以完整關掉 FU-56(兩個方向),範圍最小、對下游近乎零破壞
缺點:
- ❌ 違反原則一:把租戶折進組合字串 = 以字串組合表達租戶範圍 = 第二套租戶機制
- ❌ 違反原則二:6 個
@Entity原地不動 - ❌ 中性依舊不成立:ownership 仍拿到 5 張帶
tenant_id的表 - ❌ 四個縫隙(判準零盲點、SPI 洩漏、機制非唯一、ArchUnit 缺口)原封不動——下次還會有人往 jar 加 entity,因為沒有任何機制擋
- ❌ 若走 filtered index:JPA
@UniqueConstraint表達不了 ⇒ 須下放 per-dialect DDL,是新的架構債
評分: 2 / 5
A 的存在必須被記錄:它證明 FU-56 不需要歸屬軸。否決 A 的是原則,不是 bug。
方案 B: jar 出 @MappedSuperclass + SPI,應用層定 @Entity(✅ 選擇)
說明:
jar 提供 NotificationOutboxBase 等 @MappedSuperclass(欄位 + 機制邏輯,不含 tenant);應用宣告具體 @Entity 繼承它,自行決定表名、索引與約束。租戶性由應用以 @TenantId 宣告:
// tenant 專案
@Entity @Table(name = "notification_outbox",
uniqueConstraints = @UniqueConstraint(columnNames = {"tenant_id", "dedupe_key"}))
public class AppNotificationOutbox extends NotificationOutboxBase implements TenantAware {
@TenantId @Column(name = "tenant_id", nullable = false, updatable = false)
private String tenantId;
}
// ownership 專案
@Entity @Table(name = "notification_outbox",
uniqueConstraints = @UniqueConstraint(columnNames = {"dedupe_key"}))
public class AppNotificationOutbox extends NotificationOutboxBase { } // 無 tenant_id
@TenantId 不要求繼承 TenantAwareEntity(後者只是便利基類),故單一繼承不成問題。
優點:
- ✅ 判準零完全滿足:表名、索引、約束、租戶性全歸應用宣告
- ✅ 兩原則同時成立
- ✅ 「擋太少」從根上消失:
tenant專案的tenant_id經@TenantId恆 NOT NULL;ownership專案根本沒這欄位 ⇒ 兩種模式的 uk 都真正生效 - ✅ 中性真正兌現:ownership 不再拿到死欄位與死約束
- ✅ 全用既有 primitive(
@TenantId、TenantAware、TenantContext),不發明新機制 - ✅ 有現成先例:
TenantAwareEntity的 javadoc 自己就寫「方式 2:建立應用層級的基類(推薦)」;Stateful<S>的 package-info 宣示「介面 + 工具,定義契約、不強制實作方式」 - ✅ dispatcher / claim / retry 等真正的框架能力留在 jar,不逼下游各寫一份
缺點:
- ❌
NotificationDeliveryListener的 SPI 簽章須換掉具體 entity(破壞性) - ❌ 5 個 Repository 介面 + 3 個
Db*Resolver/Filter實作須跟著下移或泛型化 - ❌ Repository 的 JPQL 字串寫死 entity name(
"select o from NotificationOutbox o"),改名會在啟動期才炸,非編譯期 - ❌ 下游須新增各自的 entity + repository(
app-server破壞面最大,含 6 個測試)
評分: 4 / 5
方案 C: jar 只出純介面 / SPI,persistence 全歸應用
說明:jar 完全不碰持久化,只留 NotificationService 的編排與 SPI;outbox 的 claim / retry / dispatcher 全部由應用實作。
優點:
- ✅ 判準零、規範一最徹底
- ✅ jar 最薄
缺點:
- ❌ 違反判準零的反向:claim(
UPDATE … WHERE status IN(…)的原子認領)、retry policy、fast-path 都是「純機制、無專案選擇」的碼——依判準零它們應該在 jar。逼每個下游各寫一份,正是判準零說的「讓每個下游各持一份無增值的複本」 - ❌ 叢集安全(competing consumers)這種容易寫錯的並發邏輯散到 N 個下游,是正確性風險
- ❌ 下游遷移成本最高
評分: 2 / 5
決策 (Decision)
歸屬軸:選 方案 B,分階段落地。
約束軸:立「unique 約束不得含 nullable 欄位」為規則(決策五),並據以拆解 NotificationTemplate 的雙語意(決策六)。
核心理由
- 這不是新主張,是執行既有決策。ADR-014 判準零已明文「Entity 的表結構 → schema 屬應用的資料庫」;jar 的 6 個
@Entity若今天才申請「移進 jar」會當場被否決。本 ADR 把單向的判準補成雙向,並反向稽核一次。 - 同一條「失效方向」論證。ADR-016 為了「不要安靜地出錯」而換掉整個租戶機制;FU-56 是同一個病的另一個實例(ict 69/69 死約束,安靜地從未生效)。收尾它是 ADR-016 論證的必然延伸。
- 成本比預期低。jar 零 autoconfig、零
persistence.xml,@EntityScan本來就在下游的NotificationConfig——註冊機制不動,只換 basePackages。 - 中性從宣稱變成事實。ADR-004 宣稱的 tenant-neutral 目前在 jar 的 notification 子系統不成立;方案 B 是唯一能真正兌現它的選項(A 治標且違反原則一、C 過頭)。
決策一:jar 不得擁有 @Entity(原則二)
io.leandev.appfuse..底下不得有@Entity。 框架以@MappedSuperclass+ SPI 提供能力;具體@Entity(表名、索引、約束、租戶性)一律由應用宣告。
適用對象:5 個 notification 實體 + AuditEventRecord。
決策二:@TenantId 是租戶隔離的唯一機制(原則一)
io.leandev.appfuse..底下不得出現裸tenant_id欄位。 租戶歸屬一律經@TenantId(ADR-016)表達,其語意保證NOT NULL;「無租戶」以ROOT_TENANT哨兵表達,不以 NULL 表達。亦不得以其他手段(字串組合、命名慣例)表達租戶範圍——那是第二套機制。
推論:ownership 專案的實體沒有 tenant_id 欄位,而非「有欄位但填 null」——與 m-server-common.md 既有的「不要保留 base + 灌常數 tenant 的中間態」一致。
決策三:jar 對租戶的唯一接觸點是 TenantAware,且僅用於 runAs
jar 不得假設租戶欄位存在。 需要租戶時經
TenantAware介面探詢,缺席即視為無租戶。
兩原則如何互相成全(本 ADR 的關鍵推導)
原則二 ⇒ jar 的 base class 沒有 tenantId 欄位 ⇒ jar 的 NotificationService 寫不出 findByTenantIdAndDedupeKey。它只能查單欄:
findByDedupeKey(key) // jar 唯一寫得出來的查詢
乍看像是失去租戶範圍——沒有。因為原則一:app 宣告的 entity 帶 @TenantId,而 ADR-016 明載該 filter 在 Session 建構時自動套用、且 applyToLoadByKey = true:
| 呼叫者 | 結果 |
|---|---|
請求緒(有 TenantContext) | Hibernate 自動加 tenant_id = 當前租戶 ⇒ 去重自動限縮在該租戶內 |
| 排程緒(無 context = root) | filter 跳過 ⇒ dispatcher 的 findDueForDispatch 跨租戶撈得到(正是 20-scheduling.md 的 root 讀權界) |
ownership app(entity 無 tenant_id) | 無 filter ⇒ 全域,本來就對 |
jar 完全不需要知道租戶的存在,租戶範圍卻是對的。 原則二把 tenant 逐出 jar,原則一在 app 層把它自動補回來——這正是 ADR-016 承諾卻尚未兌現的那半(「不需任何人手動處理」)。
jar 唯一仍需租戶的地方是 dispatcher 跨 async 邊界的 runAs,經 TenantAware 探詢:
if (row instanceof TenantAware ta && ta.getTenantId() != null) {
TenantContext.runAs(ta.getTenantId(), () -> deliver(row));
} else {
deliver(row); // ownership:無租戶可 runAs
}
決策四:補上規範三的兩條不變量
ADR-014 規範三新增(違反即 build 失敗):
// ① jar 不得有 @Entity
noClasses().that().resideInAPackage("io.leandev.appfuse..")
.should().beAnnotatedWith(jakarta.persistence.Entity.class)
// ② jar 不得有名為 tenantId 的欄位,除非它帶 @TenantId
noFields().that().areDeclaredInClassesThat().resideInAPackage("io.leandev.appfuse..")
.and().haveNameMatching("tenantId")
.should().beAnnotatedWith(org.hibernate.annotations.TenantId.class).allowEmptyShould(true)
這是本 ADR 對歸屬軸唯一的機械保證,也是 FU-56 得以潛伏至今的直接補洞。決策一~三是流程義務。
決策五:unique 約束不得含可為 null 的欄位(約束軸)
凡進入 unique 約束的欄位一律
NOT NULL。 「選填 / wildcard 的識別」不以 NULL 表達,而以非 null 值表達;做不到時,該欄位不得進 DB 約束(改由 app 層承擔)。
具體形式視語意而定:
| 欄位 | NULL 原本偷渡的語意 | 非 null 表達 |
|---|---|---|
NotificationOutbox.dedupe_key | 「不參與去重」 | 每列自成一鍵的唯一值(composeDedupeKey 在未給時填入,取代回 null) |
NotificationTemplate.locale | 「不分語系」(wildcard) | wildcard 哨兵(如 '*') |
NotificationTemplate.tenant_id | 「全域預設範本」 | 不適用——那是第二個概念,須拆表(決策六) |
dedupe_key 的語意精確且不變:「沒給去重鍵」=「這列自己就是自己的去重身份,永不與任何列相撞」——與現行 app 層行為(dedupeKey == null 時跳過去重查詢、直接建列)完全等價,只是把「不參與去重」從 NULL 改用唯一值表達。
哨兵的確切形式(隨機 UUID / 由 row id 衍生;
'*'vs 其他 wildcard 值)屬實作細節,不在本 ADR 凍結。
為何歸屬軸解不了這一軸(實測)
「擋太多」的成因是 dedupe_key 為 NULL,不是 tenant_id。把 tenant_id 變成 @TenantId/NOT NULL 對它毫無作用——下表為 H2 各相容模式下、以修訂後形狀實測的結果:
| 形狀 | MySQL | PostgreSQL | MSSQLServer | Oracle |
|---|---|---|---|---|
決策一~三後、無決策五:tenant uk(tenant_id NOT NULL, dedupe_key),('T1', NULL) ×2 | ✅ 進 | ✅ 進 | ❌ 擋 | ❌ 擋 |
決策一~三後、無決策五:ownership uk(dedupe_key),(NULL) ×2 | ✅ 進 | ✅ 進 | ❌ 擋 | ✅ 進 |
加上決策五:uk(both NOT NULL),兩則無 dedupeKey(各自唯一值) | ✅ 進 | ✅ 進 | ✅ 進 | ✅ 進 |
加上決策五:同 dedupeKey='k' ×2(對照,應擋) | ✅ 擋 | ✅ 擋 | ✅ 擋 | ✅ 擋 |
第一列的 tenant_id 本來就是非 null 的 'T1' ⇒ 歸屬軸對「擋太多」無效,實證。加上決策五後,uk 中不再有任何 nullable 欄位 ⇒ 各 DB 的 NULL 語意差異不再適用,四個 DB、兩種模式全部正確,且不需任何 per-dialect DDL。
副作用:uk 完全脫離 NULL 語意後,「在真實 Oracle / SQL Server 覆核 NULL 行為」失去對象——沒有東西可覆核。本 ADR 初稿曾把它列為檢查項,屬誤列,已移除。
決策六:全域範本拆表(NotificationTemplate 的雙語意)
nullable 欄位不得偷渡第二個概念。
NotificationTemplate.tenant_id = null同時表達「不屬於任何租戶」與「全域預設範本」——後者是獨立概念,須獨立的表。
這是原則一的唯一實質障礙,且其 opt-out 理由不像 outbox 那樣過期:@TenantId 的 filter 是 per-session、只給當前租戶的列,租戶 session 看不到全域列。故「tenant_id 可為 null 以表達全域」在原則一之下無法存活。
處置:拆成兩個實體。
| 實體 | 租戶欄位 | filter 行為 | 誰宣告 |
|---|---|---|---|
NotificationTemplate(租戶覆寫) | @TenantId,NOT NULL | 受 filter ⇒ 只見本租戶的覆寫 | 僅 tenant 專案 |
GlobalNotificationTemplate(全域預設) | 無 | 不受 filter ⇒ 全租戶可見 | 兩種模式皆宣告 |
jar 的 DbNotificationTemplateResolver 泛型化為「查一張表」(locale → wildcard 兩層),解析鏈由 app 組裝——那本來就是 NotificationTemplateResolver 這個既有 SPI 的職責(ADR-014 規範一的組裝模型):
tenant app : 租戶表 resolver → 全域表 resolver → NLS resolver
ownership app : 全域表 resolver → NLS resolver (無「租戶覆寫」概念,少一張表)
解析優先序與現行等價(tenant+locale → tenant+wildcard → global+locale → global+wildcard → 訊息束),只是從「一個 resolver 做四次查找」改為「兩個 resolver 各做兩次、鏈接」。
附帶效益:ownership 專案本就沒有「租戶覆寫」的概念,拆表後它只需一張表——比現況(一張帶死 tenant_id 的表)更誠實也更簡單。
落地節奏(分階段)
五個實體的耦合不對稱,故分階段而非一次性:
| 階段 | 對象 | 理由 |
|---|---|---|
| 1 | NotificationTemplate(含決策六拆表)、NotificationPreference、InAppNotification | 耦合完全封裝在自己套件內(Db*Resolver / Db*Filter 同套件、無 import)。下游破壞面小:template 僅 app-server + ict 各兩處、inapp 僅 app-server 一處、tts 零 |
| 2 | NotificationOutbox、NotificationDeliveryLog | 須一併重塑 NotificationDeliveryListener SPI(NotificationOutbox → OutboxView 介面) |
| 3 | AuditEventRecord | 依賴面最窄(4 檔、全同套件);無 tenant,故原則一不咬,純粹是原則二的一致性。屬 ADR-013 的 R1 界線,獨立處理避免與 notification 遷移互相干擾 |
決策五的 dedupe_key 部分可獨立先行:它只動 jar 的欄位宣告與 composeDedupeKey,不依賴歸屬軸任何階段。若要儘早關掉「擋太多」,可在階段 1 之前先行。
過渡期:階段 2 完成前,
notification_outbox在ownership下的「擋太少」仍在。ict 的 live 影響僅止於「失去併發最後防線」(app 層去重實測仍有效),且 fleet 目前無 Oracle / SQL Server 部署,故不另做臨時修補。
權衡分析 (Trade-offs)
我們獲得什麼 (Gains)
- ✅ 兩條原則同時成立,且互相成全——jar 不知租戶,租戶範圍卻自動正確(決策三)
- ✅ FU-56 兩個方向都消失:「擋太少」由歸屬軸、「擋太多」由約束軸——兩軸合起來才完整,任一軸單獨都不夠
- ✅ 整類消失:uk 不再含 nullable 欄位 ⇒ 各 DB 的 NULL-in-unique 語意差異不再適用,且不需 per-dialect DDL
- ✅ ADR-004 宣稱的 tenant-neutral 在 jar 層兌現;
ownership專案不再拿到死欄位與死約束,template 還少一張表 - ✅ ADR-014 判準零從單向補成雙向,jar 既有碼首次被反向稽核
- ✅ 規範一的一個未察覺違反(SPI 洩漏 entity)被結清
- ✅ jar 回歸
10-java.md的設計原則:「不包含具體的 entity、service、repository、configuration」 - ✅ 應用取回 schema 宣告權:表名、索引、約束可依自己的 DB 與部署調整
我們放棄什麼 (Losses)
- ❌ 下游升級成本:三家 consumer 須新增自己的 entity + repository;
app-server另有 6 個測試要改 - ❌ 開箱即用程度下降:新專案不再「裝上 jar 就有 notification 表」,須先宣告 entity(由
/feature add的 core slice 承擔,緩解) - ❌ JPQL 字串脆弱性外顯:entity name 由應用決定 ⇒ jar 的 JPQL 不能寫死 ⇒ 須改為泛型或由應用注入
- ❌ SPI 破壞性變更(
NotificationDeliveryListener) - ❌
tenant專案的 template 由一張表變兩張(決策六的代價;換得ownership少一張)
風險與緩解措施 (Risks & Mitigations)
| 風險 | 嚴重性 | 機率 | 緩解措施 |
|---|---|---|---|
| SQL Server / Oracle 專案踩到「擋太多」 | 高 | 低 | 由決策五消滅,且其 dedupe_key 部分可獨立先行。fleet 目前全 MySQL / H2(已查外部 conf 覆蓋層確認),無立即壓力 |
決策五收緊 dedupe_key / locale 為 NOT NULL,既有 NULL 列導致 DDL / 啟動失敗 | 中 | 中 | 升級時一次性回填既有 NULL 列(ict 實測 dedupe_key IS NULL=32 列,量小);於 CHANGELOG Breaking Changes 標示並提供回填語句 |
決策六拆表後,既有 tenant_id IS NULL 的全域範本列需搬到新表 | 中 | 高 | 提供資料搬遷語句(INSERT INTO global_… SELECT … WHERE tenant_id IS NULL);列入升級指引 |
| 泛型化 Repository 使 jar 複雜度上升、可讀性下降 | 中 | 中 | 以 @NoRepositoryBean 基底介面 + 應用 extends 的既有 Spring Data pattern 落地,非自創機制;階段 1 先在耦合最小的實體驗證形狀 |
| JPQL 寫死 entity name,改名在啟動期才炸 | 中 | 中 | 遷移時一併把 jar 的 JPQL 改為 Specification / Criteria 或由應用注入;下游 @SpringBootTest 冒煙測試可攔(context 載入即失敗) |
| 判準零反向套用被過度延伸,把 jar 的機制實作也趕下去 | 高 | 低 | 判準零的判斷句仍是否定式且雙向對稱:「移進/留在 jar,消費端會失去宣告權嗎?」不會 → capability。方案 C 即被此判準否決,已存證 |
影響 (Consequences)
正面影響
- ➕
ownership專案(ict)不再有 5 張帶死欄位的表;/remove-multi-tenancy的步驟隨之縮短 - ➕ jar 的 6 個
@Entity消失後,10-java.md的設計原則與實況一致 - ➕ 應用可依自己的 DB 選擇約束表達,不受 jar 的 JPA 表達力限制
- ➕ 「nullable 欄位偷渡第二語意」這個病灶被立成規則(決策五、六),不只修這一次
負面影響
- ➖ 三家下游的
NotificationConfig與相關 DTO / Service 須改(app-server≫ict≫tts) - ➖ 升級需資料搬遷(
dedupe_key/locale回填、全域範本搬表)
中性影響
- 🔸
@EntityScan/@EnableJpaRepositories的 basePackages 由「列舉 jar 套件」改為「應用自己的套件」——機制不變,只換值 - 🔸 範本解析優先序語意不變,只是改由 app 組裝 resolver 鏈
- 🔸
tts-server幾乎不受影響(零 entity import)
實作指南 (Implementation Guidelines)
必須遵守的規則
- jar 不得新增
@Entity;需要持久化能力時出@MappedSuperclass+ SPI(決策一,ArchUnit 強制)。 - jar 不得出現裸
tenant_id,亦不得以字串組合等手段表達租戶範圍;租戶歸屬一律@TenantId(決策二,ArchUnit 強制其一)。 - jar 不得假設租戶欄位存在;需要租戶時經
TenantAware介面探詢,缺席即視為無租戶(決策三)。 - SPI 簽章不得出現具體
@Entity(ADR-014 規範一;本 ADR 結清NotificationDeliveryListener這一筆)。 @MappedSuperclass不得自帶@Table約束假設——約束屬應用(判準零)。- 進 unique 約束的欄位一律
NOT NULL(決策五)。 - nullable 欄位不得偷渡第二個概念(決策六);是獨立概念就給它獨立的表或欄位。
建議的最佳實踐
- 應用層 entity 命名沿用 jar 的 base 去
Base後綴,減少 JPQL / 文檔的認知落差。 tenant專案的 uk 首欄放tenant_id(此時它NOT NULL,語意在所有 DB 一致);ownership專案的 uk 直接省略該欄。- 遷移單一實體時,先搬 entity + repository,跑通下游
@SpringBootTest再動 resolver / filter。 - 設計 uk 前先問兩個問題:「這一欄可能為 null 嗎?」(→ 決策五)「這個 null 是不是在表達另一個概念?」(→ 決策六)。各 DB 對「unique 中的 NULL」語意分歧(標準/MySQL/PG 視 NULL 互不相等;SQL Server 視 NULL 相等;Oracle 對部分為 null 的複合鍵另有規則),依賴任何一種都是在賭部署的 DB。
檢查清單
決策五 · dedupe_key(可獨立先行,不依賴以下任何階段) ✅ 2026-07-15
- jar
NotificationOutboxBase.dedupeKey改@Column(nullable = false) -
NotificationService.composeDedupeKey未給dedupeKey時改填每列專屬唯一值(取代回null);確認 app 層去重行為等價 - 既有 NULL 列的一次性回填語句 + CHANGELOG
Breaking Changes條目 - 回歸測試:兩則無
dedupeKey→ 皆進;同dedupeKey×2 → 第二則擋(H2MODE=MySQL / PostgreSQL / MSSQLServer / Oracle 四模式各驗;隨階段 2 移住app-server的NotificationOutboxDedupeConstraintTest——schema 歸應用後,約束形狀的回歸隨 schema 走)
階段 1(template / preference / inapp) ✅ 2026-07-16(框架 + app-server 參考實作;下游同步隨 /upgrade-appfuse-server)
- jar 出
NotificationTemplateBase/NotificationPreferenceBase/InAppNotificationBase(@MappedSuperclass,無 tenant) - 決策六:
NotificationTemplateBase的locale改 NOT NULL(wildcard 哨兵ANY_LOCALE = "*");DbNotificationTemplateResolver泛型化為「查一張表」 - 決策六:
app-server宣告NotificationTemplate(@TenantId)+GlobalNotificationTemplate(無 tenant),NotificationConfig組裝 resolver 鏈(租戶表 → 全域表 → NLS;root 無 context 時跳過租戶表) - 決策六:
ownership下游(ict)只宣告全域表,鏈為(全域表 → NLS)——隨下游升級 - 決策六:既有
tenant_id IS NULL的全域範本列搬遷語句(CHANGELOG Migration ③) - Repository 改
@NoRepositoryBean基底介面,泛型化 entity 型參 -
DbNotificationPreferenceFilter改依賴泛型 repository -
app-servercore slice 新增具體@Entity+ repository(tenant專案:加@TenantId) -
app-server的NotificationConfig@EntityScanbasePackages 改指應用套件 -
ict/tts同步(ict另需改NotificationTemplateResponse/NotificationTemplateService兩處 import)——隨下游升級 - CHANGELOG
[Unreleased]加Breaking Changes條目(依m-changelog-format.md)
階段 2(outbox / delivery-log) ✅ 2026-07-16
-
NotificationDeliveryListener.onAttempt簽章改OutboxView介面(NotificationOutboxBase直接實作) - jar 的 JPQL 字串去除寫死的 entity name(
NotificationOutboxRepositoryBase以 SpEL#{#entityName};jar 測試以 test-onlyTestNotificationOutbox驗證對應用型參正確解析) - jar 的去重查詢改
findByDedupeKey(單欄);租戶範圍由@TenantIdfilter 自動達成已實測(app-server的NotificationOutboxTenantScopeIT:本租戶命中/他租戶不命中/root 跨租戶命中) -
NotificationOutboxDispatcher改經TenantAware探詢租戶(root 哨兵__root__不runAs,保留舊「無租戶直接遞送」語意) - 「擋太少」回歸測試:
ownership形狀(無tenant_id,uk 單欄)+tenant形狀(@TenantIdNOT NULL)各驗 uk 真正生效(NotificationOutboxDedupeConstraintTest四模式)
本階段不需在真實 Oracle / SQL Server 覆核:加上決策五後 uk 中已無 nullable 欄位,NULL 語意差異不再適用 ⇒ 沒有東西可覆核。決策五自身的四模式回歸測試即為其驗證。
階段 3(audit) ✅ 2026-07-16
-
AuditEventRecord同前(AuditEventRecordBase+AuditEventJpaRepositoryBase<T>+PersistentAuditEventRepository<T>泛型化;app-server 的 audit reference domain 宣告具體 entity,AuditPersistenceConfig不再掃框架套件);與 ADR-013 R1 的界線一致(sink 仍領域無關、無租戶欄)
機械保證 ✅ 2026-07-16
- ADR-014 規範三新增兩條 ArchUnit(決策四;jar 測試
FrameworkPersistenceArchitectureTest),先紅後綠已驗(規則②初版曾抓到 57 個欄位——fluent.or()頂層綁定的假陽性,修為顯式 predicate 後綠;規則①在三階段完成前對舊 jar 必紅、完成後綠)。規則②實作依「裸tenant_id欄位」的持久語意收斂範圍至@Entity/@MappedSuperclass類(jar 另有非持久的tenantId欄位:OAuth2 的 Microsoft 365 租戶、mail 認證 builder,屬不同概念)
相關文檔 (References)
內部文檔
- 多租戶設計指南:
../guides/design/multi-tenancy.md - 事件通知 / 遞送指南:
../guides/services/notification.md - 排程紀律(root 讀寫權界):
app-server/.claude/rules/20-scheduling.md - 資料隔離策略(
defaultDataIsolation):m-server-common.md - 框架設計原則(工具集而非預設實作):
appfuse-server/.claude/rules/10-java.md
外部資源
- Oracle Database Concepts — Data Integrity(partially null composite unique key 的官方語意)
- NULL complexities – Part 4, Missing standard unique constraint(SQL Server 的 NULL-in-unique 語意)
相關 ADR
- ADR-014:主要依據。本 ADR 反向套用其判準零、結清其規範一的一筆違反、擴充其規範三
- ADR-016:
@TenantId機制;本 ADR 補完其「唯一性」宣告,並兌現其「不需任何人手動處理」的承諾(決策三) - ADR-010:本 ADR 遷移的 5 個 entity 的來源
- ADR-013:其「界線修訂 R1」是判準零的先例;
AuditEventRecord(階段 3)屬其界線 - ADR-001:
tenant_id欄位策略(維持不變) - 方法論 ADR-004:tenant-neutral 宣稱;本 ADR 使其在 jar 層成立
- 方法論 ADR-006:feature slice 模型;遷移後的 entity 落在 core slice
變更歷史 (Change Log)
| 日期 | 變更內容 | 變更者 |
|---|---|---|
| 2026-07-15 | 初版(起因:FU-56 的實測結果) | Development Team |
| 2026-07-15 | 修訂:補上約束軸(決策五)。初稿把「歸屬軸」與「約束軸」壓成一軸,誤稱歸屬軸可一併解掉「擋太多」;實測推翻——tenant_id 非 null 時 SQL Server / Oracle 仍擋第二列,兇手是 dedupe_key | Development Team |
| 2026-07-15 | 重寫:確立兩條原則為決策主體。①「把租戶折進組合鍵」之解雖能完整關掉 FU-56 且無需遷移,但違反原則一(第二套租戶機制),否決並存證於方案 A ②新增決策三的關鍵推導(兩原則互相成全:jar 不知租戶,@TenantId filter 自動補回範圍)③新增決策六(全域範本拆表)④決策五擴及 NotificationTemplate.locale(同樣以 NULL 偷渡 wildcard 語意) | Development Team |
| 2026-07-16 | 框架側全數落地(決策五 + 階段 1/2/3 + 決策四 ArchUnit;檢查清單打勾並補實作註記)。驗證:appfuse-server 707/0/5skip、app-server 394/0。決策三關鍵推導經 NotificationOutboxTenantScopeIT 實測成立。剩下游(ict / tts)同步,隨 /upgrade-appfuse-server | Development Team + AI Assistant |
文檔維護者: Development Team + AI Assistant 最後審閱: 2026-07-15