跳至主要内容

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,由消費端自行組合與配置。這個決策把組合的成本外部化:能力本身不可用,要能用必須有接線碼。

於是每個能力事實上有兩半:

半邊住哪
capabilityappfuse-server jarNotificationServiceRecipientResolverMailDeliveryFileStorage
參考接線碼參考實作 app-serverNotificationConfigEmailServiceMailDeliveryMailerServiceFileStorageConfig

問題是這個對偶從未被寫成規範,只靠慣性維持。三個後果:

  1. 能力可以在沒有接線碼的情況下發版——下游升級套件後拿到新能力,卻不知道怎麼接,也沒有可複製的範例。
  2. 接線碼可以在沒有 SPI 的情況下寫成——能力若未以介面暴露組合點,接線碼只能硬接框架實作類,消費端無從客製。
  3. 接線碼可以悄悄長出業務依賴——io.leandev.app.notification 目前 import entity.order.Orderentity.customer.Customerservice.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-surface partition 掃不到頂層 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,消費端會失去任何實質的宣告權替換權嗎?

  • 不會capabilityappfuse-server jar)
  • 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 已是良好範例:RecipientResolverNotificationTemplateResolverNotificationDeepLinkProviderMailDeliverySmsDeliveryLineDelivery 全為介面,故 NotificationConfig 得以只組裝、不改寫框架。

判斷句:這個能力有沒有任何「不同專案會做不同選擇」的點?有 → 該點必須是介面。

無 SPI 的能力不得發版——接線碼會被迫硬接實作類,消費端失去客製空間,等同偷渡了 autoconfiguration 的偏見卻沒有它的便利。

技術債的第一個結清案例:acting(delegation / impersonation)

「既有能力補齊 SPI」是一筆技術債(見「我們放棄什麼」)。acting 是最乾淨的示範案例,建議優先結清。

框架側的 capability 齊備:ActingContextActingClaimsJwtTokenProvider.generateImpersonationToken / generateDelegationTokenActingAuditInterceptor 事件解算。Feature 以 ActingConfig 提供預設 bean;參考實作側保留 ImpersonationServiceDelegationServiceDelegationGrantActingControllerActingWebConfig 的 URL policy,故規範二的對偶關係成立。

但這組 feature 沒有任何 *Config。表面上看像是「它沒有組合點」,實際上是組合點被硬編——套用規範一的判斷句「有沒有『不同專案會做不同選擇』的點」,逐一檢視:

硬編處現況為何是專案選擇
ImpersonationService.CROSS_TENANT_AUTHORITY私有常數 "impersonation:cross-tenant"authority 命名與跨租戶政策因專案而異
ImpersonationService.candidates()池政策寫死(同租戶全體 / 具跨租戶權限則全體,排除自己與服務帳號)無「哪些角色不可被模擬」的鉤子——目前超級管理員亦可被模擬
DelegationService.candidates()寫死「同租戶、未重複授權」同上
DelegationGrant無期限、無可否轉授概念合規敏感的專案幾乎必然要求到期日

這些正是規範一要求以介面暴露的組合點(形狀上約為 ActingPolicyActingCandidateProvider)。一旦抽出,ActingConfig 會自然長出來把它們裝起來,該 feature 也就取得它應有的 seam。

推論*Config 缺席是症狀而非判準。它不否證該能力的 feature 資格(見 方法論 ADR-006 D-A),它揭露的是規範一尚未被滿足。審視既有能力時應以規範一的判斷句為準,不以「有沒有 Config」為準。

規範二:core slice 對偶義務

新增框架能力時,必須在同一個發版週期內同時交付:能力(jar)、對應的 core slice(app-server)、features.json entry、CHANGELOG Added 條目。

能力與其參考接線碼同時發版。否則下游升級套件後拿到一個沒有用法的能力,而 /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.jsonrequires 宣告。

規範三:中性不變量(機械強制)

① 框架不得 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_PACKAGESfeatures.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,通常非破壞性
wiring slice 的邊界被濫用(把不該進 demo 的碼塞進去躲檢查)✅ 風險消滅: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.mdSPI 介面納入公開 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.mdcore 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 依據