C# 架構實戰比較:同一個訂單 API,三層、DDD 與垂直切片怎麼選?
前言
假設今天要用 C# 寫一套訂單後端,包含建立訂單、查詢、付款狀態更新與取消訂單。採用三層架構,可以從 Controller → Service → Repository 開始;採用 DDD,可能會出現 Application、Domain、Infrastructure;採用垂直切片,又會變成 Features/Orders/Cancel。光看目錄,很難判斷這些選擇在實際開發上差在哪裡。
這篇固定使用同一套 ASP.NET Core API、EF Core 與 PostgreSQL,以「取消訂單」比較三種寫法,再把需求改成「多個入口共用規則」「與付款同時發生」「取消後要發送通知」,觀察程式碼與責任如何變動。比較集中在專案內部的設計、開發與維護成本。
本文的「垂直架構」指 Vertical Slice Architecture,垂直切片架構。程式碼為說明責任配置的核心片段,省略 DI、完整型別、EF 對應與例外轉譯,沒有建立或執行三套可重現的 C# 範例專案;文中的選型是依情境提出的工程判斷。
一、先釐清:這三個名稱關注的事情不同
三層、DDD 與垂直切片可以放在一起討論,但需要先對齊它們各自解決的問題。
| 觀點 | 最關心的問題 | 在 C# 專案中的常見表現 |
|---|---|---|
| 三層架構 | HTTP、商業處理、資料存取如何分工? | Presentation、Business、Data |
| DDD(領域驅動設計) | 商業名詞、邊界與規則如何被正確表達? | 限界上下文、聚合、實體行為、值物件 |
| 垂直切片架構 | 同一個功能需要一起修改的程式碼,如何聚在一起? | Features/Orders/Cancel、GetDetails |
這裡的三層是邏輯上的責任分層,可以全部執行在同一個 ASP.NET Core 程序裡。Microsoft 也區分了邏輯上的 layer 與實體部署的 tier;多個 layer 可以放在同一個 tier。Microsoft:N-tier architecture
DDD 涵蓋的範圍更廣。策略設計會先處理業務語言與限界上下文(Bounded Context),戰術設計才涉及聚合、實體等程式設計方式。把方案切成四個 .csproj,仍需要回答規則由誰負責、模型代表哪個業務範圍。Martin Fowler:Bounded Context
垂直切片則以用例為組織單位,讓同一功能的輸入、處理與輸出靠在一起。Jimmy Bogard 提出的方向是降低切片之間的耦合,讓各個用例選擇合適的實作方式。Jimmy Bogard:Vertical Slice Architecture
因此,DDD 可以放進三層架構,也可以和垂直切片搭配。為了看出差異,以下先比較三種具體實作:
- 三層架構,規則集中在應用服務。
- 採用 DDD 模型的分層架構,規則交給領域物件。
- 垂直切片,簡單用例直接由自己的 Handler 處理。
二、固定同一個專案與驗收條件
比較的範圍是一個可獨立部署的 Ordering 服務,三種寫法使用相同資料表、索引、查詢條件與一致性要求。API 可以用 Controller 或 Minimal API 暴露,這個選擇不決定內部架構。
取消訂單的行為
這次實作的入口為 POST /orders/{id}/cancel,請求內容只包含取消原因 reason。三種寫法都必須符合以下契約:
| 條件 | 預期行為 |
|---|---|
| 尚未登入 | 由 API 認證機制拒絕,回應 401 |
| 訂單不存在,或不屬於目前買家 | 回應 404,避免透露別人的訂單是否存在 |
| 取消原因為空白 | 回應 400 |
狀態為 Pending |
改成 Cancelled,保存原因與 UTC 時間,回應 204 |
已經是 Cancelled,且本次請求格式有效 |
回應 204,保留第一次的原因與時間,不再建立取消事件 |
| 已付款或已出貨 | 回應 409,本版功能不接受取消 |
| 取消與付款同時更新同一筆訂單 | 偵測版本衝突,重新確認狀態,避免互相覆蓋 |
| 取消後需要通知其他系統 | 狀態與 Outbox 訊息在同一個本地交易提交 |
buyerId 由伺服器從已驗證的使用者身分取得,不能直接相信 Request Body 傳來的買家編號。後面的 FindOwnedAsync 都代表「以訂單 ID 加上目前買家 ID 查詢」,並載入可追蹤的實體。
程式碼共用的持久化契約
為了讓片段可比較,先說明省略部分的責任:
CancelResult表達成功、查無訂單、原因無效與狀態不允許,由 API 層轉成前述 HTTP 回應。clock是注入的 TimeProvider,用來取得 UTC 時間;領域方法則接收已取得的時間值。OutboxMessage.ForOrderCancelled(order)建立帶有唯一訊息 ID 的待發送資料,內容取自已記錄的訂單取消結果。- 分層版本的 Repository、Outbox 與 Unit of Work 共用同一個 scoped
DbContext;新增 Outbox 時先加入追蹤,最後統一儲存。 - 三種寫法都必須設定 EF Core concurrency token;後面會解釋其作用。片段中的
if無法取代這個設定。
Outbox 的用途是把「需要通知」這件事持久保存,讓背景工作稍後發送。它的原子性範圍涵蓋業務資料與待發送訊息;訊息中介軟體的發送由另一個步驟負責。Chris Richardson:Transactional outbox
三、三層架構:從服務方法看完整流程
先採用容易辨識責任的三層:API 接收請求,Business 處理用例,Data 負責 EF Core。以下目錄是一種安排,專案數量可以依團隊需要調整。
1 | src/ |
這個版本也可以使用依賴反轉:介面放在 Business,Data 提供實作,再由 API 啟動時的組合根(Composition Root)註冊 DI。三層架構並不限制我們只能讓商業程式碼直接依賴資料庫實作。
核心 C#:規則寫在 OrderService
OrderRecord 在這個版本以保存資料為主,允許服務修改屬性。取消訂單的判斷與修改由 OrderService.CancelAsync 完成:
1 | public async Task<CancelResult> CancelAsync( |
這種以一個程序處理一個用例的寫法,可以視為 Transaction Script。對規則少、流程直接的功能,它能清楚表達從讀取到寫入的順序。Martin Fowler:Transaction Script
實務上的好處與代價
新增取消功能時,開發者可以沿著 Controller、Service、Repository 往下讀。規則集中在服務方法,除錯時也容易追蹤目前執行到哪一步。
需要注意的是商業規則的入口是否受控。如果客服工具、排程與付款回呼都各自取得 OrderRecord,直接設定 Status,原本在 OrderService 的保護就可能被跳過。
三層仍然可以處理這個問題:讓所有入口共用取消服務,或抽出一致的規則物件。當 OrderService 同時負責查詢、付款、退款、報表、匯出時,也可以拆成 CancelOrderService、RefundOrderService 等用例服務。大型 Service 是需要重構的訊號,不能拿它當成三層架構必然失敗的證據。
四、DDD 分層:讓訂單負責自己的狀態規則
如果訂單規則開始變多,DDD 的價值會出現在模型如何表達業務,以及哪些狀態變更必須經過同一個入口。
先確認業務邊界,再切專案
在這個案例裡,Ordering 負責判斷「訂單是否接受取消」,Payment 負責「款項是否已請款、如何退款」。它們對同一筆交易關心的事情不同,模型與名詞需要明確對接。這些邊界可以先存在同一個應用程式內,部署拆分另外決定。
往模型內部看,Order 作為聚合根,管理訂單狀態轉移。這裡的聚合是要一起維持規則的一組物件與狀態;大小應依一致性需求決定。本文的取消規則只需要訂單本身,不必順便載入整個買家、商品目錄與所有歷史付款。
Microsoft 的 DDD 指南把 Domain 用於表達業務狀態與行為,Application 負責協調用例,Infrastructure 處理持久化;也指出單純 CRUD 可以採用較簡單的方式。Microsoft:Design a DDD-oriented microservice
目錄可以安排成這樣:
1 | src/ |
核心 C#:Order.Cancel 保護規則
以下省略建構、識別碼與版本屬性,聚焦取消行為。CancelDecision 包含 Changed、Unchanged、InvalidReason、NotAllowed 四種結果:
1 | public sealed class Order |
private set 讓一般呼叫端必須經過行為方法修改狀態。取消規則由 Order.Cancel 表達,付款也應有對應的狀態轉移方法。這些方法共同維護不變條件(Invariant),例如已出貨訂單不能再走待付款取消流程。
時間由 Application 傳入,因此領域方法不必讀取系統時鐘,也不需要知道 HTTP、登入機制或 EF Core。資料庫對應可以放在 Infrastructure,讓領域類別維持一般 C# 物件。Microsoft:DDD domain model
Application 還是要處理協調
把規則移進領域模型之後,載入訂單、確認目前買家可以存取、建立 Outbox 與儲存交易,仍然需要有人處理。這些工作放在用例的 Handler:
1 | var order = await orders.FindOwnedAsync(orderId, buyerId, ct); |
DDD 的具體收益是:Web API、客服用例與排程只要使用同一個領域行為,就會經過相同的狀態規則。 呼叫端仍須處理各自的權限;管理員可替他人取消,不代表一般買家也能繞過所有權檢查。
代價則是模型設計與協調成本。只有少量欄位維護功能時,增加聚合、介面與型別可能沒有帶來相應價值。反過來說,業務上已經有取消期限、退款政策、出貨限制與跨入口共用規則時,花時間把模型講清楚,通常比持續散布狀態判斷更有幫助。
五、垂直切片:打開一個功能,就能看見完整用例
垂直切片把組織重點移到「取消訂單」這件事。這個切片擁有自己的 Endpoint、Request 與 Handler;查詢訂單明細則由另一個切片負責。
1 | Ordering.Api/ |
核心 C#:簡單切片可以直接使用 DbContext
先採用與三層範例相同的資料模型,讓取消規則直接出現在這個功能的 Handler 裡:
1 | var order = await db.Orders.SingleOrDefaultAsync( |
這段邏輯和三層服務非常接近。主要差異在於程式碼的組織與相依方式:它與取消用例的 Request、Endpoint 放在一起,且直接依賴 EF Core,省去這個功能暫時用不到的 Repository 轉接。
EF Core 的 DbContext 本來就以一次 Unit of Work 為使用單位,會追蹤實體修改並在 SaveChanges 儲存。因此,是否額外包一層介面,應依隔離需求與可讀性決定。Microsoft:DbContext lifetime
功能集中之後,哪些事情變簡單?
當取消 API 增加「前端顯示的提醒文字」,原本不帶回應內容的 204 就需要改成例如 200 加上回應 DTO,三種架構都要調整公開契約。採用切片時,開發者可以在 Cancel 目錄集中調整相關處理。查詢明細若需要不同的欄位與投影,也由 GetDetails 自己處理,不必擴充一個所有 API 共用的 OrderDto。
但把資料夾改名成 Features,並不會自動降低耦合。如果所有 Handler 最後仍呼叫同一個龐大的 OrderService,功能之間還是會透過它互相影響。
另一個需要控制的風險是規則重複。若每個切片都寫一份「什麼狀態可以取消」,規則改動時就要逐一修正。這時可以把共用不變條件收進 Order.Cancel,切片繼續處理自己的用例。VSA 並不要求永遠使用 Transaction Script;Bogard 的原文也提到,複雜邏輯可以重構進領域模型。Jimmy Bogard:Vertical Slice Architecture
MediatR、CQRS 可以另外決定
切片的 Handler 可以由 Endpoint 直接呼叫,也可以透過 MediatR 派送。是否使用派送套件,取決於團隊是否需要相應的處理管線與呼叫方式。
同樣地,寫入用 Cancel、讀取用 GetDetails,可以先共用一個資料庫。CQRS 關注讀寫模型的分離,實體部署與是否採用 Event Sourcing 可以另外決定;切成用例目錄本身沒有要求導入這些設計。Martin Fowler:CQRS
六、同一個需求改動,三種寫法會碰到哪裡?
初版程式碼都能完成取消訂單。更有區別的是後續需求變動時,哪些責任需要一起調整。下表描述本文三種實作的典型修改位置,實際範圍仍取決於原有切分方式。
| 新需求 | 三層+服務規則 | DDD 分層 | 垂直切片+Handler 規則 |
|---|---|---|---|
| 取消回應增加提示欄位 | API 合約與對應轉換,必要時調整服務回傳 | API 合約與 Application 結果 | 主要在 Cancel 切片內 |
| 建立後超過 30 分鐘不能取消 | 修改取消服務;確認其他入口共用它 | 修改 Order.Cancel 或明確的取消政策 |
修改取消 Handler;檢查其他入口有無重複規則 |
| 排程也要取消逾期訂單 | 呼叫合適的用例服務,區分排程權限與理由 | 新增排程用例,重用適用的領域行為 | 新增排程切片,抽出真正共用的規則 |
| 訂單明細增加查詢專用欄位 | 調整查詢服務、投影與回應 | 調整查詢路徑,通常不必修改聚合 | 修改 GetDetails 切片 |
| 已付款也能申請取消,並等待退款 | 服務要協調新流程與狀態 | 模型要區分申請、接受、退款完成 | 切片要區分不同用例,複雜規則適合抽到模型 |
情境一:取消期限從 30 分鐘改成依商品類型決定
規則開始涉及訂單內容時,優先確認哪些資料是當時交易的依據。例如預購商品的取消條款可能要隨訂單保存,不能只查目前商品設定,讓昨天的訂單突然套用今天的新政策。
三層可以把政策集中在商業層;DDD 可以由 Order 搭配取消政策判斷;VSA 可以呼叫同一個政策。能否避免規則散落,取決於共用規則的歸屬是否明確。
情境二:新增客服取消與逾期自動取消
這些入口未必應該呼叫與買家完全相同的應用服務。客服有不同權限,逾期取消可能有不同原因與稽核要求;適合各自建立用例,再重用共通的狀態轉移。
如果業務確認三者遵守相同的狀態限制,DDD 模型能提供一個一致入口。若規則其實不同,就需要把差異表達出來,例如不同取消政策或不同業務行為,避免以一個含大量布林參數的方法掩蓋差異。
情境三:取消後還要退款
需求一旦包含外部金流,「呼叫退款成功後再改狀態」就會面臨網路中斷、逾時與回應遺失。此時需要釐清 CancellationRequested、RefundPending、Cancelled 等業務狀態是否真的存在,以及失敗後如何恢復。
三種架構都需要面對這些決策。DDD 有助於表達狀態規則,VSA 有助於分開不同用例,分層則有助於隔離金流介接。實際需求常常會同時用到這些能力。
七、實戰中更容易出錯的部分:併發與交易
把判斷放進 Service、聚合或 Handler,都只能檢查目前記憶體裡的那份訂單。Production 的付款與取消可能來自不同程序,必須由持久化機制配合保護。
領域方法保護一份物件,版本檢查保護資料庫寫入
假設付款與取消同時讀到 Pending:
sequenceDiagram
participant Cancel as 取消用例
participant Pay as 付款狀態更新用例
participant DB as PostgreSQL
Cancel->>DB: 讀取 Pending,版本 V1
Pay->>DB: 讀取 Pending,版本 V1
Cancel->>Cancel: 檢查取消規則
Pay->>Pay: 檢查付款狀態轉移規則
Cancel->>DB: 以原版本 V1 更新取消,並寫入 Outbox
DB-->>Cancel: 提交成功,版本已變更
Pay->>DB: 嘗試以原版本 V1 更新付款
DB-->>Pay: 更新不到符合版本的資料
Note over Pay,DB: 重新載入,依現在狀態處理衝突
EF Core 需要明確設定 concurrency token,才會在更新時比對載入的版本;更新不到預期資料時,會拋出 DbUpdateConcurrencyException。這項保護必須涵蓋付款、取消等所有競爭寫入路徑。Microsoft:Handling concurrency conflicts
本文選用 PostgreSQL,可依 Npgsql 文件將 uint 版本屬性對應到系統欄位 xmin,相關設定放在持久化對應中。若改用應用程式管理的版本值,就必須確保相關更新都會換發新版本。Npgsql:Concurrency tokens
發生衝突後,本文的 API 契約採取以下處理:
- 若重新讀取後已取消,就回傳既有的成功結果,不再建立 Outbox。
- 若已付款或出貨,就回傳狀態衝突。
- 若仍可取消,可在有限次數內重新執行完整用例。
重跑時應使用新的工作單位,或完整清除失敗嘗試的追蹤狀態,避免把舊修改與尚未提交的 Outbox 一起帶入。直接換掉原始版本再強制覆寫,會繞過重新判斷商業狀態的目的。
此外,訂單資料庫擋下狀態更新,不代表外部金流已請款的事實會消失。若取消已提交、付款通知才抵達,仍需另外處理對帳、退款或人工介入。
Outbox 與訂單必須一起提交
前面的三種片段都在狀態確實改變後加入 Outbox,再儲存一次。在支援交易的資料庫 provider 上,EF Core 預設會讓單次 SaveChanges 的變更具備交易原子性。因此訂單更新與 Outbox 新增必須使用同一個工作單位,保留這項保證。Microsoft:Using transactions
若 Repository 裡先儲存訂單,Outbox 元件再用另一個連線另存訊息,就需要額外的交易協調,不能只憑 UnitOfWork 這個類別名稱認定已經安全。單次儲存的交易也沒有自動鎖住前面那次讀取,併發控制仍須另外處理。
背景發送可能重複,所以接收端仍要依訊息 ID 或業務鍵去重。此例「已取消就回成功」只定義了訂單狀態上的重複操作行為;如果還需要辨識相同請求鍵卻帶不同內容,必須另外設計 Idempotency Key 的保存與衝突規則。Chris Richardson:Transactional outbox
八、開發成本、維護成本與效能怎麼看?
以下是在本文實作前提下的判斷,適合拿來討論團隊工作方式。它們沒有通用的人天數或固定效能倍率。
| 評估面向 | 三層+服務規則 | DDD 分層 | 垂直切片 |
|---|---|---|---|
| 初版簡單 CRUD | 流程直接,容易開始 | 需節制模型與抽象數量 | 每個功能可用簡單 Handler 開始 |
| 找到一個功能的全部程式碼 | 通常沿技術層閱讀 | 同時閱讀用例與領域模型 | 輸入、流程、回應較集中 |
| 共用複雜商業規則 | 需管理服務與政策的共用入口 | 聚合行為可集中不變條件 | 需辨識重複規則,適時導入模型 |
| 隔離 EF Core | 可透過商業層的介面 | 領域模型保持獨立,Application 依抽象協調 | 直接用 DbContext 時,該切片會依賴 EF Core |
| 多人同時改不同功能 | 共用 Service、DTO 可能形成交會點 | 共用模型變動需要協調 | 功能專用檔案較分散,共用模型仍需協調 |
| 規則的獨立驗證入口 | 服務方法或政策物件 | 領域方法 | Handler,或抽出的模型與政策 |
| 主要學習成本 | 邊界與服務責任 | 業務建模、聚合與一致性 | 切片邊界、重構與共用規則的判斷 |
效能應該比較實際執行的工作
在相同 SQL、資料量與序列化方式下,無法只靠三層、DDD 或 VSA 名稱預測 RPS、CPU 或 RAM。類別之間多呼叫幾次,與多查一次資料庫是不同量級的工作,但實際影響仍應量測。
對這個訂單 API,優先檢查的是:載入了哪些欄位、是否有 N+1 查詢、結果是否分頁,以及取消操作是否不必要地載入大量關聯資料。EF Core 官方的查詢最佳化指南也把索引、投影、結果數量與關聯載入列為主要考量。Microsoft:Efficient querying
例如訂單列表只需要摘要欄位,可以直接投影成回應 DTO;需要改變訂單狀態時,再載入執行規則所需的聚合。這個讀寫路徑的分工在三種組織方式裡都能採用。
九、我的選法:先看變動來源,再決定組合
如果要替這個案例做選型,我會先判斷目前主要成本來自「功能很多、常各自修改」,還是「同一份複雜規則被多個入口使用」。下列建議是依這個判斷提出的取捨。
簡單後台與規則少的系統:三層或輕量切片
如果大多是設定維護、查詢、匯出與簡單狀態更新,三層已經可以清楚分工。團隊熟悉既有結構時,可以先把大型 Service 拆成用例服務,改善 DTO 與查詢邊界。
如果新功能經常各自調整輸入與回應,輕量 VSA 也合適。從一個 Endpoint、一個 Handler 與必要的資料存取開始,等相依與重複真的出現,再抽出共用部分。
核心交易與多入口規則:值得建立領域模型
當取消、付款、退款、折扣與履約互相影響,且多個入口必須遵守同一套規則,DDD 模型能讓狀態與不變條件集中表達。投入的重點包括與業務確認名詞、界定責任、找出哪些變更必須一致。
這不要求所有模組一起改。商品標籤維護可以維持簡單 CRUD;訂單狀態與退款政策則使用較完整的模型。
這個訂單案例,我會考慮 VSA+DDD
當功能數量與規則複雜度都增加,可以讓用例用垂直切片組織,核心規則由 Domain 保存,持久化細節放在 Infrastructure:
1 | Ordering.Api/Features/Orders/Cancel/Endpoint.cs |
下面的圖表示執行責任,實際類別庫相依由介面與 DI 配合控制;Domain 不需要引用 Infrastructure:
flowchart TD
API["取消訂單 Endpoint"] --> H["Cancel Handler:身分、載入、協調"]
H --> D["Order.Cancel:狀態與取消規則"]
H --> P["持久化介面:訂單與 Outbox"]
P --> EF["Infrastructure:EF Core 與版本檢查"]
EF --> DB[(PostgreSQL)]
R["GetDetails Handler:查詢專用投影"] --> Q["查詢介面與實作"]
Q --> DB
功能相關的處理集中在切片,必須跨入口一致的規則集中在模型。查詢可以使用適合自己的投影,也不必為了遵守寫入模型而載入整個聚合。
十、既有三層專案,可以逐步演進
已經有 Controller、Service、Repository 的專案,可以先挑最常改的一個功能,建立清楚的用例邊界,再決定是否需要領域模型。
- 縮小服務責任:把大型
OrderService中的取消流程整理成獨立用例,保留既有 API 契約。 - 收斂重複規則:找出 Web、排程、客服是否各自寫了同樣判斷,確認它們在業務上是否真的相同。
- 保護狀態變更:將確認共用的不變條件移到
Order.Cancel或政策物件,收起任意修改狀態的入口。 - 重整功能位置:依修改習慣,把取消功能的 Request、Handler、回應集中管理;共用模型保留明確歸屬。
- 補齊一致性設計:確認版本衝突、交易邊界、Outbox 與重複操作行為,讓所有寫入入口遵守相同保證。
每一步都應改善一個可指出的問題,例如減少重複狀態判斷、縮小共享 DTO 影響範圍,或避免付款覆蓋取消。這樣比較容易判斷重構是否值得。
總結
以同一個 C# 訂單 API 來看,三層架構能清楚分配技術責任;DDD 能把重要的業務語言與不變條件落進模型;垂直切片能把一起修改的功能程式碼放在一起。
規則少時,用簡單的流程;規則需要跨入口一致時,建立領域模型;功能需要各自演進時,用切片管理變動。 在本文的訂單案例中,VSA 搭配 DDD 是值得考慮的組合。架構的成效,最後要從需求修改、錯誤定位與資料正確性來觀察。
延伸閱讀
- 資深後端轉學 C# 指南:從 PHP、Laravel、Go、Node.js 到 .NET 的概念對照與分散式電商實戰
- Jimmy Bogard:Vertical Slice Architecture
- Martin Fowler:Bounded Context
- Microsoft:Handling concurrency conflicts
本文由 AI 協助整理與撰寫,技術資料於 2026-10-16 查閱。封面為 AI 生成的概念插畫,以同一個紙板模型的水平層、垂直隔間與中央結構表達不同設計觀點;精確責任與流程以內文圖表為準。








