Entity 設計
本指南說明如何基於 AppFuse 框架設計花店系統的 Entity。
Entity 基類
AuditableTenantEntity
所有業務 Entity 繼承 AuditableTenantEntity,自動獲得多租戶隔離與審計功能:
// entity/base/AuditableTenantEntity.java
@Getter
@Setter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableTenantEntity extends TenantAwareEntity {
@CreatedBy
@Column(name = "created_by", updatable = false)
private String createdBy;
@CreatedDate
@Column(name = "created_date", updatable = false)
private Instant createdDate;
@LastModifiedBy
@Column(name = "last_modified_by")
private String lastModifiedBy;
@LastModifiedDate
@Column(name = "last_modified_date")
private Instant lastModifiedDate;
}
繼承關係:
TenantAwareEntity(框架提供):多租戶支援,自動管理tenantIdAuditableTenantEntity(專案定義):加入審計欄位
AuditableBase
非多租戶 Entity(如 Tenant 本身)使用 AuditableBase:
@Getter
@Setter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableBase {
// 僅審計欄位,無 tenantId
}
Entity 設計範本
基本結構
// entity/sales/Product.java
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "product",
uniqueConstraints = {
@UniqueConstraint(name = "uk_product_tenant_sku", columnNames = {"tenant_id", "sku"})
},
indexes = {
@Index(name = "idx_product_tenant", columnList = "tenant_id"),
@Index(name = "idx_product_sku", columnList = "sku"),
@Index(name = "idx_product_status", columnList = "status")
}
)
public class Product extends AuditableTenantEntity implements Stateful<ProductStatus> {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(length = 36)
private String id;
@Column(length = 30, nullable = false)
@NotBlank
@Size(max = 30)
private String sku;
@Column(length = 200, nullable = false)
@NotBlank
@Size(max = 200)
private String name;
@Enumerated(EnumType.STRING)
@Column(length = 20, nullable = false)
@NotNull
private ProductStatus status = ProductStatus.ACTIVE;
// 業務方法
public boolean isActive() {
return isInStatus(ProductStatus.ACTIVE);
}
}
設計原則
| 原則 | 說明 |
|---|---|
| Lombok | 使用 @Getter、@Setter、@NoArgsConstructor 簡化程式碼 |
| UUID 主鍵 | String id + @GeneratedValue(strategy = GenerationType.UUID) |
| 繼承基類 | 業務 Entity 繼承 AuditableTenantEntity |
| Stateful 介面 | 有狀態的 Entity 實作 Stateful<T> |
| 索引定義 | 使用 @Index 提升查詢效能 |
| 唯一約束 | 使用 @UniqueConstraint 確保資料完整性 |
主鍵設計
UUID 主鍵(推薦)
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(length = 36)
@Size(max = 36)
private String id;
優點:
- 分散式系統友好
- 不暴露業務資訊
- 可在客戶端預先生成
手動設定 ID
某些 Entity(如 Tenant)支援手動設定 ID:
@Id
@Column(length = 36)
@Size(max = 36)
private String id;
@PrePersist
protected void onPrePersist() {
if (id == null || id.isBlank()) {
id = java.util.UUID.randomUUID().toString();
}
}
狀態管理
Stateful 介面
實作 Stateful<T> 介面支援狀態查詢:
public class Product extends AuditableTenantEntity implements Stateful<ProductStatus> {
@Enumerated(EnumType.STRING)
@Column(length = 20, nullable = false)
private ProductStatus status = ProductStatus.ACTIVE;
@Override
public ProductStatus getStatus() {
return status;
}
@Override
public void setStatus(ProductStatus status) {
this.status = status;
}
// Stateful 介面提供的方法
public boolean isActive() {
return isInStatus(ProductStatus.ACTIVE);
}
}
狀態枚舉
public enum ProductStatus {
ACTIVE("已上架"),
INACTIVE("已下架");
private final String label;
ProductStatus(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}
關聯設計
欄位關聯(推薦)
使用欄位關聯而非 @ManyToOne,避免不必要的 JOIN:
// ✅ 推薦:欄位關聯
@Column(name = "category_code", length = 36, nullable = false)
@NotBlank
@Size(max = 36)
private String categoryCode;
// ❌ 避免:JPA 關聯
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
優點:
- 避免 N+1 查詢問題
- 簡化序列化(無循環引用)
- 降低耦合度
嵌入式物件(FileDescriptor)
使用 @Embedded 嵌入值物件:
@Embedded
private FileDescriptor mainImage;
@ElementCollection
@CollectionTable(indexes = @Index(name = "idx_product_gallery_images_product_id", columnList = "product_id"))
private Set<FileDescriptor> galleryImages = new HashSet<>();
JSON 欄位
AttributeConverter
使用 AttributeConverter 將複雜物件存為 JSON:
// Entity 中使用
@Lob
@Convert(converter = JsonConverters.AddressListConverter.class)
private List<Address> addresses = new ArrayList<>();
Converter 實作
// converter/JsonConverters.java
public class JsonConverters {
private static final ObjectMapper objectMapper = new ObjectMapper()
.registerModule(new JavaTimeModule());
@Converter
public static class AddressListConverter implements AttributeConverter<List<Address>, String> {
@Override
public String convertToDatabaseColumn(List<Address> attribute) {
if (attribute == null) return "[]";
try {
return objectMapper.writeValueAsString(attribute);
} catch (JsonProcessingException e) {
return "[]";
}
}
@Override
public List<Address> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return new ArrayList<>();
try {
return objectMapper.readValue(dbData, new TypeReference<>() {});
} catch (JsonProcessingException e) {
return new ArrayList<>();
}
}
}
// 其他 Converter:StringListConverter、MapConverter 等
}
常用 Converter
| Converter | 用途 |
|---|---|
AddressListConverter | 地址列表 |
ContactListConverter | 聯絡人列表 |
StringListConverter | 字串列表(如標籤) |
MapConverter | 自訂 metadata |
驗證註解
Bean Validation
@Column(length = 200, nullable = false)
@NotBlank
@Size(max = 200)
private String name;
@Column(precision = 10, scale = 2, nullable = false)
@NotNull
@Min(0)
private BigDecimal price;
@Column(length = 255)
@Email
@Size(max = 255)
private String email;
@Column(length = 50)
@Pattern(regexp = "^[a-z0-9-]+$", message = "只能包含小寫字母、數字和連字號")
private String slug;
常用驗證註解
| 註解 | 用途 |
|---|---|
@NotBlank | 字串非空且非空白 |
@NotNull | 非 null |
@Size(max = n) | 字串長度限制 |
@Min(n) / @Max(n) | 數值範圍 |
@Email | Email 格式 |
@Pattern | 正則表達式 |
@DecimalMin / @DecimalMax | BigDecimal 範圍 |
業務方法
封裝領域邏輯
public class Product extends AuditableTenantEntity {
private int stock = 0;
private int lowStockThreshold = 5;
// 查詢方法
public boolean isLowStock() {
return stock <= lowStockThreshold;
}
public boolean isOutOfStock() {
return stock <= 0;
}
public StockStatus getStockStatus() {
if (isOutOfStock()) return StockStatus.OUT_OF_STOCK;
if (isLowStock()) return StockStatus.LOW_STOCK;
return StockStatus.IN_STOCK;
}
// 修改方法(含業務規則)
public void deductStock(int quantity) {
if (stock < quantity) {
throw new IllegalStateException("Insufficient stock");
}
this.stock -= quantity;
}
public void addStock(int quantity) {
this.stock += quantity;
}
// 狀態變更
public void activate() {
this.status = ProductStatus.ACTIVE;
}
public void deactivate() {
this.status = ProductStatus.INACTIVE;
}
}
完整範例:Customer Entity
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "customer", indexes = {
@Index(name = "idx_customer_tenant", columnList = "tenant_id"),
@Index(name = "idx_customer_number", columnList = "customer_number"),
@Index(name = "idx_customer_phone", columnList = "phone"),
@Index(name = "idx_customer_status", columnList = "status")
})
public class Customer extends AuditableTenantEntity implements Stateful<CustomerStatus> {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(length = 36)
private String id;
/// 客戶編號(格式:{租戶代碼}-CUST-{流水號})
@Column(name = "customer_number", length = 30, nullable = false, unique = true)
@NotBlank
@Size(max = 30)
private String customerNumber;
/// 客戶類型
@Enumerated(EnumType.STRING)
@Column(length = 20, nullable = false)
@NotNull
private CustomerType type;
/// 客戶狀態
@Enumerated(EnumType.STRING)
@Column(length = 20, nullable = false)
@NotNull
private CustomerStatus status = CustomerStatus.ACTIVE;
/// 電話(必填)
@Column(length = 20, nullable = false)
@NotBlank
@Size(max = 20)
private String phone;
/// 姓名(個人客戶)
@Column(length = 100)
@Size(max = 100)
private String name;
/// 公司名稱(企業客戶)
@Column(length = 200)
@Size(max = 200)
private String companyName;
/// 地址列表(JSON)
@Lob
@Convert(converter = JsonConverters.AddressListConverter.class)
private List<Address> addresses = new ArrayList<>();
/// 累計消費金額
@Column(precision = 12, scale = 2, nullable = false)
@NotNull
private BigDecimal totalSpent = BigDecimal.ZERO;
// ============================================
// 業務方法
// ============================================
public String getDisplayName() {
return type == CustomerType.INDIVIDUAL ? name : companyName;
}
public void addSpent(BigDecimal amount) {
this.totalSpent = this.totalSpent.add(amount);
}
public boolean isIndividual() {
return type == CustomerType.INDIVIDUAL;
}
public boolean isCorporate() {
return type == CustomerType.CORPORATE;
}
}
最佳實踐
1. 命名規範
| 類型 | 規範 | 範例 |
|---|---|---|
| Entity 類別 | PascalCase | Product、Customer |
| 資料表 | snake_case | product、customer |
| 欄位 | camelCase → snake_case | categoryCode → category_code |
| 狀態枚舉 | Entity + Status | ProductStatus、CustomerStatus |
2. 欄位順序
public class Product extends AuditableTenantEntity {
// 1. ID
@Id
private String id;
// 2. 業務識別碼
private String sku;
// 3. 核心業務欄位
private String name;
private BigDecimal price;
// 4. 狀態欄位
private ProductStatus status;
// 5. 關聯欄位
private String categoryCode;
// 6. 嵌入物件
@Embedded
private FileDescriptor mainImage;
// 7. JSON 欄位
@Lob
@Convert(...)
private List<Address> addresses;
// 8. 業務方法
public boolean isActive() { ... }
}
3. 避免的做法
| 避免 | 原因 | 替代方案 |
|---|---|---|
@ManyToOne 關聯 | N+1 問題、序列化複雜 | 欄位關聯 |
Long id | 不適合分散式 | String id + UUID |
| 手動管理審計欄位 | 重複程式碼 | 繼承 AuditableTenantEntity |
| package-private Entity | 不符專案慣例 | public + Lombok |
| 直接暴露 setter | 破壞封裝 | 業務方法 |
下一步
- AppFuse 框架應用 - 框架核心功能
- 專案模式 - 專案架構總覽
- 資料庫遷移 - Flyway 遷移管理