🌏 Read this article in English
快速定義:DDD 是什麼
在深入改造步驟前,先釐清常見的誤解。
❌ DDD 不是: – 一套複雜的理論需要反覆研究 – 需要新增 50% 的代碼行數 – 一定要搭配 Event Sourcing、CQRS 等高級概念 – 一種從零開始的架構模式(需要重寫整個項目)
✅ DDD 實際上是: – 一種代碼組織方式:業務邏輯從 Service 層向下遷移到 Entity 層 – 核心原則:讓 Entity 能自我驗證、自我決策,而非只是數據容器 – 結果:Service 層變瘦,代碼更好測試,模組更易解耦
舉個實例:
當前狀況:
Item Entity → 只有 23 行,都是 getter/setter,無任何業務邏輯
ItemService → 373 行,包含所有驗證:交期檢查、狀態轉移、供應商驗證
DDD 改造後:
Item Entity → 150+ 行,包含 addSupplier()、updateStatus() 等業務方法
ItemService → 150 行,只負責協調,驗證都在 Item 類內
結果 → 總代碼從 396 行到 300 行(↓24%),更重要的是代碼變清晰了
你的代碼為什麼會腐化
不要從理論開始,先看看你當前的問題。
根本原因樹
症狀是 Service 層臃腫、模組耦合強,但根本原因是什麼?
【症狀】Service 層代碼臃腫(300+ 行)
↓
【根本原因 A】業務驗證散落在 Service 層
│
├─ 例子:「供應商交期驗證」在 ItemService.addSupplier() 裡
├─ 成本:修改驗證邏輯時容易遺漏其他位置
├─ 後果:同一個規則出現在 Service、Controller、Validator 中,版本不一致
│
└─ 代碼長這樣:
@Service
public class ItemService {
public void addSupplier(Long itemId, SupplierDTO dto) {
if (dto.getLeadTime() > 180) { // ❌ 驗證在 Service
throw new Exception("交期超過限制");
}
itemSupplierRepository.save(...);
}
}
【根本原因 B】跨越聚合邊界直接操作多個 Repository
│
├─ 例子:ItemService 同時用 ItemSupplierRepository、ItemSpecRepository、ItemUnitRepository
├─ 成本:新人難以理解「哪些東西應該原子性地一起保存」
├─ 後果:無法寫出有效的單元測試(因為邏輯散落)
│
└─ 代碼長這樣:
@Service
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
private final ItemSupplierRepository itemSupplierRepository; // ❌ 多個 Repo
private final ItemSpecRepository itemSpecRepository; // ❌ 多個 Repo
private final ItemUnitRepository itemUnitRepository; // ❌ 多個 Repo
public void createItem(CreateItemRequest req) {
Item item = itemRepository.save(...);
req.getSuppliers().forEach(s -> itemSupplierRepository.save(...));
req.getSpecs().forEach(s -> itemSpecRepository.save(...));
// 問題:如果中間拋異常,只有部分數據被保存,造成不一致
}
}
【根本原因 C】模組之間強耦合
│
├─ 例子:ItemService 直接調用 InventoryService.initializeLots()、ProcurementService.createItem()
├─ 成本:改一個需求需要動 5 個模組,測試複雜度爆炸
├─ 後果:發佈風險高,每次都要全量迴歸測試
│
└─ 代碼長這樣:
@Service
public class ItemService {
private final InventoryService inventoryService;
private final ProcurementService procurementService;
public void createItem(CreateItemRequest req) {
Item item = itemRepository.save(...);
inventoryService.initializeLots(item.getId()); // ❌ 強耦合
procurementService.createItem(item.getId()); // ❌ 強耦合
}
}
【派生症狀】新功能開發很慢
↓
根本原因:因為上述 A、B、C,每個新功能都要觸及多個地方
【派生症狀】新人上手困難(需要 2 週才能理解業務邏輯)
↓
根本原因:業務邏輯分散,沒有一個地方集中說「Item 的規則是什麼」
診斷:你有幾個問題? – ✅ 有 A(驗證散落) – ✅ 有 B(多 Repo 耦合) – ✅ 有 C(服務間耦合)
所有三個都符合 → 強烈建議導入 DDD
DDD 改造:分步操作手冊
現在開始改造。不講理論,只講怎麼做。
第一步:識別聚合根邊界
聚合根是 DDD 的核心概念,簡單說:一個聚合根代表一個業務概念的完整邊界。
具體做什麼
Step 1:列出你的核心業務概念
例子(製造企業 ERP):
✓ Item(物料) → 複雜業務規則(交期、狀態轉移) → 應該是聚合根
✓ Order(訂單) → 複雜業務規則(流程管理) → 應該是聚合根
✓ Supplier(供應商)→ 中等複雜(有些驗證) → 應該是聚合根
✓ User(用戶) → 簡單(基本屬性) → 不一定需要 DDD
✓ Category(分類) → 簡單(純數據) → 不需要 DDD
Step 2:對每個概念問自己三個問題
Q1: 它有複雜的業務規則嗎?
Item 的例子:
✅ 是:「交期不能超過 180 天」
✅ 是:「已停用物料無法添加供應商」
✅ 是:「物料狀態轉移有特定的流程」
結論 → Item 可能是聚合根
Category 的例子:
❌ 否:沒有特殊驗證邏輯
❌ 否:只是儲存名稱和排序
結論 → Category 只是數據,不需要 DDD
Q2: 它有子實體嗎?這些子實體是否總是一起操作?
Item 的例子:
✅ 有 ItemSupplier(供應商),何時修改?只有 Item 被修改時
✅ 有 ItemSpec(規格),何時修改?只有 Item 被修改時
✅ 有 ItemUnit(單位),何時修改?只有 Item 被修改時
結論 → ItemSupplier、ItemSpec、ItemUnit 應該是 Item 的子聚合
(無法獨立存在,無法直接被其他地方查詢)
InventoryLot 的例子:
❌ 否:InventoryLot 可獨立存在
❌ 否:Item 更新時不需要更新 InventoryLot(它們是解耦的)
❌ 否:InventoryLot 會從庫存模組單獨查詢和修改
結論 → InventoryLot 應該是獨立的聚合根,不該包含在 Item 裡
Q3: 修改它時是否需要跨越多個 Repository?
當前情況(問題):
Item 保存時需要同時:
✅ itemRepository.save(item)
✅ itemSupplierRepository.saveAll(suppliers)
✅ itemSpecRepository.saveAll(specs)
✅ itemUnitRepository.saveAll(units)
問題 → 邊界劃分有問題!應該只用一個 Repository
改造後(目標):
✅ itemRepository.save(item) ← 級聯保存 suppliers、specs、units
結論 → ItemSupplier 等應該是 Item 的子聚合,無需單獨 Repository
Step 3:畫出聚合根邊界
┌──────────────────────────────────┐
│ Item 聚合根 │
├──────────────────────────────────┤
│ 基本信息: │
│ • code (String) 物料編碼 │
│ • name (String) 物料名稱 │
│ • status (Enum) 狀態 │
│ • leadTime (Integer) 交期 │
│ │
│ 子聚合(只能通過 Item 訪問): │
│ • suppliers: Set<ItemSupplier> │
│ • specs: Set<ItemSpec> │
│ • units: Set<ItemUnit> │
│ │
│ 業務方法(包含驗證邏輯): │
│ • addSupplier(ItemSupplier) │
│ • removeSupplier(Long id) │
│ • updateStatus(ItemStatus) │
│ • validate() │
└──────────────────────────────────┘
⚠️ 重要:ItemSupplier、ItemSpec、ItemUnit 不應該有單獨的 Repository
它們只能通過 Item.addSupplier()、Item.addSpec() 等方法訪問
判斷是否做對了
✅ 聚合根邊界識別正確的標誌:
□ 聚合根內的子實體無法獨立查詢(必須通過聚合根)
□ 修改聚合根時只需依賴一個 Repository(ItemRepository)
□ 聚合根內有業務驗證邏輯(不全都在 Service)
□ 子實體不會被聚合根之外的地方直接修改
第二步:將驗證邏輯遷移到聚合根
這是 DDD 改造的核心。
具體做什麼
現在打開你的 ItemService,找出所有驗證邏輯:
// ❌ 當前樣子(所有驗證都在 Service)
@Service
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
private final ItemSupplierRepository itemSupplierRepository;
public void addSupplier(Long itemId, SupplierDTO dto) {
Item item = itemRepository.findById(itemId).orElseThrow();
// 驗證 1:交期驗證在 Service
if (dto.getLeadTime() > 180) {
throw new Exception("供應商交期不能超過 180 天");
}
// 驗證 2:狀態驗證在 Service
if (item.getStatus() == ItemStatus.DISCONTINUED) {
throw new Exception("已停用物料無法添加供應商");
}
ItemSupplier supplier = new ItemSupplier(
dto.getSupplierId(),
dto.getLeadTime()
);
itemSupplierRepository.save(supplier); // ❌ 跨越邊界操作
}
}
逐個遷移到聚合根:
// ✅ 改造後(驗證在聚合根)
@Entity
@Table(name = "items")
@Data
@NoArgsConstructor
public class Item {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String code;
private String name;
@Enumerated(EnumType.STRING)
private ItemStatus status;
// 子聚合(級聯管理)
@OneToMany(mappedBy = "item", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<ItemSupplier> suppliers = new HashSet<>();
// ✅ 業務方法:聚合根自己驗證
public void addSupplier(ItemSupplier supplier) {
// 驗證 1:交期驗證在聚合根
if (supplier.getLeadTime() > 180) {
throw new DomainException("供應商交期不能超過 180 天");
}
// 驗證 2:狀態驗證在聚合根
if (this.status == ItemStatus.DISCONTINUED) {
throw new DomainException("已停用物料無法添加供應商");
}
// 添加供應商(聚合根保護邊界)
this.suppliers.add(supplier);
supplier.setItem(this);
}
public void removeSupplier(Long supplierId) {
suppliers.removeIf(s -> s.getId().equals(supplierId));
}
public void updateStatus(ItemStatus newStatus) {
// 狀態轉移驗證
if (this.status == ItemStatus.DISCONTINUED && newStatus != ItemStatus.DISCONTINUED) {
throw new DomainException("已停用物料無法重新啟用");
}
this.status = newStatus;
}
}
Service 層簡化為只負責協調:
// ✅ 改造後的 ItemService(變瘦了)
@Service
@RequiredArgsConstructor
public class ItemDomainService {
private final ItemRepository itemRepository;
public void addSupplier(Long itemId, Long supplierId, Integer leadTime) {
Item item = itemRepository.findById(itemId).orElseThrow();
// Service 只負責協調,驗證由聚合根負責
ItemSupplier supplier = new ItemSupplier(supplierId, leadTime);
item.addSupplier(supplier); // 聚合根驗證,拋異常則操作失敗
// 只有一個 Repository,級聯保存子聚合
itemRepository.save(item);
}
}
現在你可以寫單元測試:
@Test
public void testAddSupplierWithExcessiveLeadTime() {
// ✅ 直接測試聚合根邏輯,無需 mock Repository
Item item = new Item();
item.setCode("ABC123456");
item.setStatus(ItemStatus.ACTIVE);
ItemSupplier supplier = new ItemSupplier();
supplier.setLeadTime(200); // 超過限制
// 應該拋異常
assertThrows(DomainException.class,
() -> item.addSupplier(supplier)
);
}
@Test
public void testAddSupplierToDiscontinuedItem() {
// ✅ 測試狀態轉移驗證
Item item = new Item();
item.setStatus(ItemStatus.DISCONTINUED);
ItemSupplier supplier = new ItemSupplier();
supplier.setLeadTime(100);
assertThrows(DomainException.class,
() -> item.addSupplier(supplier)
);
}
判斷是否做對了
✅ 驗證邏輯遷移成功的標誌:
□ 所有業務驗證現在都在 Item 類中
□ ItemService 不再包含業務驗證代碼
□ ItemService 代碼行數大幅減少
□ 能夠獨立單元測試聚合根(無需 mock Repository)
□ 刪除了 itemSupplierRepository、itemSpecRepository 等多個 Repository 依賴
第三步:解耦模組之間的耦合(事件驅動)
現在 ItemService 裡可能還有類似的代碼:
public void createItem(CreateItemRequest req) {
Item item = Item.createNew(req.getCode(), req.getName());
itemRepository.save(item);
// ❌ 直接調用其他模組的 Service(強耦合)
inventoryService.initializeLots(item.getId());
procurementService.createItem(item.getId());
}
這些跨模組的依賴應該通過事件驅動來解耦。
具體做什麼
Step 1:定義領域事件
// 基類(Spring 已有 ApplicationEvent)
public abstract class DomainEvent {
private final Instant occurredAt;
protected DomainEvent() {
this.occurredAt = Instant.now();
}
public Instant getOccurredAt() {
return occurredAt;
}
}
// 具體事件
public class ItemCreatedEvent extends DomainEvent {
private final Long itemId;
private final String itemCode;
private final String itemName;
public ItemCreatedEvent(Long itemId, String code, String name) {
super();
this.itemId = itemId;
this.itemCode = code;
this.itemName = name;
}
public Long getItemId() { return itemId; }
public String getItemCode() { return itemCode; }
public String getItemName() { return itemName; }
}
public class ItemStatusChangedEvent extends DomainEvent {
private final Long itemId;
private final ItemStatus newStatus;
public ItemStatusChangedEvent(Long itemId, ItemStatus newStatus) {
super();
this.itemId = itemId;
this.newStatus = newStatus;
}
public Long getItemId() { return itemId; }
public ItemStatus getNewStatus() { return newStatus; }
}
Step 2:在聚合根發布事件
@Entity
@Table(name = "items")
public class Item {
// ... 其他字段
@Transient
private List<DomainEvent> domainEvents = new ArrayList<>();
// ✅ 工廠方法:建立新物料時發布事件
public static Item createNew(String code, String name) {
Item item = new Item();
item.code = code;
item.name = name;
item.status = ItemStatus.ACTIVE;
// 發布事件:物料已建立
item.domainEvents.add(
new ItemCreatedEvent(item.id, code, name)
);
return item;
}
// ✅ 修改狀態時發布事件
public void updateStatus(ItemStatus newStatus) {
if (this.status == ItemStatus.DISCONTINUED && newStatus != ItemStatus.DISCONTINUED) {
throw new DomainException("已停用物料無法重新啟用");
}
this.status = newStatus;
// 發布事件:物料狀態已改變
domainEvents.add(new ItemStatusChangedEvent(this.id, newStatus));
}
// 獲取和清除事件
public List<DomainEvent> getDomainEvents() {
return new ArrayList<>(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
Step 3:Service 發布事件(不直接調用其他 Service)
// ❌ 改造前
@Service
public class ItemService {
private final InventoryService inventoryService;
private final ProcurementService procurementService;
public void createItem(CreateItemRequest req) {
Item item = Item.createNew(req.getCode(), req.getName());
itemRepository.save(item);
// ❌ 直接調用,強耦合
inventoryService.initializeLots(item.getId());
procurementService.createItem(item.getId());
}
}
// ✅ 改造後
@Service
@RequiredArgsConstructor
public class ItemDomainService {
private final ItemRepository itemRepository;
private final ApplicationEventPublisher eventPublisher;
public void createItem(CreateItemCommand cmd) {
Item item = Item.createNew(cmd.getCode(), cmd.getName());
itemRepository.save(item);
// ✅ 發布事件,解耦其他模組
item.getDomainEvents().forEach(eventPublisher::publishEvent);
item.clearDomainEvents();
}
}
Step 4:其他模組獨立監聽事件
// 庫存模組(無需知道 Item 的存在)
@Component
@RequiredArgsConstructor
public class ItemCreatedEventListener {
private final InventoryLotService inventoryLotService;
@EventListener
public void onItemCreated(ItemCreatedEvent event) {
// 物料建立後,初始化庫存批次
inventoryLotService.initializeLots(event.getItemId());
}
}
// 採購模組(無需知道 Item 的存在)
@Component
@RequiredArgsConstructor
public class ItemStatusChangedEventListener {
private final ProcurementService procurementService;
@EventListener
public void onItemStatusChanged(ItemStatusChangedEvent event) {
if (event.getNewStatus() == ItemStatus.DISCONTINUED) {
// 物料停用時,清除未執行的採購單
procurementService.cancelOpenOrders(event.getItemId());
}
}
}
判斷是否做對了
✅ 模組解耦成功的標誌:
□ ItemService 不再 import 其他模組的 Service
□ 新需求(如「物料停用時做 X」)能通過添加 EventListener 實現
□ 不需要修改 ItemService 就能添加新功能
□ 能夠獨立發佈和測試各個模組
常見誤區與失敗案例
改造 DDD 時,最容易掉進這些坑。
誤區 1:「聚合根越大越好」
❌ 失敗案例:
你想把所有相關的 Entity 都放進聚合根:
@Entity
public class Item {
// 直接相關(✅ 應該包含)
private Set<ItemSupplier> suppliers;
private Set<ItemSpec> specs;
private Set<ItemUnit> units;
// 間接相關(❌ 不應該包含)
private Set<InventoryLot> inventoryLots; // ← 這是庫存模組的!
private Set<ProcurementOrder> procurementOrders; // ← 這是採購模組的!
}
為什麼失敗: – Item 保存時需要級聯保存 5+ 個表,鎖競爭激烈,性能下降 – Item 聚合根變成「超級物件」,難以理解 – 修改 Item 時意外影響到庫存、採購邏輯,耦合反而增加
✅ 正確做法:
@Entity
public class Item {
// 只包含直接的業務關係
private Set<ItemSupplier> suppliers; // ✅ 供應商和物料綁定
private Set<ItemSpec> specs; // ✅ 規格和物料綁定
private Set<ItemUnit> units; // ✅ 單位和物料綁定
// 不包含其他模組的概念
// InventoryLot 通過事件初始化,不在聚合根內
}
判斷標準(不要盲目擴大聚合根):
Q: 子實體是否與聚合根一起刪除?
是 → 應該包含(如 ItemSupplier)
否 → 不應該包含(如 InventoryLot)
Q: 子實體是否只從聚合根訪問?
是 → 應該包含
否 → 應該獨立為聚合根
Q: 修改子實體時是否總是修改聚合根?
是 → 應該包含
否 → 應該獨立
誤區 2:「業務驗證應該面面俱到」
❌ 失敗案例:
你把所有可能的驗證都搬進聚合根:
@Entity
public class Item {
public void addSupplier(ItemSupplier supplier) {
// 驗證 1:聚合根內的規則 ✅
if (supplier.getLeadTime() > 180) {
throw new DomainException("交期超過限制");
}
// 驗證 2:聚合根內的規則 ✅
if (this.status == ItemStatus.DISCONTINUED) {
throw new DomainException("已停用物料無法添加供應商");
}
// 驗證 3:跨聚合根(❌ 問題開始)
if (inventoryService.hasStock(this.id)) {
throw new DomainException("有庫存物料無法變更供應商");
}
// 驗證 4:跨聚合根(❌ 問題繼續)
if (procurementService.hasOpenOrder(this.id)) {
throw new DomainException("有待執行採購單,無法變更");
}
this.suppliers.add(supplier);
}
}
為什麼失敗: – 聚合根需要依賴 InventoryService、ProcurementService(破壞解耦) – 單元測試需要 mock 太多外部服務,測試變得脆弱 – 聚合根的職責混亂,業務規則難以維護
✅ 正確做法:
聚合根只驗證「邊界內」的規則:
@Entity
public class Item {
public void addSupplier(ItemSupplier supplier) {
// ✅ 只驗證聚合根內的規則
if (supplier.getLeadTime() > 180) {
throw new DomainException("供應商交期不能超過 180 天");
}
if (this.status == ItemStatus.DISCONTINUED) {
throw new DomainException("已停用物料無法添加供應商");
}
this.suppliers.add(supplier);
supplier.setItem(this);
}
}
跨聚合根的驗證在 Service 層,而且可選:
@Service
@RequiredArgsConstructor
public class ItemDomainService {
private final ItemRepository itemRepository;
private final InventoryService inventoryService;
private final ProcurementService procurementService;
public void addSupplierWithFullValidation(Long itemId, Long supplierId, Integer leadTime) {
Item item = itemRepository.findById(itemId).orElseThrow();
ItemSupplier supplier = new ItemSupplier(supplierId, leadTime);
// 聚合根的驗證(必須遵守)
item.addSupplier(supplier);
// 跨聚合根的驗證(應用層的約束,可選)
if (inventoryService.hasStock(item.getId())) {
logger.warn("物料 {} 有庫存,但允許變更供應商", item.getId());
}
itemRepository.save(item);
}
}
判斷標準:
Q: 這個驗證是「業務規則必須遵守」嗎?
是 → 放在聚合根,無可協商
否 → 放在 Service/Controller,可根據上下文靈活處理
Q: 這個驗證涉及「多個聚合根」嗎?
是 → 放在 Service 層(跨越邊界的協調)
否 → 放在聚合根
Q: 這個驗證是「應用層的約束」(可以繞過)?
是 → 放在 Controller 層或 Service 層的可選檢查
誤區 3:「所有 Service 都應該消失」
❌ 失敗案例:
你認為改用 DDD 後,Service 層就不需要了:
// ❌ 直接在 Controller 調用聚合根
@RestController
@RequestMapping("/items")
public class ItemController {
private final ItemRepository itemRepository;
private final ApplicationEventPublisher eventPublisher;
@PostMapping
public void createItem(CreateItemRequest req) {
Item item = Item.createNew(req.getCode(), req.getName());
itemRepository.save(item);
// 事件發布邏輯散落在 Controller
item.getDomainEvents().forEach(eventPublisher::publishEvent);
}
}
為什麼失敗: – Controller 不應該知道「如何發布事件」,職責混亂 – 事件發布邏輯散落在多個 Controller,難以維護 – 無法進行全局的業務流程協調
✅ 正確做法:
Service 層仍然存在,但職責改變:
@Service
@RequiredArgsConstructor
public class ItemDomainService {
private final ItemRepository itemRepository;
private final ApplicationEventPublisher eventPublisher;
// Service 層職責:協調聚合根和事件發布
public void createItem(CreateItemCommand cmd) {
Item item = Item.createNew(cmd.getCode(), cmd.getName());
itemRepository.save(item);
// Service 層負責發布事件
item.getDomainEvents().forEach(eventPublisher::publishEvent);
}
public void addSupplier(Long itemId, Long supplierId, Integer leadTime) {
Item item = itemRepository.findById(itemId).orElseThrow();
ItemSupplier supplier = new ItemSupplier(supplierId, leadTime);
item.addSupplier(supplier);
itemRepository.save(item);
// Service 層也負責事件發布
item.getDomainEvents().forEach(eventPublisher::publishEvent);
}
}
@RestController
@RequestMapping("/items")
public class ItemController {
private final ItemDomainService itemDomainService;
@PostMapping
public void createItem(CreateItemRequest req) {
itemDomainService.createItem(
new CreateItemCommand(req.getCode(), req.getName())
);
}
}
判斷標準:
✅ Service 層改造成功的標誌:
□ 是否有專門的地方發布事件?是(在 Service)
□ Service 層代碼是否減少而非消失?是
□ Controller 是否還在調用 Service?是
□ Service 只負責協調,不包含業務邏輯?是
誤區 4:「改用 DDD 代碼就會立即減少」
❌ 失敗預期:
改造前:Item (23行) + ItemService (373行) = 396行
改造後:Item (120行) + ItemService (180行) + Event (50行) + Listener (100行)
= 450行
「咦,代碼反而增加了!DDD 是不是騙我的?」
✅ 真相:DDD 改造的代碼增長曲線
Week 1-2(改造期):代碼 ↑ 20-30%
為什麼?需要新增 EventListener、Domain Event、業務方法
這是正常的,不要擔心
Week 3-4(穩定期):代碼 → 持平
為什麼?新增的 Event 代碼被簡化的 Service 代碼抵消
Month 2(推廣期):代碼 ↓ 20-30%
為什麼?用事件驅動後,新增功能只需要添加 Listener
不再需要修改核心的 ItemService,代碼增長放緩
總體趨勢:先增後平再減
不要看一週的代碼行數,要看的是:
✅ 判斷是否改對了(而非看代碼行數):
□ ItemService 中的驗證邏輯是否減少了?
□ 新增功能時是否不再需要修改 ItemService?
□ 新人能否更快地理解業務規則?
□ 單元測試的比例是否提升?
□ 模組間的依賴是否減少了?
改造檢查清單
改造完成後,用這個清單判斷「改對了沒」。
聚合根設計是否正確
□ 聚合根內有業務驗證邏輯(不只是 getter/setter)
□ 修改聚合根時只依賴一個 Repository
□ 無法通過 ID 獨立查詢子實體(必須通過聚合根)
□ 聚合根內的邏輯能通過單元測試驗證
□ 子實體無法獨立存在(與聚合根一起刪除)
Service 層簡化是否成功
□ 主 Service 代碼行數 < 原來的 50%
□ Service 不再包含業務驗證邏輯(都在聚合根)
□ Service 每個方法都 < 30 行(實現簡潔)
□ Service 不再直接 import 其他模組的 Service
□ 能否在不改 Service 的情況下實現新功能
模組解耦是否有效
□ Service 層不再 import 其他模組的 Service
□ 新需求能通過添加 EventListener 實現(不改既有代碼)
□ 能否獨立單元測試某個模組(mock Event)
□ 模組發佈時無需等待其他模組
□ 新增「X 事件發生時做 Y」的改動點是否只有 1 個(新增 Listener)
警告:改造失敗的信號
⚠️ 如果出現以下情況,停下來重新檢查:
□ 聚合根變得「超級大」(包含 10+ 個子實體)
→ 你的聚合根邊界劃分有問題,需要拆分
□ 聚合根開始依賴 Service(如 inventoryService.check())
→ 你的驗證邏輯又跑回 Service 了,需要重新整理
□ 事件監聽器開始同步調用多個其他 Service
→ 你的事件驅動解耦失敗了,監聽器職責混亂
□ 新功能仍需修改 ItemService、InventoryService、ProcurementService
→ 說明事件驅動沒有成功解耦,需要重新審視聚合根邊界
下一步:怎麼開始
改造不是一次性的,而是漸進式的。
這週你應該做什麼
【今天】評估你的項目
- 打開一個最核心的 Service(通常是業務邏輯複雜的)
- 數一下:這個 Service 有多少行代碼?依賴多少個 Repository?
- 識別:其中哪些驗證邏輯應該在聚合根裡?
【這週】設計改造方案
- 和團隊討論:「哪個是最核心的聚合根?」
- 畫出當前的依賴關係圖
- 畫出改造後的依賴關係圖(應該是「星形」變「無直接依賴」)
【下週】試驗改造(先改一個小功能)
- 選一個「新增」的小功能(而非修改現有功能)
- 用新的聚合根設計來實現它
- 用檢查清單(第四章):改對了沒?
【然後】決定是否全面推廣
- 如果試驗成功 → 逐步遷移其他功能
- 如果試驗失敗 → 回到原方案,分析為什麼(可能是聚合根邊界設計不當)
不要做的事
❌ 不要一次性改造整個系統
→ 改造期間無法正常交付新功能
→ 風險太高,難以定位問題
❌ 不要期望立即看到代碼減少
→ 短期會增加(新增 Event、Listener)
→ 長期才會減少(事件驅動減少了新功能對核心代碼的修改)
❌ 不要過度設計
→ 現在不需要 Event Sourcing、CQRS 等高級概念
→ 先把基礎的 DDD(聚合根、事件驅動)做好再說
最後一句話
DDD 改造的關鍵不是「做完」,而是「做對」。
做對的標誌是: – ✅ 聚合根裡有驗證邏輯,Service 層變瘦了 – ✅ 新功能不再需要改多個模組 – ✅ 新人能更快理解業務規則 – ✅ 單元測試覆蓋率提升了
如果你發現自己在往「聚合根超級大」或「Service 層依賴 Service」的方向走,那就停下來,回到第三章重新讀一遍誤區。
改對了,代碼會說話。