弦而時習之

我對 Domain-Driven Design 的理解(2022 年)

因為把 Domain-Driven Design: The First 15 years 看完後腦中有了非常多想法,為了不要太快把這些東西忘記,只能盡快寫成文章記錄下來。

簡單來說,我認為 Domain-Driven Design 是一種「觀念」而不是理論或者實踐,他更接近於將「模型(Model)」的概念帶入到程式設計中,也因此在書中提到 Domain-Driven Design 是沒有嚴格規定,只有接近跟遠離核心的差異。

建構模型

實作一個軟體的方式很多,而 Domain-Driven Design 的一切討論是基於模型(Model)的概念,也就是我們該如何把「現實世界存在的資訊轉換為程式」並且讓程式來幫我們處理這些問題,這也讓像是 Value Object(值物件)跟 Entity(實體)這樣的概念在實作中被界定出來,因為我們需要找出一些跟現實世界的關聯性。

然而,這件事情其實是受到程式語言、領域上的影響。以最近很熱門的人工智慧領域來看,機器學習(Machine Learning)建構「模型」的方式是利用大量訓練資料來建構,而 Domain-Driven Design 則是透過觀察、分析來決定。

這都會影響到我們最後怎麼去實作、定義,以書中其中一個章節的 F# 範例來看,在 F# 中可以做到這樣的事情。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type EmailOnly = Email of string
type AddressOnly = Address of string
type BothMethods = {
  EmailOnly: EmailOnly,
  AddressOnly: AddressOnly
}

type ContactInfo =
  | EmailOnly
  | AddressOnly
  | BothMethods

像這樣很直覺的就可以建造出只有 Email 或 Address 或者兩者同時存在的情況,並且「其中一個符合」的定義出來,相比其他語言更加直觀。

觀察世界

既然要對真實世界進行「建模(Modeling)」那麼我們就需要對世界進行觀察,而 Event Storming(事件風暴)是其中一種手段,如果我們有更好的方式,並不一定要採用這個方法,然而大多數時候這個方法更加容易。

Event Storming 的觀察方式是將現實世界以時間順序為基礎,以「事件」作為節點來描述一件事情,並且最終會停在某個事件作為結束,有點類似大眾所知的量子力學「在觀察之前不會知道結果,最終會得到一個結果」的感覺,我們透過描述被觀測到的結果來檢查在這個領域中會出現的情況。

接下來進一步的將觀察的資訊詳細描述,加入參與者(Actor)、觸發事件的行為(Command)、影響結果的因素(Data or Read Model)、造成事件的機制(System)、事件產生的影響(Policy)來完善這個模型,讓我們得以從中找出 Value Object、Entity 這類在實作上所需的線索,以及對整個領域中造成影響的範圍建構概念,進而得到 Bounded Context(界限上下文)

這個過程也是建構 Ubiquitous Language(共通與言)的好時機,因為所有人都在用自己的方式描述這個模型,大量溝通的過程中就會逐步地將在特定「語言」完善,有點類似第一個翻譯語言的人,雙方用各自的語言描述相同的事物的感覺

與模型互動

當我們能夠將現實世界的事物以一個模型來描述時,我們還需要搭建一個可以跟這些模型互動的環境,也因此 Layered Architecture(層級式架構)這類方案就被使用在這之中,用來建構一個可以跟模型互動的環境。

以 Layered Architecture 為例子,Presentation Layer(表現層)劃分出了使用者對這個模型的觀察,可能是顏色、狀態等等。而 Application Layer(應用層)則描述了如何跟模型互動的方式,至於 Infrastructure Layer(基礎層)就負責提供實現這個模型的基底。

模型玩具的例子

我們用「迴力車」來作為例子,以 Event Storming 的角度來看,我們會觀察到「車子往前」這樣的事件,並解加入操作者(小孩)、往後拉(最多只能 3 公分)、因為彈簧驅動車子向前等等資訊。

如果將其轉換成 Domain Model 我們可以先從「資料(Data)」的方式轉換成有意義的「資訊(Infromation)」那麼會先得到一個「能量」的資訊來表示向後拉的數值,同時「最多只能 3 公分」會被定義在這之中,因為這個「能量」僅限於這個領域。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Power
  include Comparable

  # 描述 Domain Knowledge
  MAX_VALUE = 3
  MIN_VALUE = 0

  attr_reader :value

  def initialize(value = 0)
    @value = value
  end

  def ==(other)
    @value == other.value
  end

  def +(power)
    charged_value = [value + power.value, MAX_VALUE].min
    constrained_value = [MIN_VALUE, charged_value].max

    Power.new(constrained_value)
  end
end

接下來 Value Object 就可以繼續組成 Entity 來表示一個「模型」的基礎單位,並且具備了改變自身狀態的行為能力「蓄力(Charge)」和「前進(Run)」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Car
  # 對 Domain Modle 們來說只認識有意義的 Value Object 而不是 Data
  NO_POWER = Power.new(0)
  STEP_POWER_USAGE = Power.new(1)

  attr_reader :id, :power

  def initialize(id:, power: NO_POWER)
    @id = id
    @power = power
  end

  def charge(power)
    @power += power
  end

  def run
    @power -= STEP_POWER_USAGE
  end
end

根據領域的複雜程度,我們的 Entity 可能是一個更大模型的一個零件,還會有 Aggregate(聚合)以及多個模型要互動時需要的 Service(服務)

然而,我們只有「領域模型(Domain Model)」是不足以讓使用者使用,因此在基於架構方面的理論,建構出一個足以使用的環境。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class RunCarService
  def run(car)
    return until car.power == Car::NO_POWER

    car.run
    puts "The car is running..."
  end
end

class CarController
  def play(input)
    # ToyBox 是 Repository 用於表示「玩具」的集合
    car = ToyBox.find_a_card("Aotoki's blue car")
    # Ruby 的 Hash 是天然的 DTO 因此沒有另外實作 ChargeInput
    power = Power.new(input[:value])

    # Application Layer 以描述行爲的「步驟」為主
    car.charge(power)
    run_car_service.run(car)
  end
end

CardController 我們可以看到能夠使用一種叫做 #play 的方法來讓這台車動起來,並且只需要提供「能量的數值」

為了控制篇幅這邊稍微簡化到只保留 Application Layer 的部分

然而,我們仍在上面的例子看到一些不太合理的地方,像是該如何解釋 Input/Output 的來源以及去向、該如何構成一個「行為」以及操作者的角色是誰等等,也因此像是 Clean Architecture 或者 DCI(Data Context Interaction)等等不同理論也被提出或者加入到 Domain-Driven Design 的討論中,用以改善這些不完善的部分。

Domain-Driven Design: The First 15 Years 中有提到 Eric Evans 認為當初寫下的藍皮書(Domain-Driven Design 原作)是不夠完善的,也因此在 Domain-Driven Design: The First 15 Years 這本書中有很多「解釋」跟「批判」和「補充」類型的章節,持續的去完善整個體系。

已知與未知

從 Event Storming 或者建模的過程中,不難看出來整個實踐是基於「已知」的前提去進行的,也因此「領域專家(Domain Expert)」就變得非常重要,因為我們需要在過程中盡可能的提取知識,來確保整個模型的建構是足夠完善的。

這也是爭議的一部分,也就是會有人認為「花太多時間」或者「過度設計」的狀況存在,雖然在 Domain-Driven Design: The First 15 Years 中有參考了敏捷開發(或極限編程)的概念,但也是有些人選擇「融入」有些人選擇「否定」這樣的方式。

然而,模型在 Domain-Driven Design 中也是會逐漸改變的,至少現實世界中的「模型」也會隨著技術發展、使用者的變化(長大)有些許的轉變,因此我在這件事情上的觀點更傾向於「情境」本身決定實踐方式。

舉例來說,我們已經是一個成熟的「企業」那麼利用 Event Storming 來了解原有的系統並且界定出界限上下文、子領域,是非常合理的情境。另一方面,在 Domain-Driven Design 被提出的時期,也差不多是敏捷開發開始逐漸成熟的時期,某方面來說敏捷開發是對應了對「未知」探索的方法,也因此原本 Domain-Driven Design 用來處理「已知」的方法可能就不太適合。

以我今年最喜歡的 Acceptance Test Driven Development(驗收測試驅動開發,A-TDD)作為例子,以由上而下的方式逐漸探索一個系統,實際上也足以打造一個可用的軟體,跟 Event Storming 的觀察方式剛好完全相法,一個是「黑箱」的方式觀察,另一個則是「白箱」的方式觀察。

實際上,在「建模」這件事情上可能是沒有差異太大,需要專注的是 Domain(領域)的邊界跟劃分以及減少造成昂貴修改成本的情況,從敏捷開發的角度勢必會有一段時間是單體的(Monolithic),直到我們將邊界探索完畢。

開放心態

整體觀察下來,其實 Domain-Driven Design 對我來說更接近是一種觀念,或者說「Model-based(以模型為基礎)」的程式設計的實踐方式,同時整個社群的目標也不外乎是思考「如何建立適合的模型」並且以此為目標努力。

基於這樣的理由,假設有一天 Top-down(自上而下)的探索方式能夠提供「對未知探索」的建模足夠完善的解決方案,也許會被寫在新出版的書中。又或者像是 DCI 這個針對 MVC 的補充,在 15 年後被加入到書中用來呈現在怎樣的情況下,可以去補足原本 Domain-Driven Desing 的不足。

之前在發文時也有人提到「基本教義派」這樣的存在,不過也很難去界定說怎樣是「基本教義派」的定義,不過 Domain-Driven Design 的邊界隨時都在變化的狀況下,我想「基本教義派」其實就是那些在心中建立一個「固定範圍」的人們,所以我們才會覺得他們的建議「過時」跟「不合理」因為沒有隨著整個領域一起「進步」

最後,我會選擇把 Domain-Driven Design 的理論忘掉,或者說「精煉」出來。一方面是我專注的領域更多是 Startup(新創)類型的專案,因此「已知」的探索幫助並不大,同時我主要使用的是 Rails 框架,不論是 Ruby 語言或者 Rails 框架對於現有的 Domain-Driven Design 架構都是不夠契合的,即使 Rails 框架其實隱含了非常多 Domain-Driven Design 的概念,這也就表示需要去「探索」適合用在 Rails 框架的方法,而不是在已知的方法中想辦法「套用」

當然,我很喜歡 Domain-Driven Design 帶給我很多觀念上的衝擊,以及解答非常多實作上遇到的疑惑,因此我還是會持續關注、探索,直到有一天我確定我專注的開發技巧可以被稱為 Domain-Driven Design 或者無法。

電子報

留言