架構是成本 #2:模組怎麼切:邊界的證據在資料與路由,不在資料夾
路由給候選,資料給邊界。切太粗可以之後再拆,切太細要縫回來,而縫的期間資料還在繼續寫。
架構是成本 #2:模組怎麼切:邊界的證據在資料與路由,不在資料夾
路由給候選,資料給邊界。切太粗可以之後再拆,切太細要縫回來,而縫的期間資料還在繼續寫。

封面:資料夾分層的水平長條被幾道垂直的接縫穿過,標題「模組怎麼切
快速上手區
一句話:模組邊界的證據在資料與路由,不在資料夾;資料夾只記得當初怎麼寫,而那跟現在該怎麼切沒有關係。
帶得走的三件事
- 我會先挑一支寫入 api,把它在一次交易裡碰到的表列出來,因為邊界的證據在那裡,不在資料夾。(十分鐘)
- 我會在想切第二刀之前先問「切開之後多出幾筆跨界寫入」,因為升級比降級便宜,而切太細縫不回來。(一小時,要把兩邊的表對一遍)
- 我會把
shared/裡的檔案逐個問「誰有權寫它」,因為說不出主人的那個不是共用,是還沒找到家。(看shared/多大,通常一個下午)
怎麼跳著讀
- 如果只讀一段,我會讀第八節:什麼時候不該切。這一篇最容易被誤用的地方就在那裡。
- 要判準,第六節那四題與一道輸出檢查(表格與可照跑的程序各一份),第七節三條 gate 擋得住與擋不住的東西。
- 要看它怎麼跑起來,第十二節:8 個路由前綴切到 3 個模組,產物一步步長出來,中間切錯一次。
- 要證據,第二節與第十一節,引用都標了檔案與行號。
這篇很長。我自己讀長文會掉出去,所以先把結論跟導覽放這裡,跳著讀也行。每一篇都會有這一區。
一、現象:資料夾很整齊,所以「沒辦法切」
一個跑了幾年的服務,幾萬行。打開它,資料夾非常整齊:
application/
controllers/
domain/
infrastructure/
四個資料夾,分層分得清清楚楚,每一層裡面按類型再分。看起來是有架構的。
於是問「這個服務能不能拆成幾個模組」,得到的答案是「拆不了,它全部黏在一起」。而那個答案是看著資料夾得出來的。
我後來養成的第一個動作是不看資料夾,改看路由表。同一個服務,路由是這樣的:
/orders /order-items /carts /checkout
/inventory /reservations /shipments /returns
八個相異前綴。接縫一直都在,只是資料夾不長那個樣子。
⚠️ 從這裡開始會出現 檔案:行號。那些路徑指向我自己那套設計文件與 gate 腳本,我寫出行號有兩個用途:自己改的時候有東西可以回頭核,以及讓「這不是我臨時編的」有一個著落點。但位置會漂。 那些檔改一次,這篇裡的每一個行號就指向別的東西,而文章發出去之後不會跟著更新,漂了也沒有任何跡象。所以要帶走的是那條判準的形狀,不是那一行的位置。
這件事在我們自己的設計文件裡有一條實測(plugins/eng-contract/skills/design-flow/references/03-boundary.md:25-27):一個幾萬行的服務,資料夾是水平分層,照資料夾看會得到「沒有 sub-module 可切」的結論;但路由是垂直的,十幾個相異前綴,接縫一直都在。 而照寫入群集收斂之後,切出來的模組數比前綴數少得多。

左邊照資料夾看:四層水平長條,任何一刀都切斷每個功能,結論是拆不了
二、這件事貴在哪:切錯有兩個方向,代價不對稱
第 1 篇算的是一支 api 的深度,量度是跳檔數與可達狀態數。模組這一刀要換一組量度,因為它量的是別的東西:
- 跨界寫入數:有幾個地方在寫別人家的表。
- 一條需求要動幾個模組:大於 1,代表邊界從那條需求中間穿過去了。
兩個方向的切錯,代價形狀不一樣。
切太粗:模組化名存實亡。症狀是所有東西都在同一個模組裡,跨界寫入數是 0(因為根本沒有界),但一條需求要動的檔案散在各處。代價是持續的:每個人每次都要在一大坨裡面找。
切太細:製造出原本不存在的跨界寫入。症狀是模組表很漂亮,但下單這件事要跨兩個模組一起寫。代價是結構性的:交易被切成兩半,而那件事沒有辦法靠加註解或加測試補回來。
📌 兩者的差別是這一篇的核心:切太粗可以之後再拆,切太細要縫回來,而縫的期間資料還在繼續寫。 這跟第 1 篇 A-01 那條「預設走最短路徑」是同一個形狀,只是尺度換了:猜錯的兩邊價錢不對稱,所以預設站在便宜的那邊。
三、三刀,深度是固定的所以數得完
切分不是一件事,是三刀,而且深度固定:
模組(BC) 跨模組的需求硬停,建外部依賴表
sub-module 路由給候選,資料給邊界,三型獨立性判斷
sub-module 內部群 依 api path 在 sub-module 之後的第一段分群;單一群就平鋪
沒有第四層。 這條不是潔癖(plugins/eng-backend/skills/impl-layout/SKILL.md:19-20):群一大就會有人就地開子目錄掩蓋,而複雜度應該反映成群數增加,不是靠 nesting 分散掉讓問題隱形。
深度固定,「這三刀」才數得完;深度浮動,每一次新增都變成一次小型設計決策。
原則一句話:模組是意圖的所在。切分的證據在資料與路由,不在資料夾。
資料夾反映「當初怎麼寫」,路由反映「使用者怎麼用」,而寫入群集決定邊界(03-boundary.md:30-32)。三者只有後兩個跟「現在該怎麼切」有關。
四、方案並排:三種切法,各自標價
同一個服務,三種切法。代價分兩類:「不可能」的事做不到就是做不到,不標價;「有代價」的事做得到,價錢寫在括號裡。
案 A:照資料夾切
- 價錢:零。它已經在那裡了。
- 不可能:切不出模組。水平分層的資料夾裡,每一層都同時屬於所有業務,切開任何一刀都會把每個功能都切斷。
- 有代價:不適用,因為它做不到。
案 B:照路由前綴切
- 價錢:一個前綴一個模組,八個前綴八個模組。
- 不可能:沒有。技術上切得出來。
- 有代價:會製造原本不存在的跨界寫入(價錢:每一筆跨界寫入都是一條沒有人維護的狀態機的弧,而且它不會報錯)。前綴是使用者怎麼用,不是哪些表必須一起寫入。
案 C:路由給候選,資料給邊界
- 價錢:要先盤一次「哪些表總是被一起寫」,那件事沒有捷徑。
- 不可能:沒有。
- 有代價:切出來的模組數比路由前綴少,看起來比較粗(價錢:有些人會覺得不夠模組化,那要用 duty 說服他,見第七節)。
三案裡只有 C 用到那條硬約束。而那條硬約束只有一句(03-boundary.md:51):必須在同一個交易內一起寫的表,不可以被切到兩個 sub-module。 切開就是把狀態機切兩半。

左邊切之前:Order、OrderItem、Inventory
五、demo:最小的那個產物長什麼樣
切分的產物不是資料夾,是兩張表。先給形狀,因為形狀跨語言、跨框架:
輸入 路由前綴清單(誰在被用) + 資料表清單與寫入群集(什麼總是被一起寫)
│
├─→ 候選:一個前綴一個候選
├─→ 收斂:把候選壓到寫入群集上
└─→ 判型:每個候選能不能獨立
│
輸出 模組表(誰擁有哪些表、duty 一句話)+ 分群表(每支 api 歸哪一群)
最小的那個案例,一個模組、一群,兩張表長這樣:
模組表
| 模組 | 擁有的表 | duty |
| orders | Order, OrderItem | 訂單的建立與取消 |
分群表
| 群 | 收哪些 api |
| (平鋪) | POST /orders, DELETE /orders/:id |
⚠️ 單一群的時候寫「平鋪」不包那層目錄。那層目錄在只有一群的時候不提供任何分類價值,包了只是多一層要穿過。
六、判準表與正反例
每一個候選逐題問下去。候選是路由給的,而前三題判的都是資料(誰擁有哪些表、哪些表必須一起寫),第四題判的是需求有沒有人認領。最後一條不對單一候選跑,它檢查整張模組表:
- 它擁有的表裡,有沒有必須跟別人的表在同一個交易裡一起寫的|答「是」就:不能獨立,跟那些表合成同一個模組;為什麼是這一題:切開就是把狀態機切兩半,而那件事沒有辦法靠註解或測試補回來
- 它是不是什麼表都不擁有、只呼叫別人的 use case|答「是」就:不用建模組,它是 client 不是零件;為什麼是這一題:沒有擁有物就沒有邊界要守;給它一個模組只是多一個名字
- 它有沒有自己的表,加上在別人表上一個只有它寫的欄位,而且零交易|答「是」就:可以獨立,那個寄居欄位要登記;為什麼是這一題:寄居跟共用不一樣:只有它寫,所以沒有第二個人的狀態機被穿過
- 有一條需求指不出任何一個模組認領它|答「是」就:開新模組,但必須同時寫出「為什麼不能塞進任何一個既有的」;為什麼是這一題:寫不出第二句就是塞得進去,那條需求只是還沒被登記
- 模組數比寫入群集的群數多|答「是」就:收回去,有一刀是前三題之後才切的;為什麼是這一題:內聚度不同不足以構成理由,而切錯製造的跨界寫入縫不回來(第八節專講)。⚠️ 這一條不對單一候選跑,它檢查整張表
前三題是三型獨立性判斷,逐字在 03-boundary.md:60-64;第三題的驗法是一行 grep,見第十節。
同一件事寫成一段可以照著跑的程序。表格會被平台轉成圖,轉成圖之後複製不走,所以判準要有一份不是表格的版本:
輸入(前四個打開專案就數得出來,第五個要判一次)
candidates 路由前綴清單,一個前綴一個候選
tables_of[c] 候選 c 擁有的表
foreign_fields[c] 別人的表上,只有 c 寫的欄位
write_sets 必須在同一個交易裡一起寫的表,分成幾群
claims[r] 每條需求指名的認領模組
對每一個候選 c:
Q1 tables_of[c] 與別的候選的表落在同一個 write_set → 合併,不能獨立
Q2 tables_of[c] 是空的 → 不建模組,它是 client
Q3 tables_of[c] 非空 + foreign_fields[c] 非空 + 零交易 → 可獨立,登記寄居欄位
對每一條需求 r:
Q4 claims[r] 是空的 → 開新模組,
且必須寫得出「為什麼不能塞進既有的」
輸出前檢查(對整張模組表跑一次,不對單一候選)
C1 模組數 > write_sets 的群數 → 有一刀是三題之後才切的,收回去
輸出 模組表(模組 → 擁有的表、duty)+ 分群表(api → 群)
⚠️ Q1 到 Q3 對每一個候選跑,Q4 對每一條需求跑,C1 對整張模組表跑一次;四題一檢查讀的都是上面那五個輸入。 前四個都數得出來,沒有一個是「感覺這兩塊該分開」;只有 claims[r] 要判一次,而那正是 Q4 唯一一題答不出來的原因。
📌 C1 為什麼不是一題:它問的不是某個候選能不能獨立,是整張表有沒有比群數多切了一刀。放進迴圈裡它會跟 Q1 重複,因為 Q1 跑完之後不會有兩個模組共用同一群。⇒ 它抓的是前三題之後才下的那一刀,而第十二節第 3、4 步演的正是那一刀。
⚠️⚠️ **write_sets 這一格是半格,這件事我一開始沒講清楚。 打開專案數得到的是「現在哪幾張表被包在同一個交易裡」,而這一格要的是「必須一起寫」。在一個跑了幾年的服務上,那兩件事差很多:一半的大交易是當年為了保險包上去的,不是任何人想過的不變式。那跟資料夾一樣是「當初怎麼寫」**,我在第一節才剛用這個理由否定過資料夾,這裡不能自己免疫。
判法是把那一筆交易拆成兩段,看中間那一刻有沒有一個業務講得出來的不變式會破:庫存不能超賣、同一張單不能被付兩次。破了才是必須,沒破的先不算同一群。
📝 第一題在規格層就答得出來(同一個 Model 有沒有兩個以上入口在寫它),那是系列 B 講的事。兩篇問的是同一個問題,只是站在上游與下游看它。
再講判對與判錯長什麼樣。同一個服務、同一份路由表,兩種切法:
- 先看什麼|好的樣子:先拉路由表與寫入群集,再決定切幾個;對照:先打開資料夾,看它現在長什麼樣
- 模組數的來源|好的樣子:從寫入群集收斂出來的,比前綴少;對照:一個前綴一個模組,數字跟路由表一樣
- 遇到「這兩塊職責不一樣」|好的樣子:先問切開之後會不會多出跨界寫入;對照:覺得職責不一樣就是兩個模組
- 開新模組時|好的樣子:寫得出「為什麼不能塞進任何一個既有的」;對照:寫得出這個模組要做什麼,但沒問過為什麼不塞進既有的
- 共用的東西|好的樣子:每一個都說得出主人;對照:都丟進
shared/,主人以後再說
「遇到職責不一樣時先問什麼」那一條是這組對照的重點,第八節整節都在講它。
七、開新模組的三條 gate:它擋得住什麼、擋不住什麼
只留三條,理由是這三條機械檢查得到(plugins/eng-contract/scripts/gate-claims.mjs:9 就在驗後兩條)。驗不到的就不是 gate,是建議,不要混在一起賣。
- 塞不進既有的|判準:開新模組必須寫得出「為什麼不能塞進任何一個既有的」;沒過代表什麼:寫不出第二句就是塞得進去,退回去登記
- duty 寫得出來|判準:一句話、40 字內、最多兩個動作,格式「<領域名詞>的<動作1>與<動作2>」,禁用詞「管理、處理、維護、相關、系統」;沒過代表什麼:超過兩個動作就是切太大,退回重切
- 恰好一個認領者|判準:每條需求指得出唯一的認領模組;沒過代表什麼:沒有認領者代表邊界沒切完;兩個認領者代表邊界重疊
duty 那條的格式與禁用詞逐字在 03-boundary.md:93-95,而「超過兩個動作=切太大的訊號」在 :99。
⚠️ 橫切的約定不屬於任何模組,認領到 BC 公約也算數,但那不能變成逃逸口:認領數超過門檻就升人(03-boundary.md:87-88)。橫切的約定通常只有少數幾條,超過就是在灌。
📌 收尾金句先放這裡:寫不出 duty,不是文筆問題,是邊界還沒切乾淨。「訂單相關功能管理」這種 duty 什麼都配不上,因為它沒有指出任何一個動作。
⚠️⚠️ 這三條擋得住的只有一個方向:切太大。 duty 超過兩個動作、需求沒有認領者、開新模組說不出為什麼不塞進既有的,三條都是「這一刀下得不夠」的症狀。
它們擋不住切太細。 而且情況比擋不住更糟:一個切太細的模組表,每個 duty 都會更短、更乾淨、更容易通過,三條 gate 會全部放行。第十二節第 3 步就是那個樣子。
擋切太細的是第六節第一題(有沒有必須跟別人的表在同一個交易裡一起寫),不是這裡。
📌 這一節的射程要寫出來,理由跟第十一節那支 gate 一樣:驗不到的那一半只要沒被寫出來,它就會被當成驗過了。 節名如果寫成「切完怎麼驗自己沒切壞」,讀者手上就會拿著一組會放行切壞版本的檢查,而標題告訴他那就是驗法。
八、反證:更粗的切法常常是對的
內聚度不同,不足以構成切兩個模組的理由。
逐字在 03-boundary.md:72-74:
⚠️ 「內聚度不同」不足以構成切兩個 sub-module 的理由。 必須切開後兩邊仍各自滿足所有紅線。 🔬 實測踩過:照內聚度切出「技術上更漂亮」的兩個 sub-module,結果製造了原本不存在的跨界寫入。
同一段的結論是:更粗的切法常常是對的。升級比降級便宜,拆是之後的反省,不是現在的預設。
「升級比降級便宜」值得展開,因為它是這一整節的機制:
- 合成一個之後要拆:把一個模組拆成兩個,是一次性的搬移。搬的時候需求已經清楚,拆得比當初猜得準。切成兩個之後要合:兩邊之間已經長出跨界寫入、共用型別、互相呼叫的路徑。合回來要先把那些拆掉,而拆的期間兩邊都還在被使用。

左邊切太粗:從現在到改過之後有一條回頭的實線箭頭,標「回得來」
⚠️ 但這條有失效條件,不寫出來會被拿去合理化「什麼都不切」:
當兩個候選各自都能通過三型判斷、而且切開之後跨界寫入數是 0,那就切。這時候「更粗」買不到任何東西,只是把兩個沒有關係的東西綁在一起。
判準是切開之後多出幾筆跨界寫入,不是「感覺它們不一樣」。多出 0 筆就切,多出任何一筆就先別切。
九、先有 ERD ≠ 照 db table 切模組
這是 data driven 最常見的誤讀,而且整個系列都會被它牽連,所以在這裡正面拆掉。第 4 篇談 ERD 先行的時候會回指這一節,正本在這裡。
data driven 說的是先設計 ERD,不是把 code 按 db table 切分。
兩者是兩把不同的刀,量的東西不一樣:
- 量什麼|ERD:資料的全局一致視圖;模組邊界:意圖的所在:誰有權改變什麼
- 幾份|ERD:一份,全局;模組邊界:多個,分區
- 切錯的症狀|ERD:同一個事實兩個地方定義;模組邊界:一致性邊界被切兩半,或模組化名存實亡
ERD 是意圖的資料表達,不是資料庫的實作藍圖。照表切模組,等於把「資料怎麼存」誤當成「誰該負責」。
正確的形態是把資料庫當成一個共用模組來呼叫:資料是全局的、讀得到;寫入權責是分區的、只有擁有者能寫。落成兩條讀寫分界:
跨模組【讀】 → 可以直接查對方的表(只用查詢,不寫)
跨模組【寫】 → 一律呼叫對方的介面,永遠不要直接寫對方的表
理由要寫出來,不能只給結論:狀態機的弧是有限的、被設計過的。 外部直接更新對方的表,等於偷偷加了一條沒有人維護的弧,而且它不會報錯:欄位型別對、外鍵對、編譯綠,要等到對帳對不起來才發現。
⚠️ 代價要標,而且不只一筆。最明顯的那筆是對方改欄位就會打到這一邊。
⚠️⚠️ 但先付錢的其實是被讀的那一邊:他的 code 裡沒有任何一行指向讀他的人,所以他改欄位之前不會知道有人在讀。那正是上一段禁止跨模組寫的理由(沒有人維護、而且不會報錯)原封不動的弱化版,只是弱在改壞的當下不會有髒資料,而是有人的查詢安靜地錯了。
另外兩筆:直接查表看得到對方多步寫入之間那一刻的中間狀態,走介面時對方只暴露它願意認的狀態;還有依身分過濾的權限,寫查詢條件的是讀方自己的資料存取層,擁有者那邊的過濾條件因此不在路徑上,而那正是第 1 篇把這類權限放進資料存取層的理由。
四筆都願意付才直接讀,不願意就走對方的介面讀。這是取捨,不是對錯。
十、共用的東西要有主人
這是我看過最容易長出垃圾桶的一節。兩種共用要分開處理,混在一起就會長出一個誰都能丟東西的 shared/:
- 程式碼共用|怎麼收:抽一支共用 function(檔案層級),不是抽一層(架構層級);判準:抽完之後模組還是只依賴自己加那支 function,沒有長出新的環
- 資料共用|怎麼收:一張表只有一個擁有者能寫,其他模組要改它就走擁有者的介面;判準:例外是寄居欄位:在別人的表上放一個只有自己寫的欄位,且零交易
程式碼共用那一列是第 1 篇判準表第三題的完整版:那裡只給了結論(抽函式不抽層),這裡給的是主人歸屬的判準。
寄居要能證明,起手是一行 grep(03-boundary.md:66-70):
grep -rn "<欄位名>" <專案>/ | grep -v <候選模組>/
# 0 筆=確定寄居;有命中=候選,要逐處看
⚠️ 這一行答的是「誰提到這個名字」,而判準要的是「只有它寫」,兩者不一樣。 上一節才剛開放跨模組直接讀,所以命中裡本來就會混著讀。照本篇自己的例子跑一次就看得到:訂單詳情要顯示出貨時間,讀了一次 Order.shippedAt,grep 就有命中,出貨那個模組會被判成不能寄居。
所以命中之後還有一步:逐處看它是讀還是寫,只有寫的那些才推翻寄居。 這一步機械做不到,因為讀跟寫在不同的棧裡長得不一樣,而 grep 只認名字。
📌 這正是這個系列一直在講的分界:機械負責把候選找齊,判定歸人。 0 筆是機械就結得掉的那一半,有命中的是人要看的那一半。⚠️ 把整行讀成「有命中=共用」就是讓機械替人作答,而它答錯的時候不會說。
真的有第二個人在寫,那就是共用,必須走擁有者的介面。
📌 shared/ 該不該存在的判準也給得出來:**shared 裡每一個檔案都要說得出它屬於誰。說不出來的,它不是共用,它是還沒找到家。**
十一、交給沒有 review 的執行者
這一篇的判斷落在工作流的哪一格。整條線長這樣,這一篇亮的是邊界切分與決定內部分群兩格:
需求 → 資料模型 → 頁面盤點 →【邊界切分】→ api 契約
→ 契約落地(入口 + 輸入輸出定義 + 假資料,還沒有業務邏輯)
→ 填事實 → 判層數 → 判要不要 domain →【決定內部分群】
→ 實作 → domain 重構(判了要建才有這一格)
這條線是決定的順序,不是某一套工具的站名:不管用什麼語言、有沒有這類流程文件,這些決定都會發生,差別只在有沒有被排過先後。
為什麼是這兩格:邊界切分排在 api 契約之前,因為契約一公開,邊界就跟著固定了。之後才發現切錯,要縫的是已經散出去的跨界寫入,而縫的期間資料還在繼續寫,這正是第二節那個不對稱的代價。決定內部分群排在判層數之後,理由是相反的:它數的是已經存在的檔案,太早分群等於替還沒長出來的東西分類。
這一格的輸入:路由前綴清單、資料表清單、寄居欄位清單、寫入群集、每條需求。前四樣都是打開專案就數得出來的,沒有一樣是「感覺這兩塊該分開」;第五樣「這條需求歸誰」要判一次,而那一格正是下面說的語意歸屬。
agent 的裁量權:結構完整性那一半沒有裁量權,語意歸屬那一半有,而且它答錯的時候不會自己說。
答不出來往哪裡去:把打算切的模組表寫出來給人看,附上每個模組的 duty,不寫理由。duty 可以被指著說「這個超過兩個動作了」。
⚠️⚠️ 最後那句要展開,因為它是這一篇對 AI 最重要的結論,而且它跟直覺相反。我們有一支驗分群的 gate,它的檔頭逐字寫著自己驗不到什麼(plugins/eng-backend/scripts/gate-layout.mjs:12-13):
// ⚠️ 「這條 api 的功能跟哪個資料夾的職責相符」是【語意判斷】,機械驗不到。 // 這支只驗得到分群結果的結構完整性,驗不到分得對不對。⛔ 不要以為它過了就是分對了。
所以這一篇對 AI 的結論不是「切分可以自動化」,是:
結構完整性機械驗得到(不重不漏、群名合法、單一群平鋪、深度固定);語意歸屬機械驗不到(這支 api 的功能屬於哪個職責),那一半仍然是判斷。
📌 一個 gate 說得出自己驗不到什麼,比它多驗兩條有價值:驗不到的那一半只要沒被寫出來,它就會被當成驗過了。 機械化的射程邊界正本在系列 C,這裡只用到它在模組切分上的形狀。
十二、一步一步切一次
前面十一節講的是怎麼判。這一節把第六節那四題與那道輸出檢查走一遍,從八個路由前綴開始,看兩張產物一步步長出來。中間會切錯一次。
每一步只給差異。 一張表只在它第一次出現時給完整內容,之後只貼那一步多出來或改掉的列。
第 0 步|盤點,還沒有任何決定
路由前綴八個,資料表八張:
前綴 /orders /order-items /carts /checkout
/inventory /reservations /shipments /returns
表 Order OrderItem Cart CartItem
Inventory Reservation Shipment Return
到這裡零個模組。這一步的產物只有清單,還沒有任何一刀落下。
第 1 步|哪些表總是被一起寫
下單這件事:建 Order、建 OrderItem、扣 Inventory、建 Reservation,四張表在同一個交易裡。取消與退貨動的是同一組:退貨那一筆另外建一張 Return,而它跟回補 Inventory 是同一個交易,一起成功或一起不成功。
購物車自己一組,跟下單那個交易沒有關係。
出貨寫 Shipment。Order.shippedAt 也是出貨在寫,但它是另一次寫入,跟建 Shipment 那一次沒有被綁在同一個交易裡。
寫入群集表第一次出現,給完整內容:
群集 1 Order, OrderItem, Inventory, Reservation, Return ← 下單/取消/退貨同一個交易
群集 2 Cart, CartItem ← 購物車
群集 3 Shipment ← 出貨
⚠️ **Order.shippedAt 刻意不在任何一群裡。 這張表收的是「必須一起寫」,而它跟 Shipment 沒有綁在一起。它是第 2 步第三題要用的那一格(別人的表上只有出貨在寫的欄位),跟寫入群集是兩回事。混進來會讓第一題把出貨併回訂單,第三題就再也走不到了。**
八個前綴壓在三個寫入群集上。 這一步之後模組數的上限就定了,而它比前綴數少。
第 2 步|逐題判型
第二題先刷掉一個:/checkout 什麼表都不擁有,它只是依序呼叫下單那組 use case。它是 client 不是零件,不給它模組。
第三題留下一個:/shipments 有自己的 Shipment,加上 Order.shippedAt 這個只有它寫的欄位,而且零交易。可以獨立,寄居欄位要登記。
模組表第一次出現:
| 模組 | 擁有的表 | duty |
| orders | Order, OrderItem, Inventory, Reservation, Return | 訂單的建立與取消 |
| carts | Cart, CartItem | 購物車的加入與結清 |
| shipments | Shipment(寄居 Order.shippedAt) | 出貨單的建立與追蹤 |
第 3 步|我在這裡切錯了
看著那張表,我當時很不舒服。orders 那一列擁有四張表,而庫存明明就是另外一件事:它有自己的盤點、自己的補貨、自己的報表,跟訂單的生命週期沒有關係。而且把它們放在一起,orders 那個模組明顯比另外兩個大。
所以我把它拆開:
| 模組 | 擁有的表 | duty |
- | orders | Order, OrderItem, Inventory, Reservation, Return | 訂單的建立與取消 |
+ | orders | Order, OrderItem, Return | 訂單的建立與取消 |
+ | inventory | Inventory, Reservation | 庫存的扣減與預留 |
| carts | Cart, CartItem | 購物車的加入與結清 |
| shipments | Shipment(寄居 Order.shippedAt)| 出貨單的建立與追蹤 |
四個模組,每一個的 duty 都寫得出來,每一個看起來都很內聚。這張表比上一張漂亮。
第 4 步|判準把它推翻
我回頭跑第一題:它擁有的表裡,有沒有必須跟別人的表在同一個交易裡一起寫的?
下單那個交易寫 Order、OrderItem、Inventory、Reservation 四張。切開之後,那四張分屬兩個模組。所以那個交易現在跨模組,而依第九節那條讀寫分界,跨模組的寫要走對方的介面。
有人會說交易帶得過去。沒錯:同一個程序、同一顆資料庫,orders 開的交易讓 inventory 一起寫、最後一次 commit,這是日常寫法。但那不是切開:兩個模組共用同一次提交、同一次 rollback,界線只畫在模組表上,資料那一層根本沒分。
於是真正切得開的路只剩兩條:要嘛讓 orders 直接寫 inventory 的表(那正是第九節說的、偷偷加一條沒有人維護的弧),要嘛把交易拆成兩段再自己補償(那是把狀態機切兩半)。
兩條都是我切之前不存在的問題。 我沒有解決任何東西,我製造了一個。
所以那一刀收回去:
| 模組 | 擁有的表 | duty |
- | orders | Order, OrderItem, Return | 訂單的建立與取消 |
- | inventory | Inventory, Reservation | 庫存的扣減與預留 |
+ | orders | Order, OrderItem, Inventory, Reservation, Return | 訂單的建立與取消 |
| carts | Cart, CartItem | 購物車的加入與結清 |
| shipments | Shipment(寄居 Order.shippedAt)| 出貨單的建立與追蹤 |
📌 收回去之後有一件事看得見了:那張「比較漂亮」的表,漂亮的部分全在 duty 欄。 四個 duty 都合法、都在 40 字內、都只有兩個動作,gate 全部會過。duty 寫得出來只證明沒切太大,不證明沒切太細。 三條 gate 攔不住這一刀,攔住它的是第一題。
第 5 步|盤點的產物
一路走到這裡,兩張產物長這樣:
第 0 步 8 個前綴、8 張表 0 個模組
第 1 步 + 寫入群集表(3 群) 上限定了:≤3 個群集
第 2 步 + 模組表(3 個) /checkout 刷掉、/shipments 獨立
第 3 步 模組表變 4 個 ← 切錯了
第 4 步 模組表收回 3 個 ← 第一題推翻它
8 個路由前綴,3 個模組。 而那個 3 不是我選的,是寫入群集算出來的。
同一條路,教科書的標準做法會怎麼走
把同一件事交給另外兩種做法:
- 對「切幾個模組」有沒有說法|照 DDD 戰術設計:有:Bounded Context;照 Clean Architecture:沒有;這一篇:有:寫入群集
- 判準是什麼|照 DDD 戰術設計:語言邊界、團隊邊界;照 Clean Architecture:不適用;這一篇:必須一起寫的表
- 在戰術層有沒有可執行步驟|照 DDD 戰術設計:沒有,那屬戰略設計;照 Clean Architecture:不適用;這一篇:三型判斷 + 三條 gate
Clean Architecture 對這一題沒有說法,而那不是它的缺陷。 它管的是依賴方向,也就是一個東西內部的環怎麼排;它從來沒有說要處理「這些東西該切成幾塊」。它沒有理由要有那個說法。
DDD 有說法但用不上,理由不一樣:Bounded Context 是戰略設計的產物,而戰略設計正是這個系列一開始那句指控的所在:它是好東西,但很少人真的做,因為它沒有給出可以在星期一早上執行的步驟。
📌 所以這是一場關於可執行性的賭注,不是一場關於誰的理論比較完整的爭論。DDD 給的答案在概念上比寫入群集更完整,而寫入群集的優勢只有一個:它是打開資料庫就數得出來的。
十三、收尾:切不好的時候,往回修那一刀
這一篇還有一個收束,它同時回答了「高內聚低耦合怎麼來的」。
模組是意圖的所在。 邊界照意圖切好之後,一個聚合要動的東西自然都在同一邊,純資料操作也不必跨界。高內聚低耦合是切對之後的結果,不是另外追求的目標。
反過來說也成立,而這是更有用的診斷:
如果做聚合老是要跨模組、純資料操作老是要 join 到別人家,那不是 DDD 沒做好,是邊界切錯了。 往回修的是那一刀,不是再加一層。
📌 這句是第 1 篇那句「抽象要付費」在模組尺度上的另一面:那裡的錯誤是加了不需要的層,這裡的錯誤是切了不該切的界,而兩種錯誤都會被誤診成「架構做得不夠」,然後用加東西的方式去解決。
本篇憲章條文
A-04|複雜度要反映成看得見的量,不准靠 nesting 藏起來。
- 為什麼:藏起來的複雜度不會消失,只會延後被發現,而發現的時候通常沒有人記得當初為什麼那樣切。
- 為什麼不能是另一種:「該分就分,靠 review 把關」抓得到單次違規,抓不到累積。換成 agent 執行的時候,連單次都抓不到。
- 撤銷條件:當有一個量測能在複雜度累積時自動亮燈,而且不靠人看,這條可以放寬成「亮燈才處理」。
A-05|模組是意圖的所在;切分的證據在資料與路由,不在資料夾。
- 為什麼:資料夾記的是當初怎麼寫,那是歷史;路由記的是別人現在怎麼用它,寫入群集記的是什麼必須一起改。後兩者才跟「誰有權改變什麼」有關。
- 為什麼不能是另一種:「照現有資料夾切比較不會動到太多東西」把遷移成本當成了設計判準。那兩件事沒有關係,而且照資料夾切在水平分層的專案上根本切不出來。
- 撤銷條件:當資料夾結構本身就是照寫入群集建的(例如專案從第一天就按模組分目錄),資料夾與資料給的是同一個答案,這條失效。差別在於它失效是因為兩者一致,不是因為資料夾變成了證據。
A-06|共用的東西要有主人:說得出誰有權寫它。
- 為什麼:說不出誰能寫的東西,每一次改動,都是一次暗中發生的邊界決定。在程式碼那一半它累積成一個誰都能丟東西的目錄,在資料那一半它累積成一張沒有人負責的表。兩邊的症狀不一樣,病因是同一個。
- 為什麼不能是另一種:「先放著,之後再整理」假設之後會整理。實際上整理的前提是說得出每一個屬於誰,而那件事在放進去的當下最容易回答,之後只會更難。撤銷條件:當「誰有權寫它」是被登記過的、而不是要從 code 推出來的(檔案靠命名或註冊表,資料表靠一份寫得出擁有者的對照),那個東西就不是垃圾桶,這條對它失效。⚠️ 拿 grep 盤寫入點不算:第十節那一步已經說明,讀跟寫在不同的棧裡長得不一樣,機械分不出來。
下一篇把 DDD 當成本秤一次:什麼時候值得建 domain、三個問題怎麼問,以及貧血模型為什麼是半成品而不是折衷。
系列閱讀:架構是成本
- 架構是成本 #1:洋蔥的一刀切:方向是對的,被讀成層數的是另一個問題
- 架構是成本 #2:模組怎麼切:邊界的證據在資料與路由,不在資料夾
- 架構是成本 #3:把 DDD 當成本:要不要建 domain,以及貧血模型為什麼是半成品
- 架構是成本 #4:ERD 先行:先定案的東西,錯起來也最遠
- 架構是成本 #5:domain layer:一個訂單,九次加功能
- 架構是成本 #6:為什麼這套結構讓 agent 寫得對
메타데이터
- post_id
- f674cdc7dc5b
- slug
- 架構是成本-2-模組怎麼切-邊界的證據在資料與路由-不在資料夾-f674cdc7dc5b
- url
- https://medium.com/@hohshencode/%E6%9E%B6%E6%A7%8B%E6%98%AF%E6%88%90%E6%9C%AC-2-%E6%A8%A1%E7%B5%84%E6%80%8E%E9%BA%BC%E5%88%87-%E9%82%8A%E7%95%8C%E7%9A%84%E8%AD%89%E6%93%9A%E5%9C%A8%E8%B3%87%E6%96%99%E8%88%87%E8%B7%AF%E7%94%B1-%E4%B8%8D%E5%9C%A8%E8%B3%87%E6%96%99%E5%A4%BE-f674cdc7dc5b
- canonical_url
- https://medium.com/@hohshencode/%E6%9E%B6%E6%A7%8B%E6%98%AF%E6%88%90%E6%9C%AC-2-%E6%A8%A1%E7%B5%84%E6%80%8E%E9%BA%BC%E5%88%87-%E9%82%8A%E7%95%8C%E7%9A%84%E8%AD%89%E6%93%9A%E5%9C%A8%E8%B3%87%E6%96%99%E8%88%87%E8%B7%AF%E7%94%B1-%E4%B8%8D%E5%9C%A8%E8%B3%87%E6%96%99%E5%A4%BE-f674cdc7dc5b
- author_url
- https://medium.com/@hohshencode
- status
- ok
- fetched_at
- 2026-09-15 12:40:28