ADR-003: Entity 關聯設計策略
ADR 編號: 003 狀態: 已接受 (Accepted) 決策日期: 2025-12-23 決策者: Development Team 取代: 無 被取代: 無
摘要
根據關聯的重要性和用途選擇不同的實作方式:核心業務關聯使用 @ManyToOne(資料庫外鍵),弱關聯僅儲存 String ID(應用層維護),嵌入式集合使用 JSON 欄位。
背景 (Context)
問題陳述
在 Entity 設計中,我們需要處理各種類型的關聯關係:
- 租戶隔離: 所有 Entity → Tenant
- 核心業務關聯: Order → Customer(訂單必須屬於客戶)
- 強依賴關聯: Order → OrderItem(訂單項目從屬於訂單)
- 弱關聯: Order → Florist/Driver(分配的設計師/送貨員)
- 嵌入式集合: 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 - 混合策略(根據重要性選擇)
核心理由:
- 數據完整性: 核心業務關聯使用資料庫外鍵保證
- 性能優化: 租戶隔離和弱關聯避免不必要的 JOIN
- 靈活性: 弱關聯允許被引用實體刪除(保留歷史)
- 清晰原則: 有明確的判斷標準
權衡分析 (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(需要數據隔離)
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 筆)
檢查清單
核心業務關聯 (@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)
內部文檔
- 資料層設計指南 - 第 3 章
應用層原始碼(參考實作 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