跳至主要内容

資料層設計指南

文檔版本: v2.1.0 最後更新: 2026-07-29 適用對象: 開發團隊、AI Agent、使用 app-tenant-server 範本的團隊

本文檔記錄 app-tenant-server 專案的資料層設計決策與最佳實踐,確保開發一致性與高效能。

Entity 欄位型別、JSON mapping 與關聯 mapping 的現行規範以 JPA Entity 欄位型別規範 為準;本頁聚焦資料隔離、 索引與目錄結構。


目錄

  1. 多租戶架構設計
  2. JSON 欄位處理
  3. Entity 關聯策略
  4. Entity 設計檢查清單
  5. Entity 資料夾架構
  6. 常見問題 (FAQ)
  7. 參考資料

1. 多租戶架構設計

設計原則

核心理念:以 String tenantId 作為 discriminator,不建立指向 Tenant 資料表的外鍵;執行期隔離由 Hibernate 原生 @TenantId 承擔。

為什麼?

考量點傳統方式(@ManyToOne)我們的方式(String tenantId)
查詢性能❌ 需要 JOIN✅ 單表查詢
N+1 問題⚠️ 容易觸發✅ 不存在
水平擴展❌ 跨分片外鍵困難✅ 支援 sharding by tenant
數據隔離⚠️ 依賴應用層查詢條件✅ Session 建立時自動套用 @TenantId filter

實作方式

1.1 繼承 TenantAwareEntity

所有需要租戶隔離的 Entity 都應直接繼承 TenantAwareEntity,或繼承應用端 加入審計欄位的 AuditableTenantEntity

@Entity
@Table(name = "customer")
public class Customer extends AuditableTenantEntity {

@Id
@GeneratedValue(strategy = GenerationType.UUID)
@JdbcTypeCode(SqlTypes.VARCHAR)
@Column(length = 36, nullable = false, updatable = false)
private UUID id;

private String name;
}

框架基類的關鍵映射如下:

框架內部概念(請勿在應用端重複宣告)
@MappedSuperclass
public abstract class TenantAwareEntity implements TenantAware {

@TenantId
@Column(name = "tenant_id", length = 36, nullable = false, updatable = false)
private String tenantId;
}

1.2 Session 建立時綁定租戶

安全流程先解析使用者租戶並設定 TenantContext,交易開始時建立的 Hibernate Session 再綁定該租戶。查詢與 load-by-key 會自動加入 discriminator:

Optional<Customer> customer =
Optional.ofNullable(entityManager.find(Customer.class, id));

// SQL 概念:
// SELECT ... FROM customer
// WHERE id = ? AND tenant_id = ?

不需要 TenantFilterInterceptorTenantFilterAspect、手動 enableFilter() 或每個 Repository 的 tenantId 守衛。背景寫入必須先 TenantContext.runAs(tenantId, ...),並在其內建立交易;不可在已建立的 Session 中途切換租戶。

優點總結

  • 性能優先:每個查詢都是單表查詢,無 JOIN 開銷
  • 自動化:Hibernate 在 Session 建立與 INSERT 時處理 tenantId
  • 安全性:查詢含 load-by-key 皆隔離,租戶欄位不可更新
  • 可擴展:支援未來按租戶分片(sharding)

完整的 root 視角、native SQL 與交易邊界限制請參閱 多租戶設計指南


2. JSON 欄位處理

JSON 只用於原子、資料量有界且不需 SQL 查詢內部欄位的結構。Entity 保留具體 value / collection 型別,使用 AttributeConverter<T, String> + @Column(length = Length.LONG32)

Converter 對無效 JSON 必須 fail fast,不得靜默回傳空集合、空物件或 null。需要查詢、排序、唯一性或獨立生命週期時改為正規化欄位或子 Entity。

不得使用 native JSON、@Lob@JdbcTypeCode(SqlTypes.JSON),也不得在 collection 上用 @JdbcTypeCode(SqlTypes.LONGVARCHAR) 取代 converter。

完整判斷與 mapping 見 JPA Entity 欄位型別規範; 現行決策見 ADR-027


3. Entity 關聯策略

Entity association 與 scalar ID 的選擇依 aggregate boundary、資料完整性、 生命週期與歷史快照需求決定,不以避免 JOIN 作唯一理由。

  • Association 明確使用 LAZY
  • Cascade 與 orphanRemoval 只給真正的 lifecycle owner。
  • Shared Entity 不 cascade remove。
  • 有額外屬性的多對多關係建立 join Entity。
  • 租戶 discriminator 不是業務 association;server-tenant 依既有 tenant primitive 處理。

完整規則見 JPA Entity 欄位型別規範;決策背景見 ADR-028


4. Entity 設計檢查清單

新增 Entity 時的檢查項目

✅ 基本設定

  • 繼承正確的基類

    • 需要租戶隔離 → TenantAwareEntity
    • 僅需審計欄位 → AuditableBase
    • 無需基類功能 → 無需繼承
  • 設定 @Table 索引

    @Table(name = "customer", indexes = {
    @Index(name = "idx_customer_tenant", columnList = "tenant_id"),
    @Index(name = "idx_customer_phone", columnList = "phone"),
    // ...
    })

✅ 租戶隔離

  • 不要建立 @ManyToOneTenant 的關聯
  • 確認繼承 TenantAwareEntity 或應用端的 AuditableTenantEntity
  • 不要重複宣告 @Filter、手動啟用 filter 或在 Repository 補 tenantId 守衛
  • @Table.indexes 加上 tenant_id 索引

✅ JSON 欄位

  • 使用具體型別 + AttributeConverter<T, String>
  • 欄位使用 @Column(length = Length.LONG32)
  • invalid JSON fail fast;null / empty 語意明確
  • 不需在 SQL 查詢 JSON 內部欄位

✅ 關聯設計

  • Entity ref / scalar ID 依 aggregate boundary 與完整性選擇
  • Cascade / orphanRemoval 只給 lifecycle owner
  • Shared Entity 不 cascade remove
  • Fetch 類型預設使用 LAZY

✅ 驗證與約束

  • 加上適當的 Bean Validation 註解

    @NotBlank
    @Size(max = 200)
    @Email
  • 欄位加上 nullable 設定

    @Column(nullable = false)

✅ 業務方法

  • 提供有意義的業務方法(非單純 getter/setter)
    public void addItem(OrderItem item) { /* ... */ }
    public boolean canCancel() { /* ... */ }

5. Entity 資料夾架構

設計原則

按業務領域 (Domain) 分組,遵循 DDD (Domain-Driven Design) 的理念。

  • 每個資料夾代表一個業務子領域
  • 相關的 Entity、Enum、Value Object 放在同一資料夾
  • 即使只有一個 Entity 也獨立成資料夾(便於未來擴展)

目前架構

entity/
├── auth/ # 認證授權
│ ├── Account.java
│ └── Authority.java
├── base/ # 共用基礎類別
│ ├── AuditableBase.java
│ ├── Bin.java
│ ├── Code.java
│ ├── Document.java
│ └── TenantAwareEntity.java
├── customer/ # 客戶管理 (CRM)
│ ├── Customer.java
│ ├── CustomerNote.java
│ ├── CustomerStatus.java
│ ├── CustomerTier.java
│ ├── CustomerType.java
│ ├── Address.java
│ ├── Contact.java
│ ├── Gender.java
│ ├── ImportantDate.java
│ └── PaymentTerms.java
├── mail/ # 郵件設定
│ └── MailSetting.java
├── order/ # 訂單管理
│ ├── Order.java
│ ├── OrderItem.java
│ ├── OrderStatus.java
│ └── OrderStatusHistory.java
├── sales/ # 產品/銷售
│ ├── Product.java
│ ├── ProductCategory.java
│ └── ProductStatus.java
└── tenant/ # 多租戶
└── Tenant.java

未來擴展規劃

針對完整的花店管理系統(含 CRM、財務、人事),預計新增以下領域:

資料夾業務領域可能包含的 Entity
inventory/庫存管理Inventory, StockMovement, Supplier(花材有效期、損耗管理)
finance/財務Invoice, Payment, Receipt, Expense
hr/人事Employee, Schedule, Payroll, Attendance
supplier/供應商管理Supplier, PurchaseOrder
delivery/配送/物流DeliveryRoute, DeliveryRecord
marketing/行銷活動Campaign, Coupon, Promotion

領域邊界建議

  1. Order 與 Payment 分開

    • order/ 負責訂單生命週期
    • finance/ 負責付款記錄
    • 理由:一個訂單可能有多次付款(訂金 + 尾款)
  2. Employee 放 hr/,不放 auth/

    • auth/ 只管 Account(登入帳號)和 Authority(權限)
    • hr/ 管理員工資訊(Employee)、排班、薪資
    • 理由:登入認證與人事管理是不同的業務關注點
  3. 漸進式擴展

    • 不需預先建立所有資料夾
    • 當實作該功能時再新增對應資料夾

6. 常見問題 (FAQ)

Q1: 為什麼不在資料庫建立 tenantId 外鍵?

A: 性能和擴展性考量。

  • 每個查詢都需要過濾 tenant_id,建立外鍵會增加不必要的檢查開銷
  • 未來支援按租戶分片(sharding)時,跨分片外鍵難以維護
  • Hibernate 原生 discriminator 已在 ORM 邊界提供一致的租戶隔離

Q2: 什麼時候應該使用 JSON 欄位?

A: 符合以下條件時:

  • ✅ 數據從屬於主 Entity(如客戶的地址列表)
  • ✅ 不需要獨立查詢或過濾
  • ✅ 數量有限(通常不超過數百筆)
  • ✅ 結構可能變動(靈活性需求)

反之,如果需要:

  • ❌ 獨立查詢過濾(如 WHERE address.city = ?
  • ❌ 關聯查詢(JOIN)
  • ❌ 大量數據

應使用 @OneToMany 建立獨立的 Entity。

Q3: 如何測試跨租戶操作被正確阻擋?

A: 使用整合測試:

@Test
void testCrossTenantAccessBlocked() {
String id = TenantContext.supplyAs("tenant-A", () ->
transactionTemplate.execute(status ->
customerService.create(new Customer()).getId()));

boolean visibleFromTenantB = TenantContext.supplyAs("tenant-B", () ->
transactionTemplate.execute(status ->
customerService.findById(id).isPresent()));

assertThat(visibleFromTenantB).isFalse();
}

每次 runAs 都在內部建立新的交易,確保 Session 綁到正確租戶。API 整合測試則應以兩組不同租戶的認證請求驗證同一契約。

Q4: @TenantId 會自動應用於所有查詢嗎?

A: ORM 管理的查詢會套用,包括:

  • ✅ Criteria、JPQL 與一般 EntityManager 查詢
  • entityManager.find() 等 load-by-key
  • ✅ JPQL bulk update/delete
  • ⚠️ native SQL 不受保護

若確實需要 native SQL,必須手動加入並測試 tenant_id 條件;一般業務 查詢應優先使用 ORM API。


7. 參考資料

相關文檔

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

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

  • io.leandev.app.entity.base.AuditableTenantEntity - 應用端審計基類
  • io.leandev.appfuse.jpa.tenant.TenantAwareEntity - 框架多租戶基類
  • io.leandev.appfuse.security.tenant.TenantContext - 上下文工具類

設計決策記錄

日期決策原因
2025-12-23Entity 資料夾按業務領域分組DDD 設計、高內聚低耦合、便於未來微服務拆分
2025-12-23租戶關聯改用 String tenantId性能優化、支援 sharding
2026-07-29欄位、JSON 與關聯規則收斂至 entity-field-types.md五資料庫共同基線
2025-12-22JSON 欄位使用文字儲存跨資料庫相容性(H2 支援)

文檔維護者: Development Team + AI Assistant 最後審閱: 2026-07-29