這裡貧血、那裡充血,到底資料模型要怎麼設計?

隨著 DDD 的崛起,愈來愈多人開始討論所謂的貧血模型 (Anemic Domain Model),然後就開始有人指責這樣的設計怎樣怎樣 (像是物件導向沒學好之類的),但是既然是一個流行已久的設計方式,事出必有因,先理解它是怎麼出現的再來評論或批判或許會比一股腦批判要好的多。

領域驅動設計 (Domain Driven Design) 在概念上很強調 Domain Know-How 的業務邏輯,這些業務邏輯被稱為領域知識 (Domain Knowledge),轉換成系統設計規格就是領域模型 (Domain Model) 與領域服務 (Domain Service),並設置於領域層 (Domain Layer),在領域層裡面運作的邏輯是整個領域的最高指導原則,並且在考慮高內聚與低耦合的要求後,領域模型應該要在不離開領域範疇的情況下做完自己要處理的任務,所以在一個完整的領域模型中,除了資料本身外,也會包含很多資料處理的邏輯,也就是一般所說程式=資料+流程,或是程式=資料結構+演算法的概念。

例如下列程式碼,就是一個個人銀行存款的領域模型:

// Domain Model version
public class PersonalAccount
{
    private readonly string _accountNumber;
    private decimal _amount;

    public PersonalAccount(string accountNumber, decimal amount) 
    {
    	_accountNumber = accountNumber;
    	_amount = amount;
    }

    public void Deposit(decimal depositAmount)
    {
        if (depositAmount < 0)
        {
            throw new DispositAmountOutOfRangeException();
        }

        _amount += depositAmount;
    }

    public void Withdrawal(decimal withdrawalAmount)
    {
        if (withdrawalAmount < 0 || _amount < withdrawalAmount)
        {
            throw new WithdrawalAmountOutOfRangeException();
        }

        _amount -= withdrawalAmount;
    }
}

貧血模型是怎麼來的?

那麼,所謂的貧血模型 (Anemic Domain Model,更精確的講是貧血領域模型) 是怎麼回事呢?

我想你一定看過這樣的程式:

// DTO version
publiC class PersonalAccount
{
    public string AccountNumber { get; set; }
    public decimal Amount { get; set; }
}

這樣的程式被稱為 Data Transfer Object (DTO),它基本上沒有什麼學問,就只是單純的將資料裝進去,然後交給另一個處理它的程式而己,就這麼簡單。而它的變型像是一般所稱的 POCO (Plain-Old CLR Object),最多只是幫它做一點簡單的處理:

// POCO version
public class PersonalAccount
{
    private readonly string _accountNumber;
    private decimal _amount;

    public PersonalAccount(string accountNumber) => _accountNumber = accountNumber;

    public decimal GetAmount() => _amount;
    public void SetAmount(decimal amt) => _amount = amt;
}

貧血模型之所以為貧血,其最大原因是,不論是 DTO 或是 POCO,它的業務邏輯都操控在別人手上,自己沒辦法去維護自己的業務規則,以上面 DTO 或 POCO 作為例子,它們都沒有內建的處理存款與提款的程式碼,都要倚賴外部程式,像是 PersonalAccountManager 之類的。

// business logic
// base on POCO version
public class PersonalAccountManager
{
    public void Deposit(PersonalAccount account, int depositAmount)
    {
        if (depositAmount < 0)
        {
            throw new DispositAmountOutOfRangeException();
        }

        var amount = account.GetAmount();
        amount += depositAmount;
        account.SetAmount(amount);        
    }

    public void Withdrawal(PersonalAccount account, int withdrawalAmount)
    {
        if (withdrawalAmount < 0 || _accountNumber < withdrawalAmount)
        {
            throw new WithdrawalAmountOutOfRangeException();
        }

        var amount = account.GetAmount();
        amount -= withdrawalAmount;
        account.SetAmount(amount);        
    }
}

當業務邏輯只能由別人處理時,相對的就破壞了這個領域知識的內聚力,也提高了向外的耦合性,而且它並沒有辦法自我處理自己的領域知識,在後續系統的擴充與延長時,若與別人共用這個模型時,別人也一樣要實作這類領域知識,從而發生領域知識不一致的可能,導致系統設計的風險。因此,若系統內的業務邏輯、流程或知識會有共用的可能性時,就應該要考慮以領域模型的方式設計,而不是使用 DTO 或 POCO 的設計作法。

只是說真的,使用 DTO/POCO 來寫範例的習慣由來己久 (在 Java 時代就有了,Java 是叫 POJO),網路上很多的範例程式 (例如這個) 都是以 DTO/POCO 作為資料模型,對於初階的程式設計師來說,他們只會照本宣科,不會去想什麼領域知識這種事,久而久之這個習慣一養成,後面要改可就困難了。

充血模型?

資訊科技領域真的是個很愛造新名詞的領域,三不五時就會冒出新的名詞,不過充血模型 (Rich Model) 這個詞,我找了很多地方,就是沒有一個明確來自於這個領域的明確名詞,因此有可能是其他人創造的,但其實這個名詞沒什麼意義,它就只是單純要和貧血模型做對比而己,其實所謂的充血模型,只是大家在學校學過物件導向程式設計中,具有足夠內聚力的類別而己,但用一個新名詞來講,一個平凡無奇的概念突然瞬間高大上了起來。

一個充血模型,封裝 (Encapsulation) 的特性會十分明顯,它該有的資料就是它自己維護,外部想要對這個模型的資料做異動,就只能使用它所開放的方法 API 執行…這不就教科書上說的嗎?

貧血、充血要怎麼用?

下列情況,可考慮使用貧血模型:

  1. 應用程式很小,幾乎不會與其他系統共用時。
  2. 練習分層手感時 (包含寫範例程式)。
  3. 資料來源極為單純時,可能只有資料庫,而且幾乎使用 ORM。
  4. 應用程式本身沒有明確的領域知識時。
  5. 與其他系統交換,該模型的職責就只有交換資料一種而己

下列情況,可考慮使用充血模型:

  1. 應用程式本身需實作領域知識時。
  2. 應用程式會與其他系統高度共用時 (原則上只要有共用情況就要考慮了)。
  3. 需提升元件的內聚力與降低耦合度時。

參考:
https://www.martinfowler.com/bliki/AnemicDomainModel.html