DDD 架構改造實戰指南:從貧血模型到自我驗證的聚合根

🌏 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
  → 說明事件驅動沒有成功解耦,需要重新審視聚合根邊界

下一步:怎麼開始

改造不是一次性的,而是漸進式的。

這週你應該做什麼

【今天】評估你的項目

  1. 打開一個最核心的 Service(通常是業務邏輯複雜的)
  2. 數一下:這個 Service 有多少行代碼?依賴多少個 Repository?
  3. 識別:其中哪些驗證邏輯應該在聚合根裡?

【這週】設計改造方案

  1. 和團隊討論:「哪個是最核心的聚合根?」
  2. 畫出當前的依賴關係圖
  3. 畫出改造後的依賴關係圖(應該是「星形」變「無直接依賴」)

【下週】試驗改造(先改一個小功能)

  1. 選一個「新增」的小功能(而非修改現有功能)
  2. 用新的聚合根設計來實現它
  3. 用檢查清單(第四章):改對了沒?

【然後】決定是否全面推廣

  1. 如果試驗成功 → 逐步遷移其他功能
  2. 如果試驗失敗 → 回到原方案,分析為什麼(可能是聚合根邊界設計不當)

不要做的事

❌ 不要一次性改造整個系統
   → 改造期間無法正常交付新功能
   → 風險太高,難以定位問題

❌ 不要期望立即看到代碼減少
   → 短期會增加(新增 Event、Listener)
   → 長期才會減少(事件驅動減少了新功能對核心代碼的修改)

❌ 不要過度設計
   → 現在不需要 Event Sourcing、CQRS 等高級概念
   → 先把基礎的 DDD(聚合根、事件驅動)做好再說

最後一句話

DDD 改造的關鍵不是「做完」,而是「做對」。

做對的標誌是: – ✅ 聚合根裡有驗證邏輯,Service 層變瘦了 – ✅ 新功能不再需要改多個模組 – ✅ 新人能更快理解業務規則 – ✅ 單元測試覆蓋率提升了

如果你發現自己在往「聚合根超級大」或「Service 層依賴 Service」的方向走,那就停下來,回到第三章重新讀一遍誤區。

改對了,代碼會說話。

Leave a Comment