跳至主要内容

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(框架提供):多租戶支援,自動管理 tenantId
  • AuditableTenantEntity(專案定義):加入審計欄位

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)數值範圍
@EmailEmail 格式
@Pattern正則表達式
@DecimalMin / @DecimalMaxBigDecimal 範圍

業務方法

封裝領域邏輯

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 類別PascalCaseProductCustomer
資料表snake_caseproductcustomer
欄位camelCase → snake_casecategoryCodecategory_code
狀態枚舉Entity + StatusProductStatusCustomerStatus

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破壞封裝業務方法

下一步