跳至主要内容

ADR-003: Entity 關聯設計策略

ADR 編號: 003 狀態: 已接受 (Accepted) 決策日期: 2025-12-23 決策者: Development Team 取代: 無 被取代: 無


摘要

根據關聯的重要性用途選擇不同的實作方式:核心業務關聯使用 @ManyToOne(資料庫外鍵),弱關聯僅儲存 String ID(應用層維護),嵌入式集合使用 JSON 欄位。


背景 (Context)

問題陳述

在 Entity 設計中,我們需要處理各種類型的關聯關係:

  1. 租戶隔離: 所有 Entity → Tenant
  2. 核心業務關聯: Order → Customer(訂單必須屬於客戶)
  3. 強依賴關聯: Order → OrderItem(訂單項目從屬於訂單)
  4. 弱關聯: Order → Florist/Driver(分配的設計師/送貨員)
  5. 嵌入式集合: Customer → addresses(客戶的地址列表)

不同類型的關聯有不同的特性和需求,需要選擇合適的實作方式。

限制條件

  • 已決策: 租戶隔離使用 String tenantId(參見 ADR-001
  • 已決策: 嵌入式集合使用 JSON 欄位(參見 ADR-002
  • 性能要求: 避免不必要的 JOIN 和 N+1 查詢
  • 數據完整性: 核心業務數據不能有孤兒記錄

假設前提

  • 應用層可以正確處理弱關聯(被關聯實體可能已刪除)
  • 核心業務關聯需要資料庫層級保證完整性
  • 從屬關聯需要級聯刪除

考量的方案 (Options Considered)

方案 A: 全部使用 @ManyToOne(傳統 ORM 方式)

說明: 所有關聯都使用 JPA @ManyToOne + 資料庫外鍵。

優點:

  • ✅ 資料庫層級保證完整性
  • ✅ 符合 ORM 設計慣例
  • ✅ 開發者熟悉

缺點:

  • ❌ 所有查詢都需要 JOIN(性能開銷)
  • ❌ 容易觸發 N+1 查詢
  • ❌ 弱關聯無法刪除被引用實體(如設計師離職)

評分: 2/5


方案 B: 全部使用 String ID(極端應用層維護)

說明: 所有關聯都僅儲存 String ID,完全由應用層維護。

優點:

  • ✅ 查詢性能最佳(無 JOIN)
  • ✅ 靈活性最高

缺點:

  • ❌ 核心業務數據可能出現孤兒記錄
  • ❌ 無資料庫層級的完整性保證
  • ❌ 應用層負擔過重

評分: 2/5


方案 C: 混合策略(根據重要性選擇) (✅ 選擇)

說明: 根據關聯的重要性和用途選擇不同的實作方式:

關聯類型實作方式資料庫約束
租戶隔離String tenantId❌ 無外鍵
核心業務關聯@ManyToOne✅ 有外鍵
強依賴關聯@OneToMany + CASCADE✅ 有外鍵 + 級聯
弱關聯String xxxId❌ 無外鍵
嵌入式集合JSON 欄位❌ 無關聯

優點:

  • ✅ 平衡性能與數據完整性
  • ✅ 核心業務有保障
  • ✅ 弱關聯保留靈活性
  • ✅ 清晰的設計原則

缺點:

  • ❌ 需要明確的判斷標準
  • ❌ 新成員需要學習

評分: 5/5


決策 (Decision)

選擇方案: C - 混合策略(根據重要性選擇)

核心理由:

  1. 數據完整性: 核心業務關聯使用資料庫外鍵保證
  2. 性能優化: 租戶隔離和弱關聯避免不必要的 JOIN
  3. 靈活性: 弱關聯允許被引用實體刪除(保留歷史)
  4. 清晰原則: 有明確的判斷標準

權衡分析 (Trade-offs)

我們獲得什麼 (Gains)

  • 數據完整性: 核心業務關聯有資料庫層級保證
  • 性能: 租戶隔離和弱關聯無 JOIN 開銷
  • 靈活性: 弱關聯支援刪除被引用實體
  • 清晰性: 關聯類型一目了然

我們放棄什麼 (Losses)

  • 一致性: 不是所有關聯都使用相同方式
  • 簡單性: 需要判斷使用哪種方式

風險與緩解措施 (Risks & Mitigations)

風險嚴重性機率緩解措施
開發者誤判關聯類型提供清晰的判斷標準、程式碼審查
弱關聯的 ID 失效應用層處理 null 情況、保留歷史記錄
核心關聯忘記加外鍵Entity 設計檢查清單

影響 (Consequences)

正面影響

  • 數據品質: 核心業務數據不會出現孤兒記錄
  • 查詢性能: 租戶隔離和弱關聯查詢快速
  • 業務靈活性: 支援人員離職等業務場景

負面影響

  • 學習曲線: 新成員需要理解判斷標準
  • 複雜度: 需要在多種方式間選擇

中性影響

  • 🔸 架構一致性: 確立了「根據重要性選擇」的設計哲學
  • 🔸 維護成本: 需要維護判斷標準文檔

實作指南 (Implementation Guidelines)

關聯類型判斷標準

1. 租戶隔離 → String tenantId

@Entity
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Customer extends TenantAwareEntity {
// ❌ 不要這樣做
// @ManyToOne private Tenant tenant;

// ✅ tenantId 繼承自 TenantAwareEntity
}

判斷標準:

  • 是租戶感知 Entity(需要數據隔離)

參見: ADR-001: 多租戶數據隔離策略


2. 核心業務關聯 → @ManyToOne + 外鍵

@Entity
public class Order extends TenantAwareEntity {

/**
* 客戶(核心業務關聯,保留外鍵約束)
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id", nullable = false)
@NotNull
private Customer customer;
}

對應的資料庫約束:

ALTER TABLE orders
ADD CONSTRAINT fk_order_customer
FOREIGN KEY (customer_id)
REFERENCES customer(id)
ON DELETE RESTRICT;

判斷標準:

  • ✅ 業務上不允許孤兒記錄(訂單必須有客戶)
  • ✅ 被引用實體不應被刪除
  • ✅ 關聯是業務核心邏輯的一部分

範例:

  • Order → Customer(訂單必須屬於客戶)
  • OrderItem → Product(訂單項目必須關聯產品)
  • Account → Tenant(帳號必須屬於租戶)

3. 強依賴關聯 → @OneToMany + CASCADE

@Entity
public class Order extends TenantAwareEntity {

/**
* 訂單項目(強依賴關聯,級聯刪除)
*/
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
}

判斷標準:

  • ✅ 從屬關聯(OrderItem 從屬於 Order)
  • ✅ 父實體刪除時,子實體也應刪除
  • ✅ 子實體脫離父實體後無意義

範例:

  • Order → OrderItem(訂單刪除,項目也刪除)
  • Order → OrderStatusHistory(訂單刪除,歷史也刪除)

4. 弱關聯 → String xxxId

@Entity
public class Order extends TenantAwareEntity {

/**
* 分配的設計師 ID(弱關聯,無外鍵)
*
* 設計師可能被刪除,但訂單需保留歷史記錄
*/
@Column(name = "assigned_florist_id", length = 36)
private String assignedFloristId;

/**
* 分配的送貨員 ID(弱關聯,無外鍵)
*/
@Column(name = "assigned_delivery_id", length = 36)
private String assignedDeliveryId;
}

判斷標準:

  • ✅ 被引用實體可能被刪除(人員離職)
  • ✅ 需要保留歷史記錄(誰處理過這個訂單)
  • ✅ 關聯僅用於顯示或統計

範例:

  • Order → Florist(分配的設計師,可能離職)
  • Order → Driver(分配的送貨員,可能離職)
  • CustomerNote → createdBy(創建者,可能離職)

應用層處理:

// Service 層
public OrderResponse toResponse(Order order) {
String floristName = order.getAssignedFloristId() != null
? accountRepository.findById(order.getAssignedFloristId())
.map(Account::getName)
.orElse("已離職") // 處理 ID 失效的情況
: null;

return new OrderResponse(..., floristName);
}

5. 嵌入式集合 → JSON 欄位

@Entity
public class Customer extends TenantAwareEntity {

/**
* 地址列表(JSON 格式)
*/
@JdbcTypeCode(SqlTypes.LONGVARCHAR)
@Column
private List<Address> addresses = new ArrayList<>();
}

判斷標準:

  • ✅ 從屬於主 Entity
  • ✅ 不需要獨立查詢或過濾
  • ✅ 數量有限(< 100 筆)

參見: ADR-002: JSON 欄位處理策略


檢查清單

核心業務關聯 (@ManyToOne):

  • 加上 @ManyToOne(fetch = FetchType.LAZY)
  • 加上 @JoinColumn(name = "xxx_id", nullable = false)
  • 加上 @NotNull 驗證
  • 確認資料庫有外鍵約束(ON DELETE RESTRICT)

強依賴關聯 (@OneToMany):

  • 加上 @OneToMany(mappedBy = "xxx", cascade = CascadeType.ALL, orphanRemoval = true)
  • 子 Entity 有 @ManyToOne 指向父 Entity
  • 確認資料庫有外鍵約束(ON DELETE CASCADE)

弱關聯 (String ID):

  • 使用 String xxxId 欄位
  • 加上適當的索引
  • 應用層處理 ID 失效的情況(返回「已離職」等)

相關文檔 (References)

內部文檔

應用層原始碼(參考實作 app-server)

以下是參考實作中的相關檔案,供開發者參考:

  • io.leandev.app.entity.order.Order - 混合使用各種關聯
  • io.leandev.app.entity.customer.Customer - JSON 欄位
  • io.leandev.app.entity.order.OrderItem - 強依賴關聯

外部資源

相關 ADR


變更歷史 (Change Log)

日期變更內容變更者
2025-12-23初版Development Team

文檔維護者: Development Team + AI Assistant 最後審閱: 2025-12-24