前言

假設今天要用 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 可以放進三層架構,也可以和垂直切片搭配。為了看出差異,以下先比較三種具體實作:

  1. 三層架構,規則集中在應用服務。
  2. 採用 DDD 模型的分層架構,規則交給領域物件。
  3. 垂直切片,簡單用例直接由自己的 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
2
3
4
5
6
7
8
9
10
11
12
src/
├── Ordering.Api/
│ ├── Controllers/OrdersController.cs
│ └── Contracts/CancelOrderRequest.cs
├── Ordering.Business/
│ ├── Orders/OrderService.cs
│ ├── Orders/OrderRecord.cs
│ └── Ports/ # Repository、Outbox、Unit of Work 介面
└── Ordering.Data/
├── OrderingDbContext.cs
├── Repositories/EfOrderRepository.cs
└── Outbox/

這個版本也可以使用依賴反轉:介面放在 Business,Data 提供實作,再由 API 啟動時的組合根(Composition Root)註冊 DI。三層架構並不限制我們只能讓商業程式碼直接依賴資料庫實作。

核心 C#:規則寫在 OrderService

OrderRecord 在這個版本以保存資料為主,允許服務修改屬性。取消訂單的判斷與修改由 OrderService.CancelAsync 完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public async Task<CancelResult> CancelAsync(
Guid orderId, Guid buyerId, string reason, CancellationToken ct)
{
var order = await orders.FindOwnedAsync(orderId, buyerId, ct);
if (order is null)
return CancelResult.NotFound;
if (string.IsNullOrWhiteSpace(reason))
return CancelResult.InvalidReason;
if (order.Status == OrderStatus.Cancelled)
return CancelResult.Success;
if (order.Status != OrderStatus.Pending)
return CancelResult.NotAllowed;

order.Status = OrderStatus.Cancelled;
order.CancelReason = reason.Trim();
order.CancelledAt = clock.GetUtcNow();

outbox.Add(OutboxMessage.ForOrderCancelled(order));
await unitOfWork.SaveChangesAsync(ct);
return CancelResult.Success;
}

這種以一個程序處理一個用例的寫法,可以視為 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
2
3
4
5
6
7
8
9
10
11
src/
├── Ordering.Api/Endpoints/OrdersEndpoints.cs
├── Ordering.Application/
│ ├── Orders/CancelOrderHandler.cs
│ └── Ports/ # IOrderRepository、IOutbox、IUnitOfWork
├── Ordering.Domain/
│ └── Orders/Order.cs
└── Ordering.Infrastructure/
├── Persistence/OrderingDbContext.cs
├── Persistence/OrderConfiguration.cs
└── Outbox/

核心 C#:Order.Cancel 保護規則

以下省略建構、識別碼與版本屬性,聚焦取消行為。CancelDecision 包含 Changed、Unchanged、InvalidReason、NotAllowed 四種結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public sealed class Order
{
public OrderStatus Status { get; private set; }
public string? CancelReason { get; private set; }
public DateTimeOffset? CancelledAt { get; private set; }

public CancelDecision Cancel(string reason, DateTimeOffset now)
{
if (string.IsNullOrWhiteSpace(reason))
return CancelDecision.InvalidReason;
if (Status == OrderStatus.Cancelled)
return CancelDecision.Unchanged;
if (Status != OrderStatus.Pending)
return CancelDecision.NotAllowed;

Status = OrderStatus.Cancelled;
CancelReason = reason.Trim();
CancelledAt = now;
return CancelDecision.Changed;
}
}

private set 讓一般呼叫端必須經過行為方法修改狀態。取消規則由 Order.Cancel 表達,付款也應有對應的狀態轉移方法。這些方法共同維護不變條件(Invariant),例如已出貨訂單不能再走待付款取消流程。

時間由 Application 傳入,因此領域方法不必讀取系統時鐘,也不需要知道 HTTP、登入機制或 EF Core。資料庫對應可以放在 Infrastructure,讓領域類別維持一般 C# 物件。Microsoft:DDD domain model

Application 還是要處理協調

把規則移進領域模型之後,載入訂單、確認目前買家可以存取、建立 Outbox 與儲存交易,仍然需要有人處理。這些工作放在用例的 Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var order = await orders.FindOwnedAsync(orderId, buyerId, ct);
if (order is null)
return CancelResult.NotFound;

var decision = order.Cancel(reason, clock.GetUtcNow());
if (decision == CancelDecision.InvalidReason)
return CancelResult.InvalidReason;
if (decision == CancelDecision.NotAllowed)
return CancelResult.NotAllowed;
if (decision == CancelDecision.Unchanged)
return CancelResult.Success;

outbox.Add(OutboxMessage.ForOrderCancelled(order));
await unitOfWork.SaveChangesAsync(ct);
return CancelResult.Success;

DDD 的具體收益是:Web API、客服用例與排程只要使用同一個領域行為,就會經過相同的狀態規則。 呼叫端仍須處理各自的權限;管理員可替他人取消,不代表一般買家也能繞過所有權檢查。

代價則是模型設計與協調成本。只有少量欄位維護功能時,增加聚合、介面與型別可能沒有帶來相應價值。反過來說,業務上已經有取消期限、退款政策、出貨限制與跨入口共用規則時,花時間把模型講清楚,通常比持續散布狀態判斷更有幫助。

五、垂直切片:打開一個功能,就能看見完整用例

垂直切片把組織重點移到「取消訂單」這件事。這個切片擁有自己的 Endpoint、Request 與 Handler;查詢訂單明細則由另一個切片負責。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Ordering.Api/
├── Features/
│ └── Orders/
│ ├── Cancel/
│ │ ├── Endpoint.cs
│ │ ├── Request.cs
│ │ └── Handler.cs
│ └── GetDetails/
│ ├── Endpoint.cs
│ ├── Response.cs
│ └── Handler.cs
├── Persistence/
│ ├── OrderingDbContext.cs
│ ├── OrderRecord.cs
│ └── OutboxMessage.cs
└── Shared/CurrentUser.cs

核心 C#:簡單切片可以直接使用 DbContext

先採用與三層範例相同的資料模型,讓取消規則直接出現在這個功能的 Handler 裡:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var order = await db.Orders.SingleOrDefaultAsync(
x => x.Id == orderId && x.BuyerId == buyerId, ct);

if (order is null)
return CancelResult.NotFound;
if (string.IsNullOrWhiteSpace(reason))
return CancelResult.InvalidReason;
if (order.Status == OrderStatus.Cancelled)
return CancelResult.Success;
if (order.Status != OrderStatus.Pending)
return CancelResult.NotAllowed;

order.Status = OrderStatus.Cancelled;
order.CancelReason = reason.Trim();
order.CancelledAt = clock.GetUtcNow();

db.Outbox.Add(OutboxMessage.ForOrderCancelled(order));
await db.SaveChangesAsync(ct);
return CancelResult.Success;

這段邏輯和三層服務非常接近。主要差異在於程式碼的組織與相依方式:它與取消用例的 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
2
3
4
5
Ordering.Api/Features/Orders/Cancel/Endpoint.cs
Ordering.Application/Features/Orders/Cancel/Handler.cs
Ordering.Application/Features/Orders/GetDetails/Handler.cs
Ordering.Domain/Orders/Order.cs
Ordering.Infrastructure/Persistence/OrderingDbContext.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 的專案,可以先挑最常改的一個功能,建立清楚的用例邊界,再決定是否需要領域模型。

  1. 縮小服務責任:把大型 OrderService 中的取消流程整理成獨立用例,保留既有 API 契約。
  2. 收斂重複規則:找出 Web、排程、客服是否各自寫了同樣判斷,確認它們在業務上是否真的相同。
  3. 保護狀態變更:將確認共用的不變條件移到 Order.Cancel 或政策物件,收起任意修改狀態的入口。
  4. 重整功能位置:依修改習慣,把取消功能的 Request、Handler、回應集中管理;共用模型保留明確歸屬。
  5. 補齊一致性設計:確認版本衝突、交易邊界、Outbox 與重複操作行為,讓所有寫入入口遵守相同保證。

每一步都應改善一個可指出的問題,例如減少重複狀態判斷、縮小共享 DTO 影響範圍,或避免付款覆蓋取消。這樣比較容易判斷重構是否值得。

總結

以同一個 C# 訂單 API 來看,三層架構能清楚分配技術責任;DDD 能把重要的業務語言與不變條件落進模型;垂直切片能把一起修改的功能程式碼放在一起。

規則少時,用簡單的流程;規則需要跨入口一致時,建立領域模型;功能需要各自演進時,用切片管理變動。 在本文的訂單案例中,VSA 搭配 DDD 是值得考慮的組合。架構的成效,最後要從需求修改、錯誤定位與資料正確性來觀察。

延伸閱讀

本文由 AI 協助整理與撰寫,技術資料於 2026-10-16 查閱。封面為 AI 生成的概念插畫,以同一個紙板模型的水平層、垂直隔間與中央結構表達不同設計觀點;精確責任與流程以內文圖表為準。