DDD | 03-什麼是實體物件

Neking發表於2024-07-15

二、什麼是實體?

實體(Entity)是一種核心的領域模型元件,用於表示具有唯一識別符號、生命週期和行為的物件。實體是領域中關鍵概念的具體例項,它們通常對應於現實世界中的事物,比如使用者、訂單、賬戶等。

主要特點

  • 唯一識別符號(Identity):每個實體都有一個唯一的識別符號,這個識別符號是用來區分不同實體的關鍵,即使兩個實體的其他所有屬性都相同,只要識別符號不同,它們就是兩個獨立的實體。

  • 生命週期:實體有明確的生命週期,從建立到最終被廢棄。在這個過程中,實體的狀態可能會發生變化,但其身份保持不變。

  • 可變性:實體的狀態在其生命週期中是可以變化的,這意味著實體的屬性值可以被更新,實體可以響應業務事件並改變其內部狀態。

  • 業務行為:實體不僅僅是一堆資料的集合,它們還封裝了相關的業務邏輯和行為。這些行為體現了實體在領域內的職責和功能,有助於維護實體狀態的一致性和完整性。

結構設計

在編碼實現時,實體類的設計應當遵循物件導向的原則,如封裝、繼承(如果適用)、多型等,同時要確保實體的不變性和業務規則得到正確實施。此外,考慮到與基礎設施(如 ORM 對映)的整合,還需注意如何處理識別符號的生成、持久化細節以及併發訪問控制等問題。

唯一識別符號(Identity)

  • 每個實體必須有一個明確的唯一識別符號,用於區分不同例項。這個識別符號通常是不可變的,並且在實體的整個生命週期中保持不變。

  • 在 Java 等面嚮物件語言中,唯一標識通常透過一個私有的 ID 欄位和相應的 getter 方法暴露給外部,而 ID 的 setter 方法可能被省略或設為私有,以防止外部直接修改。

  • 在實現層面,開發者需要確保實體的唯一識別符號得到妥善管理,並且在設計實體時,要關注其核心的業務屬性和行為,避免將實體變成簡單的資料容器。此外,實體通常關聯到資料庫中的表,其中識別符號對映為表的主鍵(委派標識,如資料庫中的自增主鍵),以確保資料的持久化和查詢能力。

屬性(Attributes)

  • 實體包含描述其特徵的屬性(成員變數)。這些屬性反映了實體在領域中的狀態,可以是基本型別,也可以是複雜型別。
  • 屬性應儘可能精簡,僅包含那些對業務有意義的資料。

行為(Behaviors/Methods)

  • 實體不僅僅是資料的容器,更重要的是封裝了業務邏輯和規則。透過方法(行為)來操作和改變實體狀態,確保業務規則得到執行。

  • 行為應該表達領域內的概念操作,如訂單的“確認”、“取消”等,這些方法通常會改變實體的內部狀態,並可能觸發領域事件。

建構函式(Constructors)

  • 實體的建構函式通常用來初始化實體的必要屬性,特別是唯一識別符號。在某些情況下,建構函式可能需要引數來確保實體在建立時就處於有效狀態。

領域事件(Domain Events)

  • 雖不是實體結構的直接部分,但實體的行為可能會觸發領域事件,以通知系統中的其他部分有關實體狀態的重要變更。

聚合(Aggregates)

  • 實體經常作為聚合的一部分存在。聚合根負責維護聚合內部的一致性,實體則是聚合內部的成員,它們之間的關係和互動受到聚合根的控制。

值物件(Value Objects)

  • 實體中可能會包含值物件作為屬性,值物件沒有獨立的身份,僅透過其屬性值來定義,用於描述實體的某些特性。

程式碼示例

建立一個商品實體物件

/**
 * @author dolphinmind
 * @ClassName ProductEntity
 * @description 產品實體類,用於表示產品的核心資訊。
 * @date 2024/6/16
 */

public class ProductEntity {
    private final UUID productId;
    private String productName;
    private BigDecimal price;
    private String description;

    /**
     * 驗證產品ID的有效性,不能為null。
     *
     * @param productId 產品的唯一標識
     * @throws NullPointerException 如果產品ID為null
     */
    // 驗證方法
    private void validateProductId(UUID productId) {
        Objects.requireNonNull(productId, "Product ID cannot be null");
    }

    /**
     * 驗證產品名稱的有效性,不能為null或空字串。
     *
     * @param productName 產品的名稱
     * @throws NullPointerException     如果產品名稱為null
     * @throws IllegalArgumentException 如果產品名稱為空字串
     */
    private void validateProductName(String productName) {
        Objects.requireNonNull(productName, "Product name cannot be null");
        if (productName.trim().isEmpty()) {
            throw new IllegalArgumentException("Product name cannot be empty");
        }
    }

    /**
     * 驗證價格的有效性,不能為null且必須大於等於0。
     *
     * @param price 產品的價格
     * @throws NullPointerException     如果價格為null
     * @throws IllegalArgumentException 如果價格小於0
     */
    private void validatePrice(BigDecimal price) {
        Objects.requireNonNull(price, "Price cannot be null");
        if (price.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Price cannot be negative");
        }
    }

    /**
     * 驗證產品描述的有效性,允許為空或空白字串。
     *
     * @param description 產品的描述資訊
     */
    private void validateDescription(String description) {
        // 這裡可以根據業務需求決定是否需要對description進行驗證
        // 示例中假設description可以為空或空白
    }

    /**
     * 建構函式,初始化產品實體。
     *
     * @param productId 產品的唯一標識
     * @param productName 產品的名稱
     * @param price 產品的價格
     * @param description 產品的描述資訊
     * @throws NullPointerException     如果產品ID、名稱或價格為null
     * @throws IllegalArgumentException 如果產品名稱為空字串或價格小於0
     */
    public ProductEntity(UUID productId, String productName, BigDecimal price, String description) {
        validateProductId(productId);
        validateProductName(productName);
        validatePrice(price);
        validateDescription(description);

        this.productId = productId;
        this.productName = productName;
        this.price = price;
        this.description = description;
    }

    /**
     * 獲取產品的唯一標識。
     *
     * @return 產品的唯一標識
     */
    public UUID getProductId() {
        return productId;
    }

    /**
     * 獲取產品的名稱。
     *
     * @return 產品的名稱
     */
    public String getProductName() {
        return productName;
    }

    /**
     * 獲取產品的價格。
     *
     * @return 產品的價格
     */
    public BigDecimal getPrice() {
        return price;
    }

    /**
     * 獲取產品的描述資訊。
     *
     * @return 產品的描述資訊
     */
    public String getDescription() {
        return description;
    }

    /**
     * 更新產品的名稱。
     *
     * @param newProductName 新的產品名稱
     */
    public void updateProductName(String newProductName) {
        this.productName = newProductName;
    }

    /**
     * 更新產品的價格。
     *
     * @param newPrice 新的產品價格
     */
    public void updatePrice(BigDecimal newPrice) {
        this.price = newPrice;
    }

    /**
     * 更新產品的描述資訊。
     *
     * @param newDescription 新的產品描述資訊
     */
    public void updateDescription(String newDescription) {
        this.description = newDescription;
    }

    /**
     * 檢查當前物件與另一個物件是否相等,基於產品ID進行比較。
     *
     * @param o 另一個物件
     * @return 如果兩個物件相等返回true,否則返回false
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }

        if (o == null || getClass() != o.getClass()) {
            return false;
        }

        ProductEntity that = (ProductEntity) o;
        return Objects.equals(productId, that.productId);
    }

    /**
     * 計算當前物件的雜湊碼,基於產品ID計算。
     *
     * @return 當前物件的雜湊碼
     */
    @Override
    public int hashCode() {
        return Objects.hash(productId);
    }

    /**
     * 返回當前物件的字串表示,包含產品ID、名稱、價格和描述。
     *
     * @return 當前物件的字串表示
     */
    @Override
    public String toString() {
        return "ProductEntity{" +
                "productId=" + productId +
                ", productName='" + productName + '\'' +
                ", price=" + price +
                ", description='" + description + '\'' +
                '}';
    }

}

ProductEntity 可能觸發的領域事件

產品建立

  • 當一個新的ProductEntity 例項透過建構函式建立時,這標誌著一個新的產品被建立到系統中。此時,可以觸發一個如ProductCreatedEvent 的領域事件,攜帶產品 ID、名稱、價格和描述等資訊
    // 在建構函式內部觸發事件
    public ProductEntity(UUID productId, String productName, BigDecimal price, String description) {
        // ...驗證邏輯...

        this.productId = productId;
        this.productName = productName;
        this.price = price;
        this.description = description;

        // 觸發產品建立事件
        DomainEventPublisher.instance().publish(new ProductCreatedEvent(this));
    }

產品資訊更新

  • 更新產品名稱、價格或描述等操作可能代表了重要的業務狀態變更,可以分別或統一觸發如ProductNameUpdatedEventProductPriceUpdatedEventProductDescriptionUpdatedEvent等事件。
    public void updateProductName(String newProductName) {
        String oldProductName = this.productName;
        this.productName = newProductName;

        // 觸發產品名稱更新事件
        DomainEventPublisher.instance().publish(new ProductNameUpdatedEvent(this, oldProductName, newProductName));
    }

    // 類似地,為updatePrice和updateDescription新增事件觸發邏輯

為實現領域事件的釋出和訂閱機制,需要一個事件釋出器DomainEventPublisher,它負責管理事件的訂閱者並分發事件。著通常涉及到事件匯流排Event Bus 或觀察者模式的實現。

為什麼商品通常被作為一個實體物件存在?

唯一標識

  • 商品擁有一個全域性唯一的識別符號(如商品ID),這是實體的基本特徵,用於區分不同的商品例項。

屬性和行為

  • 商品包含了一系列描述性的屬性,如名稱、價格、描述、庫存量等,並可能附帶有相應的業務行為,如更新價格、減少庫存等。這些屬性和行為直接關聯到商品本身,體現了實體的特徵。

關聯性

  • 雖然商品可能參與到多個聚合中(如作為訂單項的一部分出現在訂單聚合中),商品本身的管理(如上架、下架、修改資訊)並不依賴於其他更高階別的業務概念,因此它不需要作為一個聚合根來協調內部的一致性。

複用性

  • 商品作為實體,可以在多個上下文中被引用,比如在訂單、購物車、推薦系統等多個地方,而不需要每次引用都包含其所有關聯資訊,這符合實體的複用性質。

總結來說,商品作為一個具有獨立標識、屬性和行為的物件,更適合被建模為實體物件。它在系統中扮演的角色主要是提供關於商品本身的詳細資訊,並參與其他更搞層次聚合(如訂單)的構成,而不是作為一個包含內部複雜邏輯和多個關聯物件的聚合根。

實體物件可否退化為值物件?

實體物件在某些場景下可以退化為值物件,但這種轉變需謹慎考慮並基於具體的業務需求和技術背景。

業務語境變化

  • 從唯一到非唯一:當業務邏輯不再要求某個實體具有唯一標識,或者唯一標識變得不重要是,該實體可以退化為值物件。例如,如果訂單行專案OrderLineItem不再需要透過唯一ID追蹤,而是僅關注其組合屬性時,它可以變為值物件

不變性增強

  • 提升資料完整性:將實體轉換為值物件通常意味著將屬性設定為不可變final,這能增強資料的不變性和安全性,減少併發問題

效能考量

  • 減少資料庫互動:在某些高效能或分散式場景中,將實體轉換為物件並隨聚合根一起載入,可以減少資料庫查詢次數,提高系統效能

簡化設計

  • 減少複雜度:對於簡單的資料持有結果,特別是當物件的主要職責是攜帶資料而非維護狀態時,採用值物件可以簡化系統設計

注意事項

  • 重新評估相等性:實體轉為值物件後,需要基於所有屬性來定義equalshashCode 方法,確保邏輯上的相等性判斷正確
  • 移除身份標識:原有的唯一識別符號(如ID)可能不再適用,需要從類中移除
  • 考慮生命週期管理:實體通常有獨立的生命週期,可能與聚合根相關聯,而值物件的生命週期往往依賴於擁有它的實體或聚合根
  • 更新關聯關係:如果其他實體或值物件原先引用了這個實體,需要調整這些引用,可能需要將引用改為直接包含值物件的屬性集合
  • 領域邏輯遷移:如果實體中包含業務邏輯,需要考慮這部分邏輯如何處理。有時可能需要將邏輯移動到其他領域物件或服務中

綜上,實體物件退化為值物件是一種設計上的權衡,應當基於實際業務場景和系統需求綜合考慮。在做出改變前,徹底分析影響並進行必要的設計調整。

問題探究

實體物件與值物件的區別是什麼?

實體物件(Entities)

  • 唯一標識:實體擁有唯一的識別符號(ID),這個ID用來區分不同的實體例項,即使它們的其他屬性相同。實體的識別符號在整個生命週期中保持不變
  • 可變性:實體的狀態可以在其生命週期中發生變化,即實體的屬性可以被修改
  • 業務行為:實體通常包含業務邏輯,體現為方法或操作,這些行為可以改變實體自身的狀態,反映領域內的業務規則
  • 生命週期:實體有明確的生命週期管理,從建立到最終可能的刪除

值物件(Value Objects)

  • 無唯一標識:值物件沒有獨立的唯一標識,它們透過其屬性的組合來確定相等性。如果兩個值物件的所有屬性都相同,那麼它們就被認為是相等的,即使它們在記憶體中是不同的例項
  • 不可變性:值物件通常設計為不可變的,一旦建立,其屬性就不能更改。如果需要改變,通常是透過建立一個新的值物件例項來表示變化後的狀態
  • 傳遞值:值物件在領域模型中通常用來描述實體的屬性或特徵,它們可以被實體引用,也可以在多個實體間共享,而不改變其本質
  • 關注資料:值物件主要關注資料的封裝,它們不包含業務行為,或者包含的行為僅限於驗證自身資料的完整性

擴充示例

建立一個訂單項OrderLineOrderLineItem實體物件

public class OrderLineItemEntity {

    // 訂單行專案ID,唯一標識每個訂單行專案
    private final UUID orderLineItemId;
    // 商品ID,關聯到訂單中的特定商品
    private final UUID productId;
    // 商品名稱,描述訂單中的商品
    private final String productName;
    // 商品數量,表示訂單中該商品的件數
    private int quantity;
    // 商品單價,表示每件商品的價格
    private BigDecimal unitPrice;
    // 折扣金額,表示應用於商品總價的折扣數額
    private BigDecimal discountAmount;

    /**
     * 構造一個新的訂單行專案。
     *
     * @param orderLineItemId 訂單行專案ID
     * @param productId       商品ID
     * @param productName     商品名稱
     * @param quantity        商品數量
     * @param unitPrice       商品單價
     * @param discountAmount  折扣金額
     */
    public OrderLineItemEntity(UUID orderLineItemId, UUID productId, String productName, int quantity,
                               BigDecimal unitPrice, BigDecimal discountAmount) {
        this.orderLineItemId = orderLineItemId;
        this.productId = productId;
        this.productName = productName;
        this.quantity = quantity;
        this.unitPrice = unitPrice;
        this.discountAmount = discountAmount;
    }

    /**
     * 驗證訂單行專案ID的有效性。
     *
     * @param orderLineItemId 訂單行專案ID
     * @throws NullPointerException 如果ID為null,則丟擲異常
     */
    private void validateOrderLineItem(UUID orderLineItemId) {
        Objects.requireNonNull(orderLineItemId, "訂單項ID不能為空");
    }

    /**
     * 驗證商品ID的有效性。
     *
     * @param productId 商品ID
     * @throws NullPointerException 如果ID為null,則丟擲異常
     */
    private void validateProductId(UUID productId) {
        Objects.requireNonNull(productId, "商品ID不能為空");
    }

    /**
     * 驗證商品名稱的有效性。
     *
     * @param productName 商品名稱
     * @throws NullPointerException 如果名稱為null,則丟擲異常
     */
    private void validateProductName(String productName) {
        Objects.requireNonNull(productName, "商品名稱不能為空");
    }

    /**
     * 驗證商品數量的有效性。
     *
     * @param quantity 商品數量
     * @throws IllegalArgumentException 如果數量小於1,則丟擲異常
     */
    private void validateQuantity(int quantity) {
        if (quantity < 1) {
            throw new IllegalArgumentException("商品數量不能小於1");
        }
    }

    /**
     * 驗證商品單價的有效性。
     *
     * @param unitPrice 商品單價
     * @throws NullPointerException      如果單價為null,則丟擲異常
     * @throws IllegalArgumentException 如果單價小於等於0,則丟擲異常
     */
    private void validateUnitPrice(BigDecimal unitPrice) {
        Objects.requireNonNull(unitPrice, "商品單價不能為空");

        if (unitPrice.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("商品單價不能小於等於0");
        }
    }

    /**
     * 驗證折扣金額的有效性。
     *
     * @param discountAmount 折扣金額
     * @throws NullPointerException      如果折扣金額為null,則丟擲異常
     * @throws IllegalArgumentException 如果折扣金額小於0,則丟擲異常
     */
    private void validateDiscountAmount(BigDecimal discountAmount) {
        Objects.requireNonNull(discountAmount, "折扣金額不能為空");

        if (discountAmount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("折扣金額不能小於0");
        }
    }

    /**
     * 獲取訂單行專案ID。
     *
     * @return 訂單行專案ID
     */
    public UUID getOrderLineItemId() {
        return orderLineItemId;
    }

    /**
     * 獲取商品ID。
     *
     * @return 商品ID
     */
    public UUID getProductId() {
        return productId;
    }

    /**
     * 獲取商品名稱。
     *
     * @return 商品名稱
     */
    public String getProductName() {
        return productName;
    }

    /**
     * 獲取商品數量。
     *
     * @return 商品數量
     */
    public int getQuantity() {
        return quantity;
    }

    /**
     * 設定商品數量。
     *
     * @param quantity 新的商品數量
     */
    public void setQuantity(int quantity) {
        this.quantity = quantity;
    }

    /**
     * 獲取商品單價。
     *
     * @return 商品單價
     */
    public BigDecimal getUnitPrice() {
        return unitPrice;
    }

    /**
     * 設定商品單價。
     *
     * @param unitPrice 新的商品單價
     */
    public void setUnitPrice(BigDecimal unitPrice) {
        this.unitPrice = unitPrice;
    }

    /**
     * 獲取折扣金額。
     *
     * @return 折扣金額
     */
    public BigDecimal getDiscountAmount() {
        return discountAmount;
    }

    /**
     * 設定折扣金額。
     *
     * @param discountAmount 新的折扣金額
     */
    public void setDiscountAmount(BigDecimal discountAmount) {
        this.discountAmount = discountAmount;
    }

    /**
     * 計算訂單行專案的總價(數量 * 單價 - 折扣金額)。
     *
     * @return 訂單行專案的總價
     */
    public BigDecimal calculateTotalPrice() {
        return unitPrice.multiply(BigDecimal.valueOf(quantity)).subtract(discountAmount);
    }

    /**
     * 返回訂單行專案的資訊字串。
     *
     * @return 訂單行專案資訊的字串表示
     */
    @Override
    public String toString() {
        return "OrderLineItem{" +
                "orderLineItemId=" + orderLineItemId +
                ", productId=" + productId +
                ", productName='" + productName + '\'' +
                ", quantity=" + quantity +
                ", unitPrice=" + unitPrice +
                ", discountAmount=" + discountAmount +
                '}';
    }

    /**
     * 檢查兩個訂單行專案是否相等。
     * 相等性基於訂單行專案ID。
     *
     * @param o 另一個物件
     * @return 如果兩個訂單行專案具有相同的ID,則返回true;否則返回false
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        OrderLineItemEntity that = (OrderLineItemEntity) o;
        return Objects.equals(orderLineItemId, that.orderLineItemId);
    }

    /**
     * 計算訂單行專案的雜湊碼。
     *
     * @return 訂單行專案的雜湊碼
     */
    @Override
    public int hashCode() {
        return Objects.hash(orderLineItemId);
    }
}

為什麼訂單項通常被視為實體物件?

唯一性和標識

  • 雖然訂單項通常作為訂單的一部分存在,沒有全域性唯一的識別符號要求,但在訂單內部,每個訂單項可能需要一個唯一標識來區分不同的商品及其購買詳情。這種內部唯一性表明它具有實體的特徵。

獨立屬性和行為

  • 訂單項通常包含商品 ID、數量、單價、小計金額等屬性,這些屬性組合起來描述了一個具體的購買決策。它可能還會有自己的行為,比如調整數量、計算小計等,這些行為體現了它不僅僅是資料的容器,而是具有業務邏輯的實體。

生命週期依賴

  • 雖然訂單項的生命週期通常於訂單緊密相關,但它的存在和操作(如數量變更)在一定程度上是獨立的,可以被看作是訂單聚合內部的一個子實體。

相比之下,值物件通常標識沒有唯一識別符號且僅透過其屬性來定義其相等性的物件,比如顏色、地址等,它們在領域模型中更多地用於描述屬性,而不是擁有獨立行為或生命週期。

因此,基於訂單項具有自己的屬性、可能的行為以及在訂單聚合內的相對獨立性,將其涉及為實體物件是合理的。這允許訂單項在訂單聚合的上下文中保持一定的靈活性和自治性,同時也便於處理於訂單相關的複雜業務邏輯。

相關文章