ADR-014: 框架能力與參考接線碼的對偶(capability ↔ core slice)
ADR 編號: 014 狀態: 已接受 (Accepted) 決策日期: 2026-07-10 決策者: Development Team 取代: 無 被取代: 無
摘要
appfuse-server 刻意不採 autoconfiguration,因此每個框架能力(capability)都必須配一份住在消費端的參考接線碼。本 ADR 把這個至今隱含的義務顯性化為框架開發規範:先立歸屬判準(給定一段碼,它屬 jar 還是屬參考實作),再據以定三條規範——SPI 邊界義務、core slice 對偶義務、中性不變量(以 ArchUnit 強制)。
背景 (Context)
問題陳述
「不做 autoconfiguration」是框架的核心設計決策——為了不對消費端「如何使用框架」施加偏見,框架只提供能力與 SPI,由消費端自行組合與配置。這個決策把組合的成本外部化:能力本身不可用,要能用必須有接線碼。
於是每個能力事實上有兩半:
| 半邊 | 住哪 | 例 |
|---|---|---|
| capability | appfuse-server jar | NotificationService、RecipientResolver、MailDelivery、FileStorage |
| 參考接線碼 | 參考實作 app-server | NotificationConfig、EmailServiceMailDelivery、MailerService、FileStorageConfig |
問題是這個對偶從未被寫成規範,只靠慣性維持。三個後果:
- 能力可以在沒有接線碼的情況下發版——下游升級套件後拿到新能力,卻不知道怎麼接,也沒有可複製的範例。
- 接線碼可以在沒有 SPI 的情況下寫成——能力若未以介面暴露組合點,接線碼只能硬接框架實作類,消費端無從客製。
- 接線碼可以悄悄長出業務依賴——
io.leandev.app.notification目前 importentity.order.Order、entity.customer.Customer、service.order.OrderService。這片業務碼混在「notification 的參考接線」裡,使該 feature 無法被單獨複製到新專案。
第三點是 方法論 ADR-006 要解的問題(把參考實作切成可選、可增補的 feature slice)。而該 ADR 的成立依賴框架端的紀律:若框架持續產出無 SPI 的能力、或允許接線碼與業務糾纏,feature slice 就切不乾淨。本 ADR 補上那條紀律。
限制條件
- 不得引入 autoconfiguration——那會推翻「不施加偏見」的原始決策,且組合點(
NotificationConfig這類)正是各專案需要客製之處。 - 規範必須有機械檢查,否則會退化成「會漂移的手動宣稱」。
- 既有能力已大量存在,規範須可漸進套用,不能要求一次補齊。
假設前提
- 參考實作模組(
app-server)將依方法論 ADR-006 重構為 package-by-feature,每個 feature 二分為 core slice(中性)與 wiring slice(業務、@reference-surface)。⚠️ ADR-006 D-B 更正(2026-07-17)廢除 wiring slice:feature package 整包即 core slice,業務接線住消費端的業務域。本 ADR 下文凡提{feature}/demo/者皆已無對象;規範三的中性不變量因此無例外。 - 框架與參考實作在同一個 workspace 開發,可跨模組加測試。
考量的方案 (Options Considered)
方案 A: 維持現狀(慣性)
說明: 不寫規範,靠 review 與慣性維持能力與接線碼的同步。
優點:
- ✅ 零成本
缺點:
- ❌ 方法論 ADR-006 的 core slice 中性不變量無人守,重構後會再次腐化
- ❌ 新能力可能無 SPI、無接線碼發版,下游拿不到用法
- ❌ 業務洩漏進接線碼是靜默的,目前的
@reference-surfacepartition 掃不到頂層 package
評分: 1
方案 B: 改用 autoconfiguration,消滅接線碼
說明: 框架提供 @AutoConfiguration,消費端零接線即可用。
優點:
- ✅ 對偶問題消失——沒有接線碼就沒有接線碼的紀律問題
- ✅ 下游升級即得新能力
缺點:
- ❌ 直接推翻框架的核心設計決策
- ❌ 組合點(channel 組裝、storage 後端選擇、resolver 接線)正是各專案必須客製之處,autoconfiguration 只能提供預設,客製時仍要覆寫,反而多一層間接
- ❌ 把「該怎麼用」的偏見編進框架
評分: 2
方案 C: 顯性化對偶,立歸屬判準 + 三條規範 + ArchUnit 強制(採用)
說明: 保留不做 autoconfiguration 的決策,先立「碼屬 jar 還是屬參考實作」的歸屬判準,再把「能力必有 SPI、必有 core slice、core slice 必中性」寫成框架開發規範,其中中性不變量以 ArchUnit 測試強制。
優點:
- ✅ 保留原始設計決策
- ✅ 為方法論 ADR-006 的 feature slice 提供結構保證
- ✅ 中性不變量是機械可驗證的,不是宣稱
- ✅ 歸屬判準讓「重構
appfuse-server」有可執行的依據,而非逐案裁決 - ✅ 可漸進套用(新能力立即受約,既有能力隨遷移補齊)
缺點:
- ❌ 前兩條(SPI 義務、對偶義務)是流程義務,只能靠 checklist 與 review,無法機械強制
- ❌ 新增能力的成本上升:必須同時寫 SPI、接線碼、CHANGELOG、catalog entry
評分: 4
決策 (Decision)
選擇方案: C
框架開發規範新增一條歸屬判準 + 三條規範,落點 appfuse-server/.claude/rules/(與 30-public-api.md 同層),並掛入 m-changelog-format.md 的新增能力檢核清單。
判準零:capability / core slice / 業務域的歸屬(原「wiring slice」,見 ADR-006 D-B 更正)
三條規範都預設「這段碼屬於哪一半」已有答案。規範二尤其如此——它要求新增能力時同時交付 capability 與 core slice,卻沒說給定一個類別,它該落在哪邊。而「重構 appfuse-server」這件事的全部內容,正是回答這個問題。故判準先行。
判斷句:這段碼若移進 jar,消費端會失去任何實質的宣告權或替換權嗎?
- 不會 → capability(
appfuse-serverjar)- 會 → core slice(參考實作,中性)
- 帶業務語意 → 業務域(該業務自己的 package,
@reference-surface;原為{feature}/demo/的 wiring slice,已由 ADR-006 D-B 更正廢除)
「宣告權/替換權」是實質且可檢驗的,不是文體偏好:
| 落點 | 為何消費端不可失去 |
|---|---|
Spring bean 的組裝(@Bean 方法、configurer) | 組裝方式即「如何使用框架」,正是本框架拒絕 autoconfiguration 的理由 |
@ConfigurationProperties 的欄位 | 下游會為自訂接線加自己的欄位 |
| Entity 的表結構 | schema 屬應用的資料庫 |
| Controller 的 URL | 屬應用的對外契約面 |
| 對框架 SPI 的具體 implementation 選擇 | 選了哪個實作是消費端的決定 |
反之,純委派、無選擇、無宣告的機制實作應上移 jar——把它留在參考實作只會讓每個下游各持一份無增值的複本,並誤導下游以為那是自己的組合面。
已知的誤置(重構 appfuse-server 的第一批對象):
| 現況 | 判準判讀 | 處置 |
|---|---|---|
TokenBlacklistService(app-server,46 行) | 三個方法全為對框架 TokenBlacklistStore 的純委派,唯一增值是一行 log.debug;移進 jar 不剝奪任何宣告權或替換權 | 刪除,消費端直接注入 TokenBlacklistStore |
MailSetting(entity)、MailSettingController | 表結構與 URL 屬應用 | 留 core slice |
EmailServiceMailDelivery | 消費端對框架 MailDelivery SPI 的一個實作選擇 | 留 core slice |
判準亦追認 ADR-013「界線修訂 R1」(框架把通用持久稽核 sink 升為 opt-in capability;Feature 提供 in-memory 預設,需要持久化時才由 reference domain 接線)為規則的 實例,而非一次性裁決。
與規範一的關係:規範一(組合點必須是介面)是本判準的特例——組合點之所以必須留在消費端手上並以介面暴露,正是因為它承載替換權。判準界定「哪一半」,規範一界定「留在消費端那一半要長成什麼形狀」。
規範一:SPI 邊界義務
每個對外能力必須以介面暴露其組合點。
消費端需要替換或注入的每一個決策點,都必須是介面而非具體類。notification 已是良好範例:RecipientResolver、NotificationTemplateResolver、NotificationDeepLinkProvider、MailDelivery、SmsDelivery、LineDelivery 全為介面,故 NotificationConfig 得以只組裝、不改寫框架。
判斷句:這個能力有沒有任何「不同專案會做不同選擇」的點?有 → 該點必須是介面。
無 SPI 的能力不得發版——接線碼會被迫硬接實作類,消費端失去客製空間,等同偷渡了 autoconfiguration 的偏見卻沒有它的便利。
技術債的第一個結清案例:acting(delegation / impersonation)
「既有能力補齊 SPI」是一筆技術債(見「我們放棄什麼」)。acting 是最乾淨的示範案例,建議優先結清。
框架側的 capability 齊備:ActingContext、ActingClaims、
JwtTokenProvider.generateImpersonationToken / generateDelegationToken 與
ActingAuditInterceptor 事件解算。Feature 以 ActingConfig 提供預設 bean;參考實作側保留
ImpersonationService、DelegationService、DelegationGrant、ActingController 與
ActingWebConfig 的 URL policy,故規範二的對偶關係成立。
但這組 feature 沒有任何 *Config。表面上看像是「它沒有組合點」,實際上是組合點被硬編——套用規範一的判斷句「有沒有『不同專案會做不同選擇』的點」,逐一檢視:
| 硬編處 | 現況 | 為何是專案選擇 |
|---|---|---|
ImpersonationService.CROSS_TENANT_AUTHORITY | 私有常數 "impersonation:cross-tenant" | authority 命名與跨租戶政策因專案而異 |
ImpersonationService.candidates() | 池政策寫死(同租戶全體 / 具跨租戶權限則全體,排除自己與服務帳號) | 無「哪些角色不可被模擬」的鉤子——目前超級管理員亦可被模擬 |
DelegationService.candidates() | 寫死「同租戶、未重複授權」 | 同上 |
DelegationGrant | 無期限、無可否轉授概念 | 合規敏感的專案幾乎必然要求到期日 |
這些正是規範一要求以介面暴露的組合點(形狀上約為 ActingPolicy、ActingCandidateProvider)。一旦抽出,ActingConfig 會自然長出來把它們裝起來,該 feature 也就取得它應有的 seam。
推論:
*Config缺席是症狀而非判準。它不否證該能力的 feature 資格(見 方法論 ADR-006 D-A),它揭露的是規範一尚未被滿足。審視既有能力時應以規範一的判斷句為準,不以「有沒有 Config」為準。
規範二:core slice 對偶義務
新增框架能力時,必須在同一個發版週期內同時交付:能力(jar)、對應的 core slice(
app-server)、features.jsonentry、CHANGELOGAdded條目。
能力與其參考接線碼同時發版。否則下游升級套件後拿到一個沒有用法的能力,而 /feature add 也無 slice 可裝。哪些檔進 jar、哪些進 core slice,依判準零裁決,不逐案協商。
對偶關係:
appfuse-server/…/{capability}/ ←→ app-server/…/{feature}/ (core slice,整包)
介面 + 實作 Config + Properties + 接線
app-server/…/{business}/ (業務域)
業務碼 ∪ 對 feature SPI 的接線
{feature} 不必與 {capability} 一對一——一個 feature 可組合多個 capability(notification 用到 notification + mail + nls)。相依關係由 features.json 的 requires 宣告。
規範三:中性不變量(機械強制)
① 框架不得 import app 層。② core slice 不得 import 業務 package。
以 ArchUnit 測試強制,違反即 build 失敗:
// appfuse-server 側
noClasses().that().resideInAPackage("io.leandev.appfuse..")
.should().dependOnClassesThat().resideInAPackage("io.leandev.app..")
// app-server 側:core slice 不得依賴業務 package——**無例外**
// (原有的 `.and().resideOutsideOfPackage(".{feature}.demo..")` 豁免子句已隨
// wiring slice 類別廢除;業務對 feature 的接線住業務域,方向為業務 → feature)
noClasses().that().resideInAPackage("io.leandev.app.{feature}..")
.should().dependOnClassesThat().resideInAnyPackage(BUSINESS_PACKAGES)
BUSINESS_PACKAGES 由 features.json 推導——凡不是 feature 的 app 層 package 即業務 package。推導、不手維護清單,對齊 m-methodology-sync.md 不變量一與 m-module-inventory.md 的既有治法。
這是本 ADR 唯一的機械保證。規範一與規範二是流程義務,靠 checklist 與 review 承擔。
權衡分析 (Trade-offs)
我們獲得什麼 (Gains)
- ✅ 保留「不做 autoconfiguration」的核心設計決策,同時消除它的隱性成本
- ✅ 方法論 ADR-006 的 feature slice 有了結構保證,不會重構完又腐化
- ✅ 業務洩漏進接線碼從「靜默」變成「build 失敗」
- ✅ 新能力必然帶著可複製的用法抵達下游
我們放棄什麼 (Losses)
- ❌ 新增框架能力的成本上升(SPI + core slice + catalog + CHANGELOG 四件套)
- ❌ 既有能力的 SPI 補齊是一筆技術債,需隨 feature 遷移逐一結清(首個結清目標見規範一的 acting 案例)
- ❌ ArchUnit 只守得住「中性」,守不住「有沒有寫 core slice」
風險與緩解措施 (Risks & Mitigations)
| 風險 | 嚴重性 | 機率 | 緩解措施 |
|---|---|---|---|
| 判準零、規範一、二無機械強制,久之流於形式 | 中 | 中 | 掛入 m-changelog-format.md 的新增能力檢核清單;/publish 預檢時對 [Unreleased] 的 Added 條目提示確認對應 core slice 是否存在 |
| 判準零被用來把「宣告權模糊」的碼一律上移 jar,侵蝕消費端組合空間 | 高 | 低 | 判斷句是否定式(「會失去嗎」),舉證責任在上移方;存疑即留 core slice。上移屬公開 API 擴張,受 30-public-api.md 與 deprecation cycle 約束 |
ArchUnit 的 BUSINESS_PACKAGES 推導與 features.json 不同步 | 中 | 低 | 推導自 features.json,不另立清單;catalog 是唯一 SoT |
| 既有能力補 SPI 造成公開 API 破壞性變更 | 中 | 中 | 依 03-versioning.md 的 deprecation cycle 處理;補 SPI 多為新增介面 + 既有類 implements,通常非破壞性 |
| — | — | ✅ 風險消滅:ADR-006 D-B 更正廢除了 wiring slice,規範三的中性不變量無例外可鑽——core slice 一律不得 import 業務 package,ArchUnit 無豁免子句 |
影響範圍
| 對象 | 影響 |
|---|---|
appfuse-server 開發者 | 新增能力時多三項義務;既有能力隨遷移補 SPI;依判準零逐一裁決既有碼的歸屬(首批見「已知的誤置」表) |
app-server | 依方法論 ADR-006 重構為 package-by-feature,core/wiring 二分;判準零判定為 capability 的碼上移 jar 後,其在此的複本刪除 |
| 下游應用 | 無立即影響;重構完成後可經 /feature add 補裝能力、/feature sync 取得改良 |
30-public-api.md | SPI 介面納入公開 API 範圍,受 deprecation cycle 約束 |
與其他文件的關係
| 文件 | 關係 |
|---|---|
| 方法論 ADR-006 | 對偶 ADR:該 ADR 定參考實作側的 feature slice 模型(core/wiring 二分、package-by-feature、add + sync);本 ADR 定框架側必須遵守的紀律,使其成立。其 D-C 推論(自足性) 是本 ADR 規範一的鏡像:規範一對框架要求「組合點必須是介面」,D-C 推論對參考實作要求「註冊點必須是 bean 貢獻,不寄生模組級 WebConfig / SecurityConfig」——兩者皆為使組合可加可減,而不必改動他人的檔案 |
m-reference-code.md | core slice(feature package 整包)不標記、業務域整包標記 @reference-surface;規範三的 ArchUnit 是其 partition 規則的機械補強 |
m-changelog-format.md | 規範二的對偶義務掛入新增能力的檢核清單 |
| ADR-013 | 其「界線修訂 R1」(通用持久稽核 sink 上移為 opt-in 服務、audit Feature 提供 in-memory fallback、應用 reference domain 以 AuditPersistenceConfig 選用持久化)是判準零的先例;本 ADR 把該次裁決追認為規則的實例。ADR-013 的 acting 能力則是規範一 SPI 技術債的首個結清目標 |
| ADR-017 | 本 ADR 的反向套用 + 三處補洞。①判準零盲點:本 ADR 的判斷句與「已知的誤置」表只單向(app → jar),從未反向稽核 jar 既有的碼——jar 的 6 個 @Entity 因此漏網,而依判準零「Entity 的表結構 → schema 屬應用的資料庫」它們全部誤置。②規範一的未察覺違反:本 ADR 讚許 notification 為 SPI 良好範例,但 NotificationDeliveryListener.onAttempt(NotificationOutbox, …) 把具體 @Entity 洩漏進已發布的 SPI 簽章。③規範三的涵蓋缺口:兩條 ArchUnit 皆擋不住 jar 長出 @Entity 或裸 tenant_id,ADR-017 補上兩條 |
30-public-api.md | 規範一產出的 SPI 介面屬公開 API;判準零判定上移 jar 的碼亦然,故上移是公開 API 擴張 |
03-versioning.md | 既有能力補 SPI、或依判準零上移碼進 jar 時的 deprecation cycle 依據 |