使用Typescript實現DDD領域建模 - Matthew de Nobrega

banq發表於2019-06-23

Typescript提供了一系列用於構建富域模型的工具。然而,有很多方法可以解決這個問題,並且需要解決一些棘手的挑戰。
任何方法必須解決的主要挑戰是:
  • 序列化/反序列化:來自永續性和傳輸層的資料是無型別的,需要進入“型別安全區域”
  • 處理聚合,值物件和列表
  • 支援聯合型別,多型和繼承
  • 不可變性 - 雖然這不是一項要求,但優點是足以支援建立不可變模型的方法

介面
開始需要序列化的資料建模域的第一步是:為資料定義介面,並將原始資料轉換為這些介面:

  • 定義這些介面可以依賴工具的幫助,因為工具有更多的資訊,編譯器可以捕獲明顯的錯誤,如嘗試訪問不存在的欄位。
  • 然而,這種方法不支援不變性並且不提供型別安全性 - 不正確的資料將被傳入而沒有錯誤,會在型別系統中被認為是物件的樣子與其真實樣子之間產生未識別的不匹配。只有當程式碼嘗試執行訪問不存在的屬性之類的操作時,才會識別出這種情況。
  • 轉換為介面不會向領域物件新增任何功能,因此根據定義它們是貧血的


interface IPerson { 
  name:string // name理論上不能未定義const person = <Person> 
deserializedData console.log(person.name)//不保證名稱已定義


建設者
一種常用的方法是使用構建器Builder模式:

  • 構建器可以進行現場級驗證,並可以在返回之前檢查完整物件,因此可以保證型別安全。
  • 構建器將構造物件與輸入的引數分離,這具有優點,但是為非編碼資料的編組增加了大量開銷。
  • 構建器可以用於生成不可變的域物件,但是具有改變這些物件的狀態的域方法變得非常麻煩,並且使用類引用構建器是醜陋的。
  • 構建器增加了很多開銷 - 每個域類都需要定義一個構建器,並且兩者需要保持同步


class Person {
  readonly name: string
  addLastName(lastName: string): Person {
    return new PersonBuilder()
      .setName(`${this.name} ${lastName}`)
      .getResult()
  }
}
class PersonBuilder {
  getResult(): Person {} // Throw if name not initialized
  setName(name: string): PersonBuilder {} // Throw for invalid name
}
const person = new PersonBuilder()
  .setName(deserializedData.name) 
  .getResult()


使用建構函式
雖然有很多關於建構函式應該具有多少複雜性的討論,但是看到建構函式的唯一作用是建立非型別化資料的型別安全例項,並且對該資料進行可選更新,從而導致可讀,型別安全碼:

// Get the field value from the update, falls back to the item
function fallback(update?: any, item: any, field: string) {}
class Person {
  readonly name: string
  constructor(item: any, update?: any) {
    if (!item) { throw new Error('Item not supplied') }
    this.name = fallback(update, item, 'name')
    if (!this.name) { throw new Error('Name not supplied') }
  }
  
  addLastName(lastName: string): Person {
    return new Person(this, { name: `${this.name} ${lastName}` })
  }
}
const person = new Person(deserializedData)

  • 與構建器一樣,支援欄位級和完整物件檢查,並且可以保證型別安全
  • 構造複雜物件可以在一行中完成,而不是使用構建器或對映器的每個欄位的行
  • 更改狀態的域模型方法可以使用建構函式的update引數在一行中執行此操作,而不是使用構建器逐行重新建立完整物件
  • 一個類的所有構造和驗證邏輯都在一個地方


額外巢狀物件和聯合型別
領域驅動設計提倡使用聚合進行域建模,聚合通常具有巢狀值物件。使用構建器或對映器處理這些情況變得很麻煩,但使用建構函式是乾淨的:

class Address {
  readonly postalCode: string
  // Obvious constructor..
}
class Person {
  readonly address: Address
  readonly name: string
  constructor(item: any, update?: any) {
    if (!item) { throw new Error('Item not supplied')
    this.address = new Address(fallback(update, item, 'address'))
    this.name = fallback(update, item, 'name')
    if (!this.name) { throw new Error('Name not supplied') }
  }
}
const person = new Person(deserializedData)


聯合型別是模擬“同一事物的不同型別”的好方法,並且可以使用輔助方法支援:

class PostalAddress {
  readonly postalCode: string
}
class ResidentialAddress {
  readonly street: string
}
type Address = PostalAddress | ResidentialAddress
// Takes raw address and returns constructed address
function address(item: any): Address {}
class Person {
  readonly address: Address
  readonly name: string
constructor(item: any, update?: any) {
    if (!item) { throw new Error('Item not supplied') }
    this.address = address(fallback(update, item, 'address'))
    this.name = fallback(update, item, 'name')
    if (!this.name) { throw new Error('Name not supplied') }
  }
}
const person = new Person(deserializedData)


在過去的六個月裡,我每天都在使用這種模式,這是我能找到的最簡潔的方法,用於支援不可變性並保證序列化邊界的型別安全性。

警告:
​​​​​​​
  • 雖然可以在建構函式中重塑資料,但是每次要序列化時都需要“解構器”來反轉此過程。我發現保持對稱性:item = new Item(JSON.parse(JSON.stringify(item))使得程式碼更加清晰,代價是遠離'純'DDD。
  • 上述方法不保證物件和陣列欄位的不變性。為此可以使用像immutable一樣的庫,但這會破壞序列化/反序列化的對稱性,對我來說,只是不編寫改變物件和陣列欄位的程式碼就更清晰了。
  • 有一個論點要求物件的完全例項化使單元測試變得困難 - 即你需要提供物件的所有欄位來測試任何一個方法。我正在解決這個問題,透過定義一個有效的存根物件來執行單元測試 - 可以使用建構函式的update引數設定存根物件狀態的任何必需變體:const newItem = new Item(stub,{name:'測試'})

相關文章