使用Typescript实现DDD领域建模 - Matthew de Nobrega


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:'测试'})