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-module 以 cp -r 一次複製整個模組,於是每個新專案都被迫帶走全部功能的參考實作;而既有專案在框架長出新能力時,沒有任何管道把新的參考實作補裝進來。
更深一層的問題是:這些接線碼與花店業務碼在結構上沒有分界線——io.leandev.app.notification 裡的 OrderNotificationRecipientResolver 直接 import entity.order.Order,NotificationConfig 直接 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 + sync(source-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/ | MailConfig、MailProperties |
actuator/ | MailEndpoint、MailHealthIndicator |
controller/mail/ | MailSettingController、MailTestController |
entity/mail/ | MailSetting |
repository/mail/ | MailSettingRepository |
service/mail/ | EmailService、MailerService、MailHealthCheckService |
notification/ | EmailServiceMailDelivery |
| 非 Java | app-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
其中 OrderNotificationRecipientResolver、OrderNotificationService、LowStockRecipientResolver 是純業務碼。而 NotificationConfig(組合點)直接 import 前兩者。同樣的糾纏出現在 FileStorageConfig → app.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/OrderStatus、entity/sales/ProductStatus 與 service/sales/ProductCategoryService。它落在 controller/{businessDomain}/ 的形狀內,但 base 正是 m-reference-code.md 明列為 framework-feature、不標記的套件——於是這片洩漏連 @reference-surface 都不會蓋上。兩個盲區成因相同:partition 規則靠目錄慣例推斷業務性,而業務性其實寫在 import 裡。D-E 的 ArchUnit 不變量正是為此而設。
核心張力
「不做 autoconfiguration」是一個有意識的、正確的框架設計決策:autoconfiguration 會把「該怎麼用」的偏見編進框架,而 appfuse 選擇讓消費端組合。但這個決策把組合的成本外部化到參考實作——參考實作因此必須同時扮演三個角色:
- 框架能力的使用範例(mail 該怎麼接)
- 可運行的完整 demo(花店)
- scaffold 種子(新專案的起點)
角色 1 要求「按 feature 可選、可增補」;角色 2 要求「業務具體、可信」;角色 3 要求「複製後 retarget 乾淨」。三者在單一 monolithic 模組裡互相碾壓,cp -r 只好全都要。
這與 ADR-005 面對的張力同構:那裡是方法論知識混層(Tier 1/2/3),這裡是參考實作程式碼混層(框架接線 / 業務 demo / 骨架)。
既有機制的對位
| 既有機制 | 管什麼 | 本 ADR 的位置 |
|---|---|---|
/upgrade-appfuse-* | 框架套件版本 | 不變——feature slice 是「怎麼用套件」,不是套件本身 |
@reference-surface(m-reference-code.md) | 下游業務碼的 retarget 閘門 | 本 ADR 讓其 partition 規則從「宣稱」變成「結構事實」 |
/practices(m-practices-sync.md) | 模組層規則(Tier 3 practices) | feature slice 是其姊妹:一個同步規則、一個同步框架接線碼 |
/methodology | workspace 層方法論 | 不變 |
「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-data→referencedata)——Java package 不得含連字號。確定性推導,不在 manifest 另立欄位;唯一性由 I3 檢查。
2026-07-28 修訂:
scheduling從 required 降為 optional。 它沒有任何 required 消費端,只有 optional 的file/notification透過requires帶入;把它列為 required 會迫使完全沒有背景任務的應用安裝排程與鎖表。SchedulingConfig是應用擁有的組態 seam,ShedLockEntry則是可同步的 canonical JPA schema mapping。
2026-07-31 修訂:新增
persistence-encryptionoptional Feature。 framework jar 只提供PersistenceEncryption/TextCipherprimitive;應用 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 → cache | AlmanacConfig 宣告 @Bean Almanac almanac(CacheManager),bean 由 CacheConfig 提供 | 零邊 |
file → scheduling | 三支 scheduler 用 @Scheduled,需 SchedulingConfig 的 @EnableScheduling | 零邊 |
notification → scheduling | NotificationOutboxPoller 用 @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 資源面則由 catalogreferenceDomains: ["serviceaccount"]隨附。後者可含 controller/service/entity/repository, 但仍是可客製、不可盲 sync 的 reference implementation,不是 core。ownership source 在 Feature 選擇前套用 policy、設定與 seed overlay,未選 Feature 時再整域裁掉。
delegation / impersonation 為何是兩個 optional feature:ImpersonationService 的 javadoc 已明載「與 Delegation 完全獨立……讓下游可只 cherry-pick 模擬功能」——作者已預期兩者是可分開取用的單位,這即是 feature 的定義。兩者共用中性的 acting 能力:jar 承載 ActingContext/ActingAuditInterceptor,acting 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.json(role: downstream、installed: [...]、syncedSHA),語彙比照practices.json。
⚠️ 粒度更正(2026-07-17):「語彙比照
practices.json」這句只該套用在語彙,不該連粒度一起抄。practices是「整個.claude/命名空間一次全對齊」,模組級syncedSHA對它正確;但 feature 是逐個獨立 provision(ADR-007 D-B:一次一個、可獨立提交、可獨立回退)。模組級 SHA 與機制粒度失配,會在「只 sync 部分 feature」時替其他 feature 謊稱已對齊,使其更新在 delta 計算中靜默消失。syncedSHA已改為 per-feature(features[{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 的中性接線碼(EmailService、MailerService、MailSetting、EmailServiceMailDelivery、LoggingSmsDelivery、EnvSubjectPrefixTemplateResolver) | 框架 primitive;同 feature 內部;requires 宣告的其他 feature | 無 | 可 add / sync(見 D-D) |
| wiring | 把 feature 接到業務領域的示範(OrderNotificationRecipientResolver、OrderNotificationService、LowStockRecipientResolver) | 業務 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 slice = feature 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/LowStockRecipientResolver、order/OrderNotificationRecipientResolver):NotificationConfig零改動(它注入ObjectProvider<DomainRecipientResolver>,介面而非具名) - 搬後:沒有任何 feature import 任何業務域(
customer/dashboard/order/sales)——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/mail、service/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.json的businessLayout宣告 (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(WebMvcConfigurer、SecurityFilterChain、AuditEventRepository等)貢獻,不寄生於模組級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) { ... }
ProductService 從 service/sales/ 搬進 sales/ 之後,此切入點靜默失配:Hibernate 的租戶 filter 不再啟用,跨租戶資料外洩,而編譯完全正常。17 個 feature 的搬遷全都沒事,直到業務領域搬遷才引爆。抓到它的是一行整合測試斷言(assertThat(skus).doesNotContain(OTHER_TENANT_SKU))。
修法是把切入點綁角色而非位置——@within(@Service)。匹配集合完全不變(原 service..* 下標了 @Transactional 的類,就是那些 @Service),但不再依賴目錄結構。
通則:feature slice 化之前,須清查所有以 package 名為字串的耦合點——AOP 切入點、@ComponentScan/@EntityScan/@EnableJpaRepositories 的 basePackages、Class.forName、設定檔中的類別全名。可指向根 package(io.leandev.app,與結構無關),不可指向 layer 或 feature 子 package。
這也劃出了 D-E 的 ArchUnit 覆蓋邊界:它守得住型別依賴(I2 的中性不變量),守不住字串耦合,也守不住 security/Authority.java 那種業務權限字串常數。機械檢查是下限訊號——這句話在本 ADR 已第三次被實例證實。
已知的 offender 與其難度:
| 位置 | 內容 | 難度 |
|---|---|---|
WebConfig → acting | addInterceptors 註冊 ActingAuditInterceptor | ✅ 已解:事件解算升 jar、Feature 提供預設 bean、URL 掛載移至 acting reference domain |
SecurityConfig → auth / service-account | requestMatchers("/auth/**", "/api/v1/auth/**", …)、requestMatchers("/api/v1/service-accounts/**") | 高:多個 SecurityFilterChain 之間有真實 @Order 與匹配順序語意,拆分是設計工作非搬家 |
resources/data/Authority.json、Role.json | ✅ 已解: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)。併掉舊 add+adopt——兩者終點相同、差別只在起點(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、不是 seam | ActingConfig | 只提供 conditional default;真正的 /api/** URL 選擇在 reference ActingWebConfig |
| 不名為 Config、是 seam | AuthController | 登入流程是每個專案必然客製之處(passwordless、portal token、租戶解析)→ 承載宣告權 |
AuthController 的證據是量測到的。2026-07-10 對三個下游 server 做 base-package 正規化後逐檔比對(app-server 397 行):
| 檔(框架行數) | ict-server | tts-server | almanac-server |
|---|---|---|---|
EmailService(203) | 0 | 0 | 0 |
TokenBlacklistService(46) | 0 | 0 | 0 |
MailConfig(202,seam) | 15 | 15 | 15 |
AuthController(397) | 203 | 145 | 69 |
上兩列是純 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(WebConfig、SecurityConfig)不是任何 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": [] } // 零組合面:選擇皆由屬性表達
上例的
auth的AuthController有實測支持(三家各異)。file已檢驗並清空。
這與 m-practices-sync.md 的 local 清單同治法:命名空間定範圍、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.md、17-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.yml、messages*.properties、resources/data/*.json是合併型檔案,feature 可加可減的前提是能按 feature 拆開。採每 feature 一個config/feature-{id}.yml,由app-server.yml以spring.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 註冊是機械搬移;SecurityConfig的requestMatchers拆分則牽動多個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.java的PRODUCT_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 的骨架)
- ✅ 接受 server ADR-014(前提;否則後續步驟會被下一個新能力打回原形)——已於 2026-07-10 接受
- ✅ 定稿
features.json(2026-07-10):17 個 feature、requires(import 邊 ∪ bean 供需邊)與seams齊備。catalog 由不變量 I3 機器校驗(見下),故非手寫宣稱
機械檢查的分工(2026-07-10 起,I1/I2 已交還 ArchUnit):
不變量 內容 由誰強制 I1 required feature 不得依賴 optional feature ArchUnit( FeatureSliceArchitectureTest,./gradlew build即 gate)I2 core slice 不得依賴業務 package(無例外—— demo/例外已隨 wiring slice 類別廢除,見 D-B 更正)ArchUnit(ADR-014 規範三) I3 features.json的kind/requires須與推導圖一致、seams/skeleton路徑須存在、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.json的skeleton宣告、業務為殘餘。I1/I2 屆時可交還 ArchUnit;I3 不能,它守的是 catalog 而非型別依賴。I3 的職責並隨 FU-52 擴充:另校驗skeleton路徑存在且不遮蔽 feature package。現況(2026-07-10 收盤):I1 = 0、I2 = 0、未歸類 = 0,全綠。基線曾為 I1 三條(
AuthController→DelegationService/ClientToken*)、I2 五條(NotificationConfig二、ReferenceDataController三),皆已解耦;DataInitializer對業務類別的 22 條 import 邊亦已由SeedContributor反轉歸零(見 FU-50)。全綠仍不等於中性:I1/I2 只看 import 邊,語意耦合可經非 import 管道殘留(字串字面值、框架型別、AOP 切入點、檔案 glob)。已知四例:
auth/SecurityConfig的 bean 寄生(已解,第 4 步):signed-link的兩個 bean 住在 auth,型別全為框架 jar 類故無 app import 邊。已移入signedlink/SignedLinkConfig。
auth/SecurityConfig的 授權規則寄生(已解,第 4 步):service-account/platform-info/authorizeHttpRequests。已反轉為SecurityContributor貢獻點。
security/Authority.java(已解)住在必要 featureauth,卻宣告PRODUCT_R/ORDER_W等業務權限字串常數——字面值不是 import,機械檢查抓不到。已依領域拆為五個常數類,各自與該 feature/領域的AuthoritySeedProvider同住,@PreAuthorize與 seed 共用單一事實來源。
data/auth/Role.json(未解)帶著參考實作的業務角色(FLORIST等)。角色定義本質上可能是 per-project 的,或應列為 D-D 的 seam;待決。機械檢查是下限訊號,不是充分條件;判準仍是 D-B 的「core slice 不得含業務語意」,須人工判讀。
-
✅ 逐 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/依賴order/customer而不觸發) -
✅ 逐 feature 搬 package(2026-07-10):17 個 feature + 3 個業務領域全部 feature-first。過渡腳本的分組規則因此歸零(骨架改由
features.json的skeleton宣告、業務為殘餘),feature 邊界成為 package 邊界——D-C 的主張至此為事實而非意圖。- 搬遷前必須清查 package-coupled 字串(見 D-C 的暗面):
TenantFilterAspect的切入點綁within(io.leandev.app.service..*),搬遷後靜默失配、租戶隔離失效。 - 骨架(
AppServer、WebConfig、JpaAuditingConfig、entity/base/Auditable*、initializer/、exception|handler|mapper|listener/)刻意留在原地——它們不屬於任何 feature,layer 目錄名對其準確。 - ✅ practices 已同步(
17-us-development.md改為 package-by-feature;12-controller-service.md與coding-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 的暗面在工具層的同一次發作。
- 搬遷前必須清查 package-coupled 字串(見 D-C 的暗面):
-
✅ 逐 feature 自足化(D-C 推論,2026-07-10 完成):把寄生於模組級 config 的註冊改為 feature 自有的 configurer bean。
seed 資料切分→→acting(WebConfig→ActingWebConfig)SecurityConfig的 filter chain 拆分SecurityConfig身上有兩種寄生,第二種比預期隱蔽:寄生 內容 為何 I1/I2 抓不到 bean 寄生 signed-link無自有 config,其signedLinkStore/signedLinkService住在auth/SecurityConfig兩 bean 的型別是框架 jar 類( io.leandev.appfuse.security.link.*),auth對signed-link無 app 層 import 邊授權規則寄生 authorizeHttpRequests寫死service-account/platform-info/mail擁有的路徑路徑是字串字面值,不是 import 這是「全綠仍不等於中性」的第三、四個實例,與
Authority.java的權限字串常數、TenantFilterAspect的 AOP 切入點同族。拆法:貢獻點,不是多 chain。
/api/v1/auth前綴被六個 controller 共用(auth、service-account的 client token、delegation、acting、impersonation、signed-link),無法以securityMatcher切開;且每條獨立 chain 都須自備 cors/csrf/oauth2RS/exceptionHandling,漏一項即安全洞。故採單一 chain +SecurityContributorSPI(住auth,比照LoginActingContributor的反轉):貢獻者先註冊、骨架基礎規則後註冊、anyRequest().authenticated()恆最後。auth因此對那四個 feature 零 import 邊,I1 自然成立。驗收以行為不變為準:先寫
SecurityAuthorizationMatrixIT(11 個斷言,涵蓋匿名/角色不足/有權三態 × feature 邊界端點)對未改的碼跑綠,再以 mutation(拿掉configcheck的hasRole)證明它抓得到偏移,才動手重構。重構後clean build298 tests 全綠(287 基線 + 11)。I3 如期抓到mail/platform-info新增的requires: ["auth"]邊並要求回填 catalog——catalog 的機器校驗在此兌現。 -
✅ 設定 fragment 化(2026-07-10 完成):
app.*依 feature 切成src/main/resources/config/feature-{id}.yml,由{moduleName}.yml的spring.config.import以optional:匯入——feature 的安裝與否由「檔案在不在」表達,不由一份會漂移的清單表達;{moduleName}.yml退化為匯入清單。/env-config掃描範圍隨之擴大(19-env-config.md+ skill Step 1/3)優先序須實測,不可推斷:
{moduleName}.yml並非走 Spring 標準spring.config.name,而是由框架EnvironConfigInjector經spring.config.additional-location注入。實測結果為外部 conf > feature 片段 > classpath:{moduleName}.yml > application.yml——片段承載 feature 預設,環境覆蓋鏈不受影響。ConfigFragmentsTest守三條(皆為靜默失敗模式):① 每個宣告的片段確實註冊為PropertySource(斷言 PropertySource 而非屬性值——測試會載入開發者本機的外部 conf,值隨機器而異,實測即踩到);② 匯入清單與config/下的檔雙向一致(多出的檔永不載入、多出的宣告因optional:靜默略過);③ 每個匯入皆帶optional:。以 mutation(import 路徑打錯字)驗證三條皆會失敗。30 個@conf-env標記逐一保存。 -
⏳
/featureskill(status→provision→sync,source-to-target):skill 已落地並於 2026-07-11 重設計為 source-to-target(app-server/.claude/skills/feature/SKILL.mdv2.0.0,模組操作型、隨/practices publish下行)——adopt+add併為provision(自偵測 install/migrate)、由框架駕駛座驅動 fleet、config 搬遷加兩道 guard(見 ADR-007 D-G)。三個模式的流程、閘門與錯誤處理皆已定義。status已手動執行驗證(對ict-server產出可用的 provision 候選報表);provision的 migrate 路徑已於almanac-server的cache首驗成功(2026-07-11:I3 綠、build 208 tests 全綠、零新增失敗、commita260bd0),並回寫兩個規格洞(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 建議修正:
status對ict-server實測顯示cache(3/3 命中、0 drift)與scheduling(1/1、0 drift)逐字相同,是零風險的 provision 起手式;ADR-007 依檔案數建議的almanac-server是另一個維度的考量(無 remote、可安全回退)。兩者不衝突:選almanac-server的cache起手。pilot 首輪(2026-07-11) 已確認 almanac cache core 3/3 本體零漂移、seamCacheConfig為 almanac 客製,並催生 skill 的 source-to-target 重設計與 D-G 設定 guard。 -
✅
/scaffold-moduleStep 5 改為 feature-aware 複製(2026-07-11,skill v4.5.0,核心機制已實跑驗證):來源 server 有features.json(role: source)時,Step 5 分派為 feature-aware——required必帶、optional按選擇帶(requires遞移閉包)、{feature}/demo/(wiring slice)一律不帶(與/featureinstall 同哲學)。⚠️ 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-accountfeature):① 預設全包含(drop demo/ + 全 feature + 下游 catalog)→compileJava綠、--check(I3)綠;② 剝掉beanRequires的負向控制 →--check如預測對almanac/file/notification失敗(證實下游 catalog 必須保留beanRequires);③ 精簡裁calendar+audit→--check綠、compileJava+compileTestJava綠;④ 裁 skeleton-IT 引用的service-account的負向控制 →compileTestJava失敗(證實當時此類 feature 必須排除於safeDroppable)。未驗證:完整 skill 儀式(Step 7 retarget、README/.code-workspace/project.jsonbookkeeping、smoke test、選單 UX)——與核心機制正交、多為既有已測路徑。 - 業務殘餘的 feature 耦合(新發現,非設計時預期):scaffold 一律帶入的業務領域(
order/customer/sales)硬 importnotification/tenant/reference-data等 optional feature——拿掉即compileJava失敗。故選擇只 offersafeDroppable(optional − 業務 demo 依賴 − 保留 feature 的requires閉包),預設全包含(=現行行為減去 demo/),零腳槍。要連業務領域一起精簡屬/scaffold-template職責。 - 順帶補既有缺口:新增 Step 6.8 把
cp -r帶過去的role: sourcefeatures.json重戳為role: downstream(收斂為帶入集、seam 路徑 retarget、syncedSHA= 來源 HEAD)——與 practices.json 在 Step 6.7 前的病相同(先前 scaffold 出的 server 都帶著錯的role: sourcecatalog,破壞/feature的 role 唯一性守衛)。Step 6.6 的 stamp 迴圈加[ -f ]守衛,容忍 Step 5.4 裁掉的 demo/。 - demo/ 一律不帶為刻意決策(非
/feature install的機械沿用):非 demo 碼對 demo 型別零編譯依賴(僅///javadoc 註解引用);代價是 seed 花店失去「feature 如何接到業務領域」的 wiring 範例,團隊自行接線。
- 實跑驗證(scratch 標的對
既有下游怎麼接上:以上七步全部作用在框架自身的
app-server,第 7 步只嘉惠新專案。既有下游(layer-first、base package 各異、無 catalog)的遷移路徑由 ADR-007 定義為/feature provision的 migrate 路徑——逐 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.md | D-E 的 core slice 對偶義務應掛入框架新增能力的檢核清單 |
m-methodology-hygiene.md | core slice 為中性(框架 primitive)、業務域為參考實作 convention——即其「primitive vs convention」判準在程式碼組織層的落地 |