ADR-010: feature slice 收納於 {basePkg}.feature.* 命名空間
ADR 編號: 010 狀態: 已接受 (Accepted) 決策日期: 2026-07-20 決策者: AppFuse Team + AI Assistant 範圍: docs-methodology(feature slice 的 package 落點;
/feature、/scaffold-module與 partition 推導的路徑基準)
摘要
ADR-006 D-C 把 feature slice 落為 {basePkg} 下的頂層 package,與消費端自己的業務域、骨架平起平坐。實務暴露兩個成本:root namespace 被殖民(下游裝 N 個 feature,root 就多 N 個不是自己寫的 package,「誰的碼」要查 catalog 才知道);佈局選擇被隱性綁架(框架的 package-by-feature 長在 root,視覺上成為「本專案的佈局慣例」,消費端對自己業務碼的佈局自由名存實亡)。
「佈局的兩種所有權」(17-us-development.md,2026-07-20 自 ict-server reflow)已用文字劃界:{feature}/ 由框架決定、{domain}/ 由專案決定,框架 provision 過的檔放在哪不構成本地碼該放在哪的論據。本 ADR 把這條線做成結構:
全部 feature slice 遷入
{basePkg}.feature.*;feature子樹之下=框架決定,之外=專案決定。
骨架與業務域不搬。root package 不變,Spring 掃描、config fragment、框架 jar 零觸碰。時機拍在現在:已 provision 的下游只有一個(ict-server,剛完成重 baseline、diff 最乾淨)、尚未進正式環境;almanac/tts 尚未 provision,重構先行則它們日後直接落在新佈局、零遷移。
背景 (Context)
問題陳述:圍籬只存在於文字
ADR-006 遷移後的 app-server(與 provision 後的下游)root package 長這樣:
{basePkg}/
├── acting/ almanac/ audit/ auth/ cache/ calendar/ delegation/
│ file/ impersonation/ mail/ notification/ notificationtenantoverride/
│ platforminfo/ referencedata/ scheduling/ serviceaccount/ signedlink/ tenant/
│ ← 18 個 feature(框架決定、sync 覆蓋)
├── customer/ order/ sales/ dashboard/
│ ← 業務域(專案決定;參考實作為 @reference-surface)
└── config/ entity/ exception/ handler/ initializer/ listener/ mapper/
← 骨架(框架種下、專案延伸)
三種所有權完全不同的 package 平鋪在同一層。後果:
- root 殖民:下游的 root 被 18 個框架 package 佔據,自己的業務域淹沒其中。哪些檔會被
/feature sync覆蓋、哪些是自己的,結構上不可見——只能查features.json。 - 佈局綁架:頂層 feature package 群使 package-by-feature 看起來像「專案的慣例」。「佈局的兩種所有權」節已澄清那只是框架自己的佈局決定,但文字紀律敵不過視覺慣性——下游想以 layer-first 排自己的業務碼,會顯得與「全專案」格格不入。
- 混合 package 噪音:本地碼塞進
mail/(看起來像鄰居)的門檻遠低於塞進一個明顯是框架領地的子樹;每個混合 package 都在/feature provision/status產生一筆要人裁決的報告噪音(見「佈局的兩種所有權」的「唯一的成本」)。
為什麼是現在(機會窗口)
- fleet 中已 provision 的 server 下游只有 ict-server 一個,且剛完成重 baseline(localSeams 只剩真客製、全 feature syncedSHA 對齊、I3 PASSED、build 綠)——遷移 diff 處於最乾淨狀態。
- ict 尚未進正式環境;上線後遷移須背部署風險,現在只需一輪完整測試。
- almanac-server、tts-server 尚未 provision(仍 layer-first)。順序若反過來(先 provision 再重構),它們要各付一次遷移;重構先行則 provision 時直接落在新佈局。
決策 (Decision)
D-A:feature slice 遷入 {basePkg}.feature.{featurePkg}
全部 feature(含 tenant、notification-tenant-override 等微 feature)自 root 遷入 feature 子樹:
{basePkg}/
├── feature/ ← 框架決定的一切 feature slice(/feature sync 的作用面)
│ ├── auth/ cache/ mail/ file/ notification/ …
├── {domain}/ ← 業務域:專案決定(參考實作為 @reference-surface)
└── config/ entity/ … ← 骨架:不搬(見 D-C)
- package 名用單數
feature:與既有骨架的單數類別 package(config、entity)一致;{basePkg}.feature.mail讀作「feature: mail」。 - feature id 中的連字號照現行慣例壓平為 package 名(
notification-tenant-override→notificationtenantoverride)。 - 無命名衝突:不存在名為
feature的 feature id 或業務域。
D-B:骨架與業務域不搬
| 面 | 處置 | 理由 |
|---|---|---|
骨架(config/、entity/base/、exception/…) | 留 root | 業務 entity 繼承 entity/base 的 audit 基類、骨架是專案會延伸的框架(config/TimeConfig 等)——搬動的侵入性大得多;且 root 留幾個 Spring 慣例 package 不是本 ADR 要解的痛(殖民感來自 18 個 feature,不來自 config/) |
業務域({domain}/) | 留 root | 它們就是專案的碼(參考實作的為 @reference-surface、宿命是 retarget/prune);root 屬於專案,業務域住 root 正是 D-A 要成全的事 |
D-C:root package 不變,接線零觸碰
@ComponentScan/@EntityScan/@EnableJpaRepositories 一律指根 package(ADR-006「不要把 package 名寫進字串」的既有紀律),掃描自然涵蓋 feature 子樹——Spring 接線零修改。config fragment(config/feature-{id}.yml)是 resources、與 package 無關。框架 jar(io.leandev.appfuse.*)零觸碰。
D-D:為何在 {basePkg} 之下、而非框架 namespace
曾考慮 io.leandev.appfuse.feature.*(框架 namespace)——否決:provision 的碼是應用擁有的(隨 scaffold retarget 到下游 basePkg;seams 由專案修改、@ConfigurationProperties 與掃描根都錨定 app package)。放框架 namespace 會製造「看似 jar、實為源碼」的身分謊言,且 retarget 機制(P3)以 basePkg 為替換錨點、不容第二個根。{basePkg}.feature.* 誠實表達「這是你的碼、但佈局由框架管理」。
D-E:partition 推導隨之簡化(單一實作)
「業務=殘餘」(ADR-006/FU-52)的推導從「頂層 package 不在 features 也不在 skeleton」變成:
feature/子樹=feature slice;skeleton宣告=骨架;root 其餘頂層 package=業務。
殘餘空間更小、誤判面更小。實作仍收斂於 feature-inventory.py 單點(/scaffold-module 呼叫它、不重推導);variant-assembly.py 的組裝樹、排除集、per-variant I3 同步改認新根。variants/ overlay 樹鏡像 src 路徑、一併搬遷。
D-F:下游 catalog 的 seam 路徑連動改寫
下游 features.json 的 seams/localSeams 內嵌完整檔案路徑(如 src/main/java/{basePkgPath}/mail/MailConfig.java)——遷移時一併機械改寫為 …/{basePkgPath}/feature/mail/…,並以 I3 校驗收口。此為 ict 遷移步驟的一部分,不留半新半舊。
考量的方案 (Alternatives)
| 方案 | 說明 | 為何不採 |
|---|---|---|
| 維持現狀(ADR-006 D-C 原樣) | feature 續留頂層 | 三個成本(殖民、佈局綁架、混合 package 噪音)是慢性的,且隨 fleet 增長單調惡化;機會窗口(單一 provisioned 下游、未上線)一旦關閉,遷移成本跳階 |
features 複數 | {basePkg}.features.* | 純命名之別;單數與既有 config/entity 等類別 package 風格一致,採單數 |
| 框架 namespace | io.leandev.appfuse.feature.* | 見 D-D:身分謊言 + retarget 錨點衝突 |
骨架一併搬(如 {basePkg}.framework.* 收骨架+feature) | 更徹底的圍籬 | 侵入性跳階(業務 entity 繼承鏈、@ConfigurationProperties 前綴慣例、下游既有 import 面全動),而骨架不是痛源;不划算 |
後果 (Consequences)
正面
- 所有權圍籬結構化:
feature/之下=框架管理(sync 覆蓋)、之外=專案的——「佈局的兩種所有權」從文字紀律升級為目錄事實。 - 消費端佈局自由兌現:root 屬於專案;業務碼採 package-by-feature 或 layer-first 純屬專案選擇,不再有視覺壓力。
- 混合 package 噪音萎縮:本地碼誤入框架領地的機率隨結構圍籬下降。
- partition 推導更硬:殘餘空間縮小(D-E)。
負面 / 成本
- 一次性機械 churn:框架 app-server ~18 個 package 的
git mv+ 全量 import 改寫(含業務域對 feature 的接線、測試)、variants/樹搬遷、兩支工具與相關 rules/skills 更新;ict-server 同型遷移 + catalog 路徑改寫 + 完整測試。全部編譯器可見、無靜默失配面(字串編碼路徑除外——遷移時依 P3 紀律 grep 收口)。 - 歷史文檔的路徑示例過時:既有 ADR/FU 記錄中的舊路徑(
{basePkg}/mail/…)成為歷史敘事,不回改(ADR 是決策當時的記錄)。 - package 路徑加深一層:純外觀成本。
中性
- 未 provision 的下游(almanac/tts 與所有僅前端/docs 的專案)零影響——provision 時直接落新佈局。
- 框架 jar、config fragments、
@ComponentScan接線不變(D-C)。
不採用是安全的
維持現狀不產生新破壞——成本是慢性的殖民與噪音,與 fleet 規模同步累積。但機會窗口不可回復:一旦 almanac/tts provision 或 ict 上線,同一決策的執行成本跳階。
落地順序
順序即窗口紀律:在 almanac/tts provision 之前、ict 上線之前完成。
| # | 步驟 | 產出 / 驗收 |
|---|---|---|
| 0 | feature 單數、骨架不搬 | |
| 1 | 225e5dc9,250 檔) | 32 目錄 git mv(main 18+test 12+variants 2)、246 檔 FQN/路徑改寫(長名優先+\b 邊界)、catalog seam 路徑(D-F);app-server 全量測試綠、I3、variantCheck、完整 I4(ownership 217 tests)全綠。工具(feature-inventory.py 的 FEATURE_NS+skeleton 吞併守衛、variant-assembly.py 的 unit_of 排除比對)與 FeatureSliceArchitectureTest(I1/I2 pattern)於本步一併 namespace-aware 化 |
| 2 | fa8cc5d0) | /feature(P3 migrate 目標)、/scaffold-module(businessDepended 掃描、裁剪路徑、stamp 表)、m-reference-code(佈局 A partition)、m-reference-prune(域識別)、17-us-development(目錄樹+所有權表);殘留掃描零命中 |
| 3 | f736c2a2,已 push) | 11 目錄 git mv、169 檔改寫、catalog seams/localSeams 路徑改寫+全 10 feature syncedSHA 推進 fa8cc5d0(框架 src delta 經驗證純為 namespace 遷移);I3 PASSED、ict 全量 build 綠。字串引用僅根 package、不受影響 |
| 4 | /methodology publish+/practices publish 全 fleet 對齊 fa8cc5d0(6 workspace/20 模組;server type 下行規範與工具、其餘 type 純 stamp;local 面斷言通過) | |
| 5 | (之後)almanac/tts provision 直接落新佈局 | 無額外工作,僅註記 |
與其他 ADR 的關係
| ADR | 關係 |
|---|---|
| ADR-006 | 修訂其 D-C 的落點:package-by-feature 的切片原則不變,feature slice 的居所由 root 頂層改為 feature/ 子樹;「不要把 package 名寫進字串」紀律原樣沿用且是本遷移的安全前提 |
| ADR-007 | adopt/provision 的目標佈局隨本 ADR 更新;drift 三分類、逐 feature 增量、每步 build 綠的紀律不變 |
| ADR-009 | overlay 樹鏡像 src 路徑、隨遷移搬入 feature/;I4 是本遷移的驗收 harness 之一 |
| ADR-004 | 無交集(本 ADR 不動隔離語意),僅 tenant feature package 隨遷 |
拍板紀錄(2026-07-20)
接受 D-A ~ D-F。 命名定案=單數 feature;骨架與業務域不搬(D-B 原樣)。維護者於審閱時要求對 D-D 展開說明(namespace=擁有權、目錄圍籬=治理權的兩軸區分;retarget 單錨點、split package/package-private 洩漏、掃描第二根三個機制性理由),說明已納入本文 D-D 節的精神、無修訂。拍板當時的 fleet 事實:唯一 provisioned 下游 ict-server 處於乾淨 baseline(localSeams 僅真客製、I3 PASSED)、未上線;almanac/tts 未 provision。