跳至主要内容

ADR-006: 參考實作的 feature 切片與同步(feature slice)

ADR 編號: 006 狀態: 已接受 (Accepted) 決策日期: 2026-07-10 決策者: AppFuse Team + AI Assistant 範圍: docs-methodology(scaffold 與參考實作的組織);連動 appfuse-server 框架開發規範 後續修訂: ADR-012 取代 D-D 的 source-side broadcast sync 與 seam「只顯示 diff」終態;Feature partition、core 中性、seam ownership 與 provision 繼續有效


摘要

appfuse-server 刻意不採 Spring Boot autoconfiguration——為了降低對消費端「如何使用框架」的偏見,框架只提供能力(capability)與 SPI,由消費端自行組合與配置。代價是:每個框架能力都需要一份「參考接線碼」(如 mail 的 MailConfig / EmailService / MailerService / MailSetting),而這些接線碼目前全部堆在單一參考實作模組 app-server 裡。

/scaffold-modulecp -r 一次複製整個模組,於是每個新專案都被迫帶走全部功能的參考實作;而既有專案在框架長出新能力時,沒有任何管道把新的參考實作補裝進來

更深一層的問題是:這些接線碼與花店業務碼在結構上沒有分界線——io.leandev.app.notification 裡的 OrderNotificationRecipientResolver 直接 import entity.order.OrderNotificationConfig 直接 import 它。即使今天就想把 notification 當作一個單位複製,也複製不出去。

本 ADR 引入 feature slice 作為參考實作的第一級組織單位,定下五條原則:feature 為第一級概念且 SoT 在參考實作模組;每個 feature package 整包即中性的 core slice(業務接線住消費端的業務域;原「二分為 core/wiring slice」已於 2026-07-17 修訂廢除 wiring 類別,見 D-B 的更正節);slice 邊界以 package-by-feature 表達為編譯器事實(規定範圍已於 2026-07-22 縮至 feature/——業務層佈局改由專案在 businessLayout 自選,見 D-C 的修訂節);生命週期支援 provision + syncsource-to-target——由框架駕駛座驅動下游 fleet,比照 /methodology/practices/scaffold-*),並以「組合面(*Config / *Properties)為 seam」界定覆蓋邊界;框架側須遵守 capability ↔ core slice 對偶的開發規範(另立 server ADR-014)。

本 ADR 只定模型與原則,實作細節(catalog schema 定稿、逐 feature 遷移順序、/feature skill)defer 到 phase 2——比照 ADR-005 的 phase-1 / phase-2 分工。


背景 (Context)

問題陳述

盤點 app-server 現況,得到三個事實:

事實一:feature 的檔案是橫切的,且沒有任何目錄邊界等於一個 feature。

以 mail 為例,其足跡散落在七處以上:

位置檔案
config/MailConfigMailProperties
actuator/MailEndpointMailHealthIndicator
controller/mail/MailSettingControllerMailTestController
entity/mail/MailSetting
repository/mail/MailSettingRepository
service/mail/EmailServiceMailerServiceMailHealthCheckService
notification/EmailServiceMailDelivery
非 Javaapp-server.yml 設定段、messages*.properties 訊息鍵、四個測試檔

事實二:feature 的參考實作與業務領域糾纏。

io.leandev.app.notification 這個 package——依角色本應是「notification 能力的參考接線」——實際 import 了:

io.leandev.app.entity.order.Order
io.leandev.app.entity.customer.Customer
io.leandev.app.service.order.OrderService

其中 OrderNotificationRecipientResolverOrderNotificationServiceLowStockRecipientResolver純業務碼。而 NotificationConfig(組合點)直接 import 前兩者。同樣的糾纏出現在 FileStorageConfigapp.service.file.DatabaseFileStorage

事實三:這片業務洩漏對 @reference-surface 機制是隱形的。

m-reference-code.md 的 partition 規則只掃 **/controller/{businessDomain}/**/entity/{businessDomain}/io.leandev.app.notification.* 是頂層 package,兩者皆不是,因此 OrderNotificationRecipientResolver 永遠不會被 stamp、也永遠不會被 retarget——下游會靜默繼承花店的收件人解析邏輯。

2026-07-10 的全模組盤點另揭露第二個盲區,且更隱蔽controller/base/ReferenceDataController import 了 entity/order/OrderStatusentity/sales/ProductStatusservice/sales/ProductCategoryService。它落在 controller/{businessDomain}/ 的形狀內,但 base 正是 m-reference-code.md 明列為 framework-feature、不標記的套件——於是這片洩漏連 @reference-surface 都不會蓋上。兩個盲區成因相同:partition 規則靠目錄慣例推斷業務性,而業務性其實寫在 import 裡。D-E 的 ArchUnit 不變量正是為此而設。

核心張力

「不做 autoconfiguration」是一個有意識的、正確的框架設計決策:autoconfiguration 會把「該怎麼用」的偏見編進框架,而 appfuse 選擇讓消費端組合。但這個決策把組合的成本外部化到參考實作——參考實作因此必須同時扮演三個角色:

  1. 框架能力的使用範例(mail 該怎麼接)
  2. 可運行的完整 demo(花店)
  3. scaffold 種子(新專案的起點)

角色 1 要求「按 feature 可選、可增補」;角色 2 要求「業務具體、可信」;角色 3 要求「複製後 retarget 乾淨」。三者在單一 monolithic 模組裡互相碾壓,cp -r 只好全都要。

這與 ADR-005 面對的張力同構:那裡是方法論知識混層(Tier 1/2/3),這裡是參考實作程式碼混層(框架接線 / 業務 demo / 骨架)。

既有機制的對位

既有機制管什麼本 ADR 的位置
/upgrade-appfuse-*框架套件版本不變——feature slice 是「怎麼用套件」,不是套件本身
@reference-surfacem-reference-code.md下游業務碼的 retarget 閘門本 ADR 讓其 partition 規則從「宣稱」變成「結構事實」
/practicesm-practices-sync.md模組層規則(Tier 3 practices)feature slice 是其姊妹:一個同步規則、一個同步框架接線碼
/methodologyworkspace 層方法論不變

「scaffold 一次性快照、之後無 re-sync 管道」——這正是 FU-19 對 workspace 層、ADR-005 對模組 practices 層描述過的同一個病,第三次出現,這次在參考實作程式碼層。


決策 (Decision)

D-A:feature 為第一級概念,SoT 在參考實作模組

新增 app-server/.claude/features.json,與 practices.json 同層、同性質(模組 manifest):

{
"role": "source",
"features": {
"auth": { "kind": "required", "requires": [] },
"cache": { "kind": "required", "requires": [] },

"acting": { "kind": "optional", "requires": [] },
"almanac": { "kind": "optional", "requires": ["cache"] },
"audit": { "kind": "optional", "requires": [] },
"calendar": { "kind": "optional", "requires": [] },
"delegation": { "kind": "optional", "requires": ["acting", "auth"] },
"file": { "kind": "optional", "requires": ["scheduling"] },
"impersonation": { "kind": "optional", "requires": ["acting", "auth"] },
"persistence-encryption": { "kind": "optional", "requires": [] },
"mail": { "kind": "optional", "requires": ["auth", "persistence-encryption"] },
"notification": { "kind": "optional", "requires": ["auth", "mail", "scheduling", "signed-link"] },
"platform-info": { "kind": "optional", "requires": [] },
"reference-data": { "kind": "optional", "requires": ["auth"] },
"scheduling": { "kind": "optional", "requires": [] },
"signed-link": { "kind": "optional", "requires": ["auth"] },
"tenant": { "kind": "optional", "requires": [] }
}
}
  • kind: required 的 feature 在 scaffold 時自動納入、不可取消。
  • requires/scaffold-module/feature provision 時解遞移閉包:選 notification 自動帶入 auth、mail、scheduling 與 signed-link;mail 再帶入 persistence-encryption。

上表為 2026-07-10 對 app-server 全部 Java 檔跑 import 圖推導、再逐檔核對使用處與 bean 供需的結果,非設計時的臆測。實際 catalog 另含 seams 欄(見 D-D),此處為求可讀而略。

feature id 為 kebab-case,package 名為其去連字號的形式reference-datareferencedata)——Java package 不得含連字號。確定性推導,不在 manifest 另立欄位;唯一性由 I3 檢查。

2026-07-28 修訂:scheduling 從 required 降為 optional。 它沒有任何 required 消費端,只有 optional 的 filenotification 透過 requires 帶入;把它列為 required 會迫使完全沒有背景任務的應用安裝排程與鎖表。SchedulingConfig 是應用擁有的組態 seam, ShedLockEntry 則是可同步的 canonical JPA schema mapping。

2026-07-31 修訂:新增 persistence-encryption optional Feature。 framework jar 只提供 PersistenceEncryptionTextCipher primitive;應用 Feature 擁有 root key ring binding。 mail 以 beanRequires 宣告依賴並使用固定 HKDF purpose,因此部署只需一組 root key。 notification 不為 Outbox protection 宣告直接依賴;business data encryption 由應用另外選配, 不是 reference implementation 的預設組裝。notification 仍會因直接依賴 mail 而在遞移閉包 中間接取得此 Feature。

requires 不能只靠 import 圖。 Spring 的依賴多半是 bean 型別供需,而型別常來自框架 jar:

bean 邊證據import 圖
almanac → cacheAlmanacConfig 宣告 @Bean Almanac almanac(CacheManager),bean 由 CacheConfig 提供零邊
file → scheduling三支 scheduler 用 @Scheduled,需 SchedulingConfig@EnableScheduling零邊
notification → schedulingNotificationOutboxPoller@Scheduled零邊

完整的 requires = import 邊 ∪ bean 供需邊。calendar → almanac 一度被列為「疑似」,實測為誤——CalendarConfig 只在註解裡說明「需要政府行事曆時可改以 almanac 為底座」,並無實際依賴。此即「疑似邊必須實測、不可憑註解推斷」的實例。

2026-07-24 修訂:service-account 是「core + attached reference domain」的 optional Feature。 M2M/API-key 認證機制仍在 auth capability;中性 bean 組裝位於 feature/serviceaccount,CRUD、token wire、secret 輪替與 API-key 資源面則由 catalog referenceDomains: ["serviceaccount"] 隨附。後者可含 controller/service/entity/repository, 但仍是可客製、不可盲 sync 的 reference implementation,不是 core。ownership source 在 Feature 選擇前套用 policy、設定與 seed overlay,未選 Feature 時再整域裁掉。

delegation / impersonation 為何是兩個 optional featureImpersonationService 的 javadoc 已明載「與 Delegation 完全獨立……讓下游可只 cherry-pick 模擬功能」——作者已預期兩者是可分開取用的單位,這即是 feature 的定義。兩者共用中性的 acting 能力:jar 承載 ActingContextActingAuditInterceptoracting Feature 承載預設接線與 ActingCandidate 契約,附帶 reference domain 的 ActingController/response wire。三者皆 optional:許多下游因合規或隱私顧慮不會啟用 impersonation。

「沒有 *Config 就不算 feature 嗎?」 不是。D-D 的 seam 規則是條件句(「若 feature 有組合面,該組合面永不覆蓋」),不是資格判準。資格由本節與 D-B 界定:框架有 capability、參考實作有一份只依賴框架 SPI 的中性接線碼。沒有組合面的 feature 等於沒有 seam,其 core slice 100% 可零分歧 sync——對 D-D 而言是最理想的情況。

但反向的問題有價值:「沒有 Config」究竟是因為真的沒有組合點,還是組合點被硬編了? delegation / impersonation 屬後者(可模擬對象池政策、impersonation:cross-tenant 字串常數、grant 無期限與轉授概念皆寫死在 service 內),這是 ADR-014 規範一的技術債,不是 feature 資格的否證。

  • 下游模組持有自己的 features.jsonrole: downstreaminstalled: [...]syncedSHA),語彙比照 practices.json

⚠️ 粒度更正(2026-07-17):「語彙比照 practices.json」這句只該套用在語彙,不該連粒度一起抄。practices 是「整個 .claude/ 命名空間一次全對齊」,模組級 syncedSHA 對它正確;但 feature 是逐個獨立 provisionADR-007 D-B:一次一個、可獨立提交、可獨立回退)。模組級 SHA 與機制粒度失配,會在「只 sync 部分 feature」時替其他 feature 謊稱已對齊,使其更新在 delta 計算中靜默消失。syncedSHA 已改為 per-featurefeatures[{id}].syncedSHA,無模組級欄位)。

為何 SoT 在 app-server 而非 appfuse-server:catalog 描述的是「這個模組裡哪些檔案構成哪個 feature」,本質是模組 manifest。框架不需要知道參考實作怎麼切。框架端的對應義務改由 D-E 的開發規範承擔。

D-B:feature package 整包即 core slice(原「二分為 core/wiring slice」,2026-07-17 修訂)

slice內容依賴標記同步
core只依賴框架 SPI 的中性接線碼(EmailServiceMailerServiceMailSettingEmailServiceMailDeliveryLoggingSmsDeliveryEnvSubjectPrefixTemplateResolver框架 primitive;同 feature 內部;requires 宣告的其他 feature可 add / sync(見 D-D)
wiring把 feature 接到業務領域的示範(OrderNotificationRecipientResolverOrderNotificationServiceLowStockRecipientResolver業務 entity/service@reference-surface隨業務碼走 retarget,不 sync

不變量core 不得 import 業務 package。這是機械可驗證的(見 D-E 的 ArchUnit 條)。

「core slice 既然中性,為何不乾脆上移 jar?」 這是 D-B 唯一容易被誤解的地方:中性不是上移的充分條件。判準是 ADR-014 判準零——

這段碼若移進 jar,消費端會失去任何實質的宣告權替換權嗎?不會 → capability(jar);會 → core slice。

core slice 的碼之所以留在參考實作,正因為它承載了消費端不可被剝奪的東西:MailConfig 的 bean 組裝、MailProperties 的可調欄位、MailSetting 的表結構、MailSettingController 的 URL、EmailServiceMailDelivery 這個「選用哪個 MailDelivery 實作」的決定。它們中性(不含業務語意)屬消費端(承載宣告權),兩者兼具才是 core slice。

反過來,中性且不承載宣告權的碼(如純委派 wrapper)根本不該存在於參考實作——它該上移 jar。這條在 D-B 的三分裡是隱含的第四類,由 ADR-014 判準零負責清理,不由本 ADR 的 core/wiring 二分承擔。

這個二分不是新發明——它就是 m-reference-code.md 已經宣稱、但因缺乏結構支撐而未成立的那條線(「framework-feature 套件不標記 vs business surface 標記」)。把 feature 化做對,等於順手讓那條規則從宣稱變成事實,並修復事實三揭露的 stamping 盲區。

更正:wiring slice 類別廢除,feature package 整包即 core slice(2026-07-17)

上表的二分被推翻——wiring 這一列不該存在。 實測後全面廢除,本節記錄理由與代價。

觸發:消費端得伸手進 feature package 刪東西

/prune-reference 剝離業務域 sales 時,notification/demo/LowStockRecipientResolver 是它的孤兒(該檔 import io.leandev.app.sales.SalesAuthority)。於是刪一個業務域,必須進入 feature package 挖——而 feature package 是框架同步的領土,消費端不該在裡面刪東西。

根因:demo/ 破壞了它自己的 SPI 要保護的不變量

DomainRecipientResolver 的 javadoc 寫得很白:

存在的理由是方向notification 是中性的 core slice,不得 import 業務 package(ADR-014 規範三的中性不變量)。各業務領域(訂單、庫存……)以本介面貢獻自己的解析器,composite 只認識介面。」

SPI 已經讓實作可以住在業務域了。 demo/ 卻把 import 業務 package 的實作放回 feature 裡,然後靠「wiring slice 是 D-B 認可的例外」把它合法化——那個例外不必要,且正好抵銷 SPI 的目的。上表本身即證據:I2 的規則要寫成「core 不得依賴業務 package({feature}/demo/ 不受此限)」,那個括號就是例外的成本。

上表另有一處自證:它把 OrderNotificationService 列為 wiring slice,但該檔一直住在 order/(業務域)。同一組接線被拆在兩個 package——一半在 notification/demo/、一半在 order/。二分沒有一條乾淨的線可劃。

修訂後的分類
類別內容依賴標記同步
core slicefeature package 整包feature 的全部碼框架 primitive;同 feature 內部;requires 宣告的其他 feature可 add / sync
業務域(殘餘)業務碼 ∪ 該業務域對 feature SPI 的接線自由(業務 → feature 為合法方向)@reference-surface隨業務碼 retarget/prune

不變量簡化為無例外core slice(=任何 feature package)不得 import 業務 package。ArchUnit 的 I2 不再需要 demo/ 例外。

實測(app-server,2026-07-17)
  • notification/demo/全模組唯一的 wiring slice——這個類別總共只有一個實例
  • 兩個 resolver 搬回消費端(sales/LowStockRecipientResolverorder/OrderNotificationRecipientResolver):NotificationConfig 零改動(它注入 ObjectProvider<DomainRecipientResolver>,介面而非具名)
  • 搬後:沒有任何 feature import 任何業務域customerdashboardordersales)——ADR-014 規範三的中性不變量成為全模組事實,而非帶例外的宣稱
  • app-server 431/0/0、I3 綠、--stamp-targets 自然涵蓋搬過去的兩檔(業務域殘餘規則)
代價

/scaffold-module 的「drop demo/」選項失去對象(見遷移順序第 7 步的註記)——scaffold 一律帶入業務域,而接線現在住在業務域裡,故新專案會拿到花店的接線範例,由 /prune-reference 或 retarget 處置。這與其他花店業務碼的處置一致,不再是特例。

D-C:slice 邊界以 package-by-feature 表達

放棄 layer-first 目錄(controller/mailservice/mail…),改為 feature-first:

io/leandev/app/
├── mail/ ← core slice,可獨立複製
│ ├── MailConfig.java ← seam 候選,待檢驗(見 D-D 的「更正」節)
│ ├── MailProperties.java ← 同上
│ ├── MailSetting.java
│ ├── MailSettingRepository.java
│ ├── EmailService.java
│ ├── MailerService.java
│ ├── MailSettingController.java
│ └── MailEndpoint.java
├── notification/ ← core slice 整包(無 demo/,見 D-B 更正)
│ ├── NotificationConfig.java ← seam 候選,待檢驗
│ └── ...core...
└── order/ ← business surface,@reference-surface
├── Order.java、OrderService.java…
├── OrderNotificationService.java ← 對 notification 的接線(業務 → feature,合法方向)
└── OrderNotificationRecipientResolver.java ← 同上;實作 notification 的 SPI

理由:slice 邊界必須是編譯器看得見的事實,不是 manifest 裡的宣稱。一份「哪些 glob 屬於 mail」的手維護清單,每新增一個檔案就可能漏一行——這正是 m-methodology-sync.md 不變量一(canonical 由命名空間推導、不手維護清單)與 m-module-inventory.md(從 project.json 生成、禁手改)反覆戒除的反模式。package 邊界讓「跨 feature 依賴」成為 import 語句,可被 ArchUnit 直接驗證。

features.json 因此只宣告 feature 的存在性、必要性、相依,以及 seam 例外(見 D-D),不宣告檔案清單——檔案集由 package 推導。這與 practices.json 只列 local 例外、不列 canonical 本體,是同一個治法:命名空間(此處為 package)定範圍,manifest 只列機器推不出來的少數例外。seam 屬語意判定(承載宣告權與否),推不出來,故顯式列;其餘檔案的歸屬由 package 邊界推導。

D-C 修訂(2026-07-22):規定範圍縮至 feature/,業務層改為專案自選

本節原文以「放棄 layer-first 目錄」一句涵蓋了 feature 面與業務面規定的範圍現縮回 feature/;業務層佈局由各專案自選,在 features.jsonbusinessLayout 宣告 (package-by-feature 缺省 / layer-first)。

為何 feature/ 的規定不變:本節的核心論據——slice 邊界必須是編譯器看得見的事實、 檔案集由 package 推導而非手維護清單——是 feature slice 機制的前提。移除一個 feature 時 整包刪除,散進 layer 目錄就做不到。ADR-010 的收納命名空間({basePkg}.feature.*)進一步 強化了這一點。

為何業務層不需要同一條規定:業務碼不受 provision / sync 管轄(見 D-D 的所有權表: sync 對業務碼「不碰」)。那些論據——可獨立複製、可整包刪除、跨 slice 依賴可被 ArchUnit 驗證——都是為了框架與下游之間的同步機制而立;業務碼沒有那個機制,也就沒有那個約束。 原文把兩者一併規定,是把「機制的必要條件」誤當成「普遍的好設計」。

實際後果:本參考實作(app-server)的業務層改採 layer-first——理由是它的業務層小, 分層直觀易讀,而它的首要職責是給消費端看的參考。這不構成對下游的建議:領域多、各領域 大的專案,package-by-feature 仍是較好的選擇(見 17-us-development.md 的對照表)。

@reference-surface 的 partition 因此依 businessLayout 分派業務域名的層級 ({domain}/ vs {layer}/{domain}/),由 feature-inventory.py 單一實作; m-reference-code.md 的分派表同步解開了「有無 catalog」與「業務層佈局」這兩個獨立軸 先前的錯誤耦合。

D-C 推論:core slice 必須是自足的 Spring 貢獻

provision 一個 feature 不得要求編輯其 package 之外的任何 Java 檔。 跨切面註冊一律由 feature 自有的 configurer bean(WebMvcConfigurerSecurityFilterChainAuditEventRepository 等)貢獻,不寄生於模組級 WebConfig / SecurityConfig

若無此推論,D-C 的 package 邊界只在的方向成立(哪些檔屬於這個 feature),在的方向破功(裝上這個 feature 要去改誰的檔)。acting 最初即曾把 interceptor 註冊寄生於模組 WebConfig,佔一個 field、一個 constructor 參數、一個 addInterceptors override。

這使 /feature provision acting 退化為對 WebConfig.java文字插入——正是本 ADR 在設定檔 fragment 化一節已明文拒絕的反模式(「那是把設定檔當字串操作,add/remove 時脆弱且無法 diff」),只是換成 Java,且更糟:yml 尚有 spring.config.import 可 fragment 化,Java 的 constructor 簽章沒有對應機制。

正解是讓 feature 與其附帶 reference domain 自己貢獻。Feature 提供可替換的 interceptor 預設;Spring 收集 reference domain 的 WebMvcConfigurer bean 並依序套用:

// io/leandev/app/service/acting/ActingWebConfig.java
@Configuration
@RequiredArgsConstructor
class ActingWebConfig implements WebMvcConfigurer {
private final ActingAuditInterceptor interceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(interceptor).addPathPatterns("/api/**");
}
}

WebConfig 隨之刪去那三處、回歸純模組級關注(CORS、formatter、resource handler、ObjectMapper),不再知道 acting 存在。acting 的安裝面於是自足:provision 由 catalog 同時帶入 Feature package 與 referenceDomains: ["acting"]

ActingWebConfig/api/** 是 URL policy,故留 reference domain;事件解算的固定部分已升 jar,Feature 的 ActingConfig 只提供 conditional default。這也說明 *Config 名稱不決定 所有權,承載的選擇才決定。

此模式在參考實作中已有活著的證明:AuditConfig 是自足且可直接啟動的 Feature 預設;需要持久化時才由 audit reference domain 的 AuditPersistenceConfig 提供 repository provider,讓 Feature 改選持久 sink。

D-C 的暗面:package 名寫進字串,編譯器就看不見了

D-C 主張「slice 邊界必須是編譯器看得見的事實」。這條主張有一個對稱的、代價高昂的推論:任何把 package 名寫成字串的地方,都在編譯器視線之外——重構工具改不到它,javac 不會抱怨,ArchUnit 也驗不了(它檢查的是型別依賴,不是字串常數)。

2026-07-10 搬 package 時撞到的實例:

// TenantFilterAspect(搬遷前)
@Before("(@annotation(Transactional) || @within(Transactional)) && within(io.leandev.app.service..*)")
public void enableTenantFilter(JoinPoint joinPoint) { ... }

ProductServiceservice/sales/ 搬進 sales/ 之後,此切入點靜默失配:Hibernate 的租戶 filter 不再啟用,跨租戶資料外洩,而編譯完全正常。17 個 feature 的搬遷全都沒事,直到業務領域搬遷才引爆。抓到它的是一行整合測試斷言(assertThat(skus).doesNotContain(OTHER_TENANT_SKU))。

修法是把切入點綁角色而非位置——@within(@Service)。匹配集合完全不變(原 service..* 下標了 @Transactional 的類,就是那些 @Service),但不再依賴目錄結構。

通則:feature slice 化之前,須清查所有以 package 名為字串的耦合點——AOP 切入點、@ComponentScan/@EntityScan/@EnableJpaRepositoriesbasePackagesClass.forName、設定檔中的類別全名。可指向根 packageio.leandev.app,與結構無關),不可指向 layer 或 feature 子 package。

這也劃出了 D-E 的 ArchUnit 覆蓋邊界:它守得住型別依賴(I2 的中性不變量),守不住字串耦合,也守不住 security/Authority.java 那種業務權限字串常數。機械檢查是下限訊號——這句話在本 ADR 已第三次被實例證實。

已知的 offender 與其難度:

位置內容難度
WebConfigactingaddInterceptors 註冊 ActingAuditInterceptor已解:事件解算升 jar、Feature 提供預設 bean、URL 掛載移至 acting reference domain
SecurityConfigauth / service-accountrequestMatchers("/auth/**", "/api/v1/auth/**", …)requestMatchers("/api/v1/service-accounts/**"):多個 SecurityFilterChain 之間有真實 @Order 與匹配順序語意,拆分是設計工作非搬家
resources/data/Authority.jsonRole.jsonauthority 種子隨 feature 走,卻住共用 seed 檔已解SeedContributor + AuthoritySeedProvider 反轉,seed 依擁有者分目錄;Authority.json 因其 description 從未入庫而整檔刪除,權限改由各 feature 以程式碼宣告

本推論與 ADR-014 規範一是同一條紀律的兩面:規範一對框架說「組合點必須是介面」,本推論對參考實作說「註冊點必須是 bean 貢獻」。兩者皆為使組合可加可減,而不必改動他人的檔案。

D-D:生命週期為 provision + sync,組合面為 seam

2026-08-18 修訂:本節的 Feature partition、core 中性與 seam ownership 繼續有效; source-side broadcast sync、syncedSHA 終態與 seam 只顯示 two-way diff 的處置,已由 ADR-012 取代為 app release + downstream upgrade + per-seam reconciliation。以下保留為原決策脈絡。

三個操作(由 phase-2 的 /feature skill 承載,屬模組操作型 skill,住 app-server/.claude/skills/、隨 cp -r 旅行到下游)。source-to-target:catalog SoT 住框架(role: source),由框架駕駛座驅動下游 fleet(比照 /practices),in-target 執行為保留選項。

操作語意
status唯讀盤點:已裝哪些 feature、各自相對框架落後哪些 commit(per-feature syncedSHA;框架模式可盤全 fleet dashboard)
provision {target} {id}自偵測:feature 在下游 absent → install(解 requires 閉包 → 複製 core slice → retarget → 合併設定 fragment → build 驗證);present(散在 layer 目錄)→ migrate(見 ADR-007)。併掉舊 addadopt——兩者終點相同、差別只在起點(absent vs present),是工具可自偵測的細節
sync [{target}]把框架 core slice 的改良零分歧下行到下游已裝的 feature;框架模式可廣播 fleet

sync 之所以成立,是因為 core slice 依 D-B 是中性的——不含業務語意,故可像 canonical 一樣覆蓋。但 core slice 內有一部分檔不可覆蓋:

seam = core slice 中承載「消費端宣告權」的組合面,永不覆蓋。

判準即 ADR-014 判準零——是否承載消費端的宣告權或替換權。承載者為 seam(留給下游、只呈現 diff);不承載者為可覆蓋的 core slice。

候選來自現況的組合點:NotificationConfig 組裝 channel 與 resolver、AlmanacConfig 注入 CacheManager。這些可能是「消費端組合」哲學的落點——但候選不等於判定,每個都必須逐項通過判準零。

⚠️ 本段初版另舉 FileStorageConfig 為例,該例是錯的(2026-07-17 更正,見下方「更正」節)。它是本 ADR 唯一被逐項檢驗過的例子,而檢驗結果是推翻。在其餘候選也被檢驗之前,不應把它們當成已確立的 seam。

seam 是語意判定,*Config / *Properties 只是常見形狀

早期版本把 seam 定義為「檔名為 *Config / *Properties」。這是錯的,兩個方向都會失準:

方向實例說明
名為 Config、不是 seamActingConfig只提供 conditional default;真正的 /api/** URL 選擇在 reference ActingWebConfig
名為 Config、是 seamAuthController登入流程是每個專案必然客製之處(passwordless、portal token、租戶解析)→ 承載宣告權

AuthController 的證據是量測到的。2026-07-10 對三個下游 server 做 base-package 正規化後逐檔比對(app-server 397 行):

檔(框架行數)ict-servertts-serveralmanac-server
EmailService(203)000
TokenBlacklistService(46)000
MailConfig(202,seam)151515
AuthController(397)20314569

上兩列是純 core slice:三個不同組織、scaffold 後各自演化多時,逐字相同——D-B 的中性與 D-D 的零分歧覆蓋得到實證。下兩列則是組合面:若把 AuthController 當成可覆蓋的 core slice,第一次 sync 會摧毀三個專案各 69–203 行的登入客製。

MailConfig 三家一致落後 15 行——那不是客製而是漂移(框架前進、下游未跟上)。

⚠️ 本 ADR 初版把這列讀成「正說明 seam 的呈現 diff 是必要設計」——那是誤讀(2026-07-17 更正)。三家一致,代表沒有任何人行使過宣告權;同一份數據更支持相反的讀法:MailConfig 的 seam 判定至今無證據。與 AuthController 的 203/145/69(各不相同)對照,差別一目了然。此列現應讀作「MailConfig 是待重新檢驗的 seam 候選」,而非 seam 的證據。

seam 的粒度是整個組合面,不能從中切開。 *Properties*Config 是同一組合面的兩半(前者宣告可調參數、後者消費之),下游會為自訂接線加自己的欄位。若 *Properties 可覆蓋而 *Config 不可,兩者會 sync 到不一致的狀態——Config 引用了下游自有欄位、Properties 卻被沖回框架版,直接編譯失敗。同理,AuthController 為 seam 時,與它同一組合面的檔(如其 request/response DTO 若被下游擴充)須一併視為 seam。

模組級 config(WebConfigSecurityConfig)不是任何 feature 的 seam:它們不是誰的組合面,只是被寄生的共用檔,依 D-C 推論那種寄生應被消除。

更正:FileStorageConfig 不是 seam,且「下游會改它」不是判準(2026-07-17)

本 ADR 初版舉 FileStorageConfig 為 seam 的證據。2026-07-17 逐項檢驗後推翻——它從一開始就被誤判,代價是具體的生產缺口(見下)。

檢驗

用判準零逐項問「這一項若由框架決定,消費端會失去宣告權嗎?」:

FileStorageConfig 裡的東西承載宣告權?
選哪個後端已由 app.storage.type 屬性表達@ConditionalOnProperty 閘控——是設定,不是改碼
S3/SFTP 憑證與參數❌ 由 S3Properties / SftpProperties 表達(三個下游 scaffold 至今 0 分歧
journal / 裝飾器怎麼組❌ 純機械——沒有人會想用不同方式接 SftpFileJournal.builder()

初版說它「依 type 選擇 storage 後端……每個下游必然會改它」——但**「依 type 選擇」正是 @ConditionalOnProperty 做的**。那句話把「這個檔負責做選擇」誤當成「這個檔承載選擇權」。選擇權在屬性手上,不在碼裡。

誤判的代價(為何這不只是分類潔癖)

seam 永不同步 ⇒ 下游的 FileStorageConfig 自 scaffold 起凍結 ⇒ 框架後續長出的 journal 接線一律到不了。一個跑 SFTP(非交易後端)的下游因此長期缺少 ADR-005 的交易補償,且其 FileOrphanSweepScheduler(core slice、隨 sync 正常下行)拿不到 journal bean 而無法運作——core slice 收到了消費者、seam 擋住了提供者,兩邊悄悄對不上,沒壞、沒例外、只是不做事

誤判 seam 的代價不是「同步不便」,是靜默的生產缺口。 這使 seam 判定的錯誤方向不對稱:漏判 seam(該保留卻覆蓋)會炸出來、被看見;誤判 seam(不該保留卻保留)永遠不會有人發現。故判定應偏向保守——沒有證據就不是 seam

兩條可 generalize 的判準(本節的真正產出)

判準一:「下游會改它」≠「它承載宣告權」。

下游改動一個 *Config 有四種成因,只有第四種是 seam 的證據:

成因是 seam 的證據嗎對應機制
落後(stale)ADR-007 D-C:覆蓋即可
隔離取向的機械後果ADR-009:正交化或 variant
改良(improvement)ADR-007 D-C:reflow 回框架
真的組合選擇seam

初版的推論「每個下游必然會改它 → 它是 seam」跳過了前三種解釋。

判準二:選擇若已由屬性表達,該 Config 不承載宣告權。

@ConditionalOnProperty / @Value 表達的選擇,其宣告權在設定手上。碼只是把設定翻譯成 bean,那是機械工作。可 grep 的訊號:這個 Config 的每個「選擇點」是不是都能在 yml 找到對應的 key? 是 → 非 seam。

一個必要的區辨:「分歧一致」vs「分歧各異」

上方量測表的兩列是天然對照組:

  • AuthController 三家 203 / 145 / 69——各不相同,每家各自客製 → 宣告權被實際行使 → seam ✓
  • MailConfig 三家 15 / 15 / 15——完全一致,沒有任何人客製 → 宣告權從未被行使 → 判定無證據

下游一致地偏離框架,是「大家都落後」,不是「大家都客製」。 前者該覆蓋,後者才是 seam。初版把 MailConfig 這列讀成 seam 的證據,是同一個誤判在數據解讀上的重演。

連帶待辦
  • mail / notification / almanac 的 seam 宣告尚未經本節的判準重新檢驗,可能有同樣的誤判與同樣的靜默缺口。
  • file 的 seam 已清空(seams: []),成為 D-A 註記的「沒有組合面的 feature ⇒ core slice 100% 可零分歧 sync」的第一個實例——那是最理想的情況,不是缺陷

seam 清單住 features.json,不靠檔名推導

既然 seam 是語意判定,就不能由 sync 用檔名 glob 猜。每個 feature 在 catalog 顯式宣告其 seam:

"mail": { "kind": "optional", "requires": [], "seams": ["MailConfig.java", "MailProperties.java"] },
"auth": { "kind": "required", "requires": [], "seams": ["SecurityConfig.java", "AuthController.java"] },
"file": { "kind": "optional", "requires": ["scheduling"], "seams": [] } // 零組合面:選擇皆由屬性表達

上例的 mail現況、非範本——其 seam 宣告尚未經「更正」節的判準檢驗(見該節「連帶待辦」)。authAuthController 有實測支持(三家各異)。file 已檢驗並清空。

這與 m-practices-sync.mdlocal 清單同治法:命名空間定範圍、manifest 只列例外。seam 判定有爭議時,回到判準零的判斷句,而非爭論檔名。

sync 對 seam 檔只呈現 diff 供人工併入、不寫入;其餘 core slice 檔零分歧覆蓋,比照 m-practices-sync.md 的 type-canonical。

代價要誠實說:框架若在 *Properties 新增欄位、又在 *Config 消費它,下游 sync 後拿到的是兩個檔的 diff 而非可運行的碼,必須人工併入。這是「組合點屬於消費端」的必然代價,也正是不做 autoconfiguration 的成本所在。緩解手段是 sync 報告明確標示「此 feature 的 seam 有上游變更,需人工併入」,而非靜默跳過。

閘門只設在有損動作(對齊 m-practices-sync.md 安全網節):覆蓋 seam = 有損 → 不做;補回缺檔、覆蓋未修改的 core 檔 = 無損 → 逕行收斂並報告。

D-E:feature slice 的成立依賴框架側紀律,該紀律另立 server ADR

本 ADR 的 core slice 中性不變量(D-B)與 provision/sync 管道(D-D),只有在框架端持續產出「有 SPI 邊界、且配有對應 core slice」的能力時才成立。若框架發版一個沒有組合點介面的能力,接線碼只能硬接實作類;若能力發版時沒有一併交付 core slice,/feature provision 就無 slice 可裝。

這三條紀律——SPI 邊界義務core slice 對偶義務中性不變量(ArchUnit 強制)——屬框架自身的開發規範,其權威定義落在 server ADR-014: 框架能力與參考接線碼的對偶,實作規則落點 appfuse-server/.claude/rules/

本 ADR 只記錄依賴關係:ADR-006 的 feature slice 模型,以 ADR-014 的框架紀律為前提。兩者需一併接受;只做前者會在下一次框架新增能力時再度腐化。


後果 (Consequences)

正面

  • scaffold 可選 feature:新專案不再被迫帶走全部能力的參考實作。
  • 既有專案可補裝:框架長出新能力時,/feature provision 是明確管道。
  • 框架改良可下行sync 讓 core slice 的修正惠及 fleet,這超出原始需求,是 D-B 中性化的紅利。
  • 修復 stamping 盲區notification/demo/ 落入 partition 可見範圍,業務洩漏不再隱形。
  • m-reference-code.md 的宣稱成為事實:framework-feature 與 business surface 的界線由 package 結構承載。

負面 / 成本

  • package-by-feature 是大規模重構app-server 幾乎每個 Java 檔都要移動,牽動 12-controller-service.md17-us-development.md 等模組 practices 與 coding-patterns.md 的所有路徑引用。已於 2026-07-10 執行完畢(17 feature + 3 業務領域);practices 與 coding-patterns.md 的路徑引用尚待同步。
  • 搬遷會靜默破壞 package-coupled 的字串耦合TenantFilterAspect 的 AOP 切入點綁 package 名,搬遷後失配、租戶隔離失效而編譯無誤(見「D-C 的暗面」)。搬遷前須清查此類耦合點;javac 與 ArchUnit 皆守不住它,只有整合測試能。
  • 設定檔需 fragment 化app-server.ymlmessages*.propertiesresources/data/*.json 是合併型檔案,feature 可加可減的前提是能按 feature 拆開。採每 feature 一個 config/feature-{id}.yml,由 app-server.ymlspring.config.import 匯入add = 放檔 + 加一行 import,sync = 整檔比對(seam 語意同 D-D)。不採註解錨點文字插入——那是把設定檔當字串操作,add/remove 時脆弱且無法 diff。代價是 @conf-env 註解指令(見 19-env-config.md)需隨設定段一起搬進 fragment,/env-config 的掃描範圍要從單檔擴為 config/feature-*.yml
  • 自足化的難度不均(D-C 推論):WebConfig 的 acting 註冊是機械搬移;SecurityConfigrequestMatchers 拆分則牽動多個 SecurityFilterChain@Order 與匹配順序,屬設計工作,須逐條確認、不可視為搬家。
  • seed 資料亦需 fragment 化(已解):resources/data/*.json 的 authority / role 種子隨 feature 走,卻住共用檔。與 yml 同屬合併型檔案,須一併切分,否則 provision impersonation 仍要編輯 feature package 之外的檔、違反 D-C 推論。落地方式SeedContributor(骨架 SPI,每份 seed 由擁有者自帶、@Order 定序)+ AuthoritySeedProvider(權限由擁有該資源的 feature 宣告),seed 檔依擁有者分目錄。
  • 業務語意可經「非 import」管道殘留:權限字串常數(security/Authority.javaPRODUCT_R 等)、seed 中的業務角色名(FLORIST)皆住在 core slice,卻不是 import,D-E 的 ArchUnit 與過渡腳本都抓不到。此類殘留須靠 D-B 的判準人工判讀;角色定義尤其可能本質上是 per-project 的,或應比照 D-D 列為 seam。
  • 第四條同步管道:下游的更新路徑從三條(methodology / practices / 套件版本)增為四條。認知負擔上升,但每條的職責邊界清晰。
  • remove 暫不支援:移除 feature 是有損動作,且業務碼可能已依賴它。列為未來工作,需反向依賴掃描 + 人工確認。
  • 中性化本身是先決條件:D-C 的目錄重構若先於 D-B 的解耦執行,只會把糾纏的碼搬到新位置。解耦必須先做。

遷移順序(phase-2 的骨架)

  1. ✅ 接受 server ADR-014(前提;否則後續步驟會被下一個新能力打回原形)——已於 2026-07-10 接受
  2. 定稿 features.json(2026-07-10):17 個 feature、requires(import 邊 ∪ bean 供需邊)與 seams 齊備。catalog 由不變量 I3 機器校驗(見下),故非手寫宣稱

機械檢查的分工(2026-07-10 起,I1/I2 已交還 ArchUnit):

不變量內容由誰強制
I1required feature 不得依賴 optional featureArchUnitFeatureSliceArchitectureTest./gradlew build 即 gate)
I2core slice 不得依賴業務 package(無例外——demo/ 例外已隨 wiring slice 類別廢除,見 D-B 更正)ArchUnit(ADR-014 規範三)
I3features.jsonkindrequires 須與推導圖一致、seamsskeleton 路徑須存在、package 名須唯一feature-inventory.py --check——不能交給 ArchUnit,它守的是宣稱與依賴圖是否一致,不是依賴本身

ArchUnit 嚴格強於原本的 import 圖代行:它讀 bytecode,涵蓋欄位型別、方法簽章、註解、泛型等 import 陳述式抓不到的依賴。實測——以全限定名(無 import)引用業務類別時,腳本回報 [I2] OK、ArchUnit 抓到。故 I1/I2 不保留兩份實作(同一份知識兩處實作正是它漂移的成因,見 FU-52)。

ArchUnit 的分組(feature/骨架/業務)與 base package 皆features.json 推導,不硬編:業務 = 殘餘,base package 由測試自身的 package 名反推。

I3 存在的理由:catalog 是一份手寫宣稱,而宣稱會漂移——正是本 repo 一再戒除的反模式(m-methodology-sync.md 不變量一、m-module-inventory.md)。I3 讓它成為機器可驗的事實。搬 package 時它立刻兌現:seam 路徑未同步更新即報 seam path not found

第 3 步搬完 package 後,腳本的分組規則歸零——feature 分組完全由 package 推導、骨架由 features.jsonskeleton 宣告、業務為殘餘。I1/I2 屆時可交還 ArchUnit;I3 不能,它守的是 catalog 而非型別依賴。I3 的職責並隨 FU-52 擴充:另校驗 skeleton 路徑存在且不遮蔽 feature package。

現況(2026-07-10 收盤):I1 = 0、I2 = 0、未歸類 = 0,全綠。基線曾為 I1 三條(AuthControllerDelegationService / ClientToken*)、I2 五條(NotificationConfig 二、ReferenceDataController 三),皆已解耦;DataInitializer 對業務類別的 22 條 import 邊亦已由 SeedContributor 反轉歸零(見 FU-50)。

全綠仍不等於中性:I1/I2 只看 import 邊,語意耦合可經非 import 管道殘留(字串字面值、框架型別、AOP 切入點、檔案 glob)。已知四例:

  • auth/SecurityConfigbean 寄生已解,第 4 步):signed-link 的兩個 bean 住在 auth,型別全為框架 jar 類故無 app import 邊。已移入 signedlink/SignedLinkConfig

  • auth/SecurityConfig授權規則寄生已解,第 4 步):service-accountplatform-infomail 的路徑以字串字面值寫死在 authorizeHttpRequests。已反轉為 SecurityContributor 貢獻點。

  • security/Authority.java已解)住在必要 feature auth,卻宣告 PRODUCT_R / ORDER_W 等業務權限字串常數——字面值不是 import,機械檢查抓不到。已依領域拆為五個常數類,各自與該 feature/領域的 AuthoritySeedProvider 同住,@PreAuthorize 與 seed 共用單一事實來源。

  • data/auth/Role.json未解)帶著參考實作的業務角色(FLORIST 等)。角色定義本質上可能是 per-project 的,或應列為 D-D 的 seam;待決。

機械檢查是下限訊號,不是充分條件;判準仍是 D-B 的「core slice 不得含業務語意」,須人工判讀。

  1. 逐 feature 解耦(2026-07-10 完成):抽出 wiring slice 到 {feature}/demo/,補齊 @reference-surface 標記,ArchUnit 不變量上線notification/demo/ 已拆出並 stamp;三個結構性阻斷問題(auth→optional 反向依賴、硬編註冊表 import 業務碼、seed 單體)已解;FeatureSliceArchitectureTest 以 bytecode 強制 I1/I2,./gradlew build 即 gate,過渡腳本卸下這兩條(見上方分工表)。以 mutation 驗證兩條規則皆會失敗(required→optional 欄位型別、core slice→業務欄位型別),且 {feature}/demo/ 的排除確實生效(notification/demo/ 依賴 ordercustomer 而不觸發)

  2. 逐 feature 搬 package(2026-07-10):17 個 feature + 3 個業務領域全部 feature-first。過渡腳本的分組規則因此歸零(骨架改由 features.jsonskeleton 宣告、業務為殘餘),feature 邊界成為 package 邊界——D-C 的主張至此為事實而非意圖。

    • 搬遷前必須清查 package-coupled 字串(見 D-C 的暗面):TenantFilterAspect 的切入點綁 within(io.leandev.app.service..*),搬遷後靜默失配、租戶隔離失效。
    • 骨架(AppServerWebConfigJpaAuditingConfigentity/base/Auditable*initializer/exception|handler|mapper|listener/)刻意留在原地——它們不屬於任何 feature,layer 目錄名對其準確。
    • ✅ practices 已同步(17-us-development.md 改為 package-by-feature;12-controller-service.mdcoding-patterns.md 經查無路徑引用)。
    • @reference-surface 的 partition 規則已修復(2026-07-10,FU-52):原以 glob **/controller/{businessDomain}/**/entity/{businessDomain}/ 掃 business surface,搬遷後這兩種目錄一個都不剩,scaffold 會 stamp 零個標記且無任何測試接住(空清單與「來源無業務碼」形狀相同)。修法:partition 改由 features.json 推導(增 skeleton 宣告,業務 = 殘餘),並由 feature-inventory.py --stamp-targets 單一實作/scaffold-module 呼叫之——共用 SoT 不夠,同一份知識兩處實作正是它漂移的原因。/scaffold-module 依來源有無 features.json 分派新舊佈局,故仍為 layer-first 的下游當 scaffold 來源時一樣正確,且下游 provision 後自動翻面(自我退場,不需 flag day)。另補空清單 guard(stamp 後標記數為零即停止並報 partition 失配)。實測:同一來源 stamp 0→51 檔、layer-first 分支對 ict-server 新舊輸出逐檔一致。
    • 順帶解除 feature-inventory.py 自身的 package-coupled 字串(SRC_ROOT、fqn 前綴、import regex、靜態 import 回退段數皆硬編 base package)——改以 skeleton 首項為錨點推導,scaffold 到 base package 不同的下游無需 retarget。這正是 D-C 的暗面在工具層的同一次發作
  3. 逐 feature 自足化(D-C 推論,2026-07-10 完成):把寄生於模組級 config 的註冊改為 feature 自有的 configurer bean。seed 資料切分actingWebConfigActingWebConfigSecurityConfig 的 filter chain 拆分

    SecurityConfig 身上有兩種寄生,第二種比預期隱蔽:

    寄生內容為何 I1/I2 抓不到
    bean 寄生signed-link 無自有 config,其 signedLinkStore / signedLinkService 住在 auth/SecurityConfig兩 bean 的型別是框架 jar 類io.leandev.appfuse.security.link.*),authsigned-link 無 app 層 import 邊
    授權規則寄生authorizeHttpRequests 寫死 service-accountplatform-infomail 擁有的路徑路徑是字串字面值,不是 import

    這是「全綠仍不等於中性」的第三、四個實例,與 Authority.java 的權限字串常數、TenantFilterAspect 的 AOP 切入點同族。

    拆法:貢獻點,不是多 chain。 /api/v1/auth 前綴被六個 controller 共用(authservice-account 的 client token、delegationactingimpersonationsigned-link),無法以 securityMatcher 切開;且每條獨立 chain 都須自備 cors/csrf/oauth2RS/exceptionHandling,漏一項即安全洞。故採單一 chain + SecurityContributor SPI(住 auth,比照 LoginActingContributor 的反轉):貢獻者先註冊、骨架基礎規則後註冊、anyRequest().authenticated() 恆最後。auth 因此對那四個 feature 零 import 邊,I1 自然成立。

    驗收以行為不變為準:先寫 SecurityAuthorizationMatrixIT(11 個斷言,涵蓋匿名/角色不足/有權三態 × feature 邊界端點)對未改的碼跑綠,再以 mutation(拿掉 configcheckhasRole)證明它抓得到偏移,才動手重構。重構後 clean build 298 tests 全綠(287 基線 + 11)。I3 如期抓到 mailplatform-info 新增的 requires: ["auth"] 邊並要求回填 catalog——catalog 的機器校驗在此兌現

  4. 設定 fragment 化(2026-07-10 完成):app.* 依 feature 切成 src/main/resources/config/feature-{id}.yml,由 {moduleName}.ymlspring.config.importoptional: 匯入——feature 的安裝與否由「檔案在不在」表達,不由一份會漂移的清單表達;{moduleName}.yml 退化為匯入清單。/env-config 掃描範圍隨之擴大(19-env-config.md + skill Step 1/3)

    優先序須實測,不可推斷{moduleName}.yml 並非走 Spring 標準 spring.config.name,而是由框架 EnvironConfigInjectorspring.config.additional-location 注入。實測結果為 外部 conf > feature 片段 > classpath:{moduleName}.yml > application.yml——片段承載 feature 預設,環境覆蓋鏈不受影響。

    ConfigFragmentsTest 守三條(皆為靜默失敗模式):① 每個宣告的片段確實註冊為 PropertySource(斷言 PropertySource 而非屬性值——測試會載入開發者本機的外部 conf,值隨機器而異,實測即踩到);② 匯入清單與 config/ 下的檔雙向一致(多出的檔永不載入、多出的宣告因 optional: 靜默略過);③ 每個匯入皆帶 optional:。以 mutation(import 路徑打錯字)驗證三條皆會失敗。30 個 @conf-env 標記逐一保存。

  5. /feature skillstatusprovisionsync,source-to-target):skill 已落地並於 2026-07-11 重設計為 source-to-targetapp-server/.claude/skills/feature/SKILL.md v2.0.0,模組操作型、隨 /practices publish 下行)——adoptadd 併為 provision(自偵測 install/migrate)、由框架駕駛座驅動 fleet、config 搬遷加兩道 guard(見 ADR-007 D-G)。三個模式的流程、閘門與錯誤處理皆已定義。status 已手動執行驗證(對 ict-server 產出可用的 provision 候選報表);provision 的 migrate 路徑已於 almanac-servercache 首驗成功(2026-07-11:I3 綠、build 208 tests 全綠、零新增失敗、commit a260bd0),並回寫兩個規格洞(P4/P5 登錄須先於 --check;bootstrap skeleton 對混合 layer dir 與已 feature-first 未登錄 feature 的處理)。install 路徑、sync、及其餘 feature/下游尚待驗證

    工具隨 skill 走feature-inventory.py.claude/scripts/ 移入 .claude/skills/feature/assets/——scripts/ 不在 /practices 的同步命名空間(rules/*.md + skills/** + docs/**),skill 到得了下游、它的工具卻到不了。移入後兩者一起下行。

    腳本改為完全 catalog 驅動kind 與 bean 供需邊(新增 beanRequires 欄)原本硬編在腳本裡,那讓它只對「裝滿 17 個 feature 的框架」成立——下游裝子集會誤報 feature set differs。改讀 catalog 後,以合成下游(不同 base package、feature 子集、自有業務領域)實測 I3 綠。

    pilot 建議修正statusict-server 實測顯示 cache(3/3 命中、0 drift)與 scheduling(1/1、0 drift)逐字相同,是零風險的 provision 起手式;ADR-007 依檔案數建議的 almanac-server 是另一個維度的考量(無 remote、可安全回退)。兩者不衝突:選 almanac-servercache 起手。pilot 首輪(2026-07-11) 已確認 almanac cache core 3/3 本體零漂移、seam CacheConfig 為 almanac 客製,並催生 skill 的 source-to-target 重設計與 D-G 設定 guard。

  6. /scaffold-module Step 5 改為 feature-aware 複製(2026-07-11,skill v4.5.0,核心機制已實跑驗證):來源 server 有 features.jsonrole: source)時,Step 5 分派為 feature-aware——required 必帶、optional 按選擇帶(requires 遞移閉包)、{feature}/demo/(wiring slice)一律不帶(與 /feature install 同哲學)。⚠️ 2026-07-17 起失去對象:D-B 更正廢除 wiring slice,demo/ 已不存在,接線隨業務域帶入。裁剪對象含 feature 的 java/test package、config/feature-{id}.yml(+ import 清單行)、data/{id}/ seed。

    • 實跑驗證(scratch 標的對 app-server + 本地框架 composite build,2026-07-11;當時尚有其後被 ADR-025 移除的 service-account feature):① 預設全包含(drop demo/ + 全 feature + 下游 catalog)→ compileJava 綠、--check(I3)綠;② 剝掉 beanRequires 的負向控制 → --check 如預測對 almanacfilenotification 失敗(證實下游 catalog 必須保留 beanRequires);③ 精簡裁 calendar+audit--check 綠、compileJava+compileTestJava 綠;④ 裁 skeleton-IT 引用的 service-account 的負向控制 → compileTestJava 失敗(證實當時此類 feature 必須排除於 safeDroppable)。未驗證:完整 skill 儀式(Step 7 retarget、README/.code-workspace/project.json bookkeeping、smoke test、選單 UX)——與核心機制正交、多為既有已測路徑。
    • 業務殘餘的 feature 耦合(新發現,非設計時預期):scaffold 一律帶入的業務領域(order/customer/sales硬 import notificationtenantreference-data 等 optional feature——拿掉即 compileJava 失敗。故選擇只 offer safeDroppable(optional − 業務 demo 依賴 − 保留 feature 的 requires 閉包),預設全包含(=現行行為減去 demo/),零腳槍。要連業務領域一起精簡屬 /scaffold-template 職責。
    • 順帶補既有缺口:新增 Step 6.8 把 cp -r 帶過去的 role: source features.json 重戳為 role: downstream(收斂為帶入集、seam 路徑 retarget、syncedSHA = 來源 HEAD)——與 practices.json 在 Step 6.7 前的病相同(先前 scaffold 出的 server 都帶著錯的 role: source catalog,破壞 /feature 的 role 唯一性守衛)。Step 6.6 的 stamp 迴圈加 [ -f ] 守衛,容忍 Step 5.4 裁掉的 demo/。
    • demo/ 一律不帶為刻意決策(非 /feature install 的機械沿用):非 demo 碼對 demo 型別零編譯依賴(僅 /// javadoc 註解引用);代價是 seed 花店失去「feature 如何接到業務領域」的 wiring 範例,團隊自行接線。

既有下游怎麼接上:以上七步全部作用在框架自身的 app-server,第 7 步只嘉惠專案。既有下游(layer-first、base package 各異、無 catalog)的遷移路徑由 ADR-007 定義為 /feature provisionmigrate 路徑——逐 feature 增量、drift 三分類、人在迴路。不採用是安全的(代價僅「拿不到 core slice 改良」=現況)。其實作隨第 6 步一併落地。


替代方案 (Alternatives)

方案為何未採用
維持 layer-first + manifest glob 描述 slice改動小,但 slice 邊界成為手維護的宣稱,每新增檔案可能漏列;需要額外的「每個檔恰屬一個 feature」健檢來補救。違反此 repo「不維護會漂移的宣稱」的一貫治法(m-methodology-sync.md 不變量一、m-module-inventory.md
改用 Spring Boot autoconfiguration,取消參考接線碼直接推翻框架的核心設計決策(不對消費端「如何使用」施加偏見)。autoconfiguration 會把組合方式編進框架,且 NotificationConfig 這類組合點正是各專案需要客製之處
catalog SoT 放框架 (appfuse-server)feature 的存在性確實由框架能力定義,但 catalog 描述的是「參考實作怎麼切」,框架不需知道。兩層 catalog(框架宣告 capability + app 宣告 slice)分層清楚但要維護兩份、且要防漂移,成本不划算。改以 D-E 的流程義務承擔框架端責任
provision-only(只裝不同步),不做 sync機制簡單,但框架對 core slice 的改良永遠到不了下游,重演「一次性快照」的病——正是本 ADR 要解的問題

與其他文件的關係

文件關係
server ADR-014對偶 ADR、且為本 ADR 的前提:定框架端的歸屬判準(判準零)、SPI 邊界義務、core slice 對偶義務與中性不變量(ArchUnit)。無此紀律,本 ADR 的 core slice 中性與 sync 管道無法長期成立。判準零界定 core slice 的上邊界(哪些中性碼其實該上移 jar),本 ADR 的 D-B 界定其下邊界(哪些碼帶業務語意、屬業務域——D-B 更正後不再有 feature 內的 wiring slice)
ADR-005同構前例:那裡把混層的方法論知識拆成三層 + 兩套同步機制;本 ADR 把混層的參考實作碼拆成 core/wiring + 新增一套同步機制。phase-1 定模型、phase-2 實作的分工亦沿用
ADR-009補本 ADR 未涵蓋的第四類 drift:隔離取向的機械後果既非落後、亦非客製或改良,故 ADR-007 D-C 的三分類對它只能誤判為「客製」。其 file worked-example 同時推翻了本 ADR D-D 的 FileStorageConfig seam 例(見 D-D 的「更正」節)
m-reference-code.md本 ADR 的 D-B 讓其 partition 規則從宣稱變成結構事實,並修復 io.leandev.app.notification.* 的 stamping 盲區。該規則的「framework-feature 套件清單」在重構後應改為「core slice(feature package 整包)不標記、業務域整包標記」(D-B 更正後 {feature}/demo/ 已不存在)
m-practices-sync.md姊妹機制:同步模組層規則;本 ADR 同步模組層的框架接線碼。manifest 語彙role/syncedSHA/seam 例外)刻意對齊——但粒度不同:practices 全命名空間一次對齊(模組級 SHA),feature 逐個獨立 provision(per-feature SHA,見 D-A 的粒度更正)
m-server-common.md「框架先行原則」的補強:先前只說「查框架設計指南」,本 ADR 補上「該能力的參考接線碼可經 /feature provision 取得」
m-skill-execution.md/feature 依其判斷句屬模組操作型 skill(綁 app-server 的程式碼結構),住模組內、隨 scaffold 旅行
m-changelog-format.mdD-E 的 core slice 對偶義務應掛入框架新增能力的檢核清單
m-methodology-hygiene.mdcore slice 為中性(框架 primitive)、業務域為參考實作 convention——即其「primitive vs convention」判準在程式碼組織層的落地