三. Domain Modeling with Types


Reviewing the Domain Model

TODO: 补充需求.

现在让我们看下已有领域模型的伪代码 (在与领域专家讨论需求时记录下来的):

context: Order-Taking
// ----------------------
// Simple types
// ----------------------
// Product codes
data ProductCode = WidgetCode OR GizmoCode
data WidgetCode = string starting with "W" then 4 digits
data GizmoCode = ...

// Order Quantity
data OrderQuantity = UnitQuantity OR KilogramQuantity
data UnitQuantity = ...
data KilogramQuantity = ...

// ----------------------
// Order lifecycle
// ----------------------
// ----- unvalidated state -----
data UnvalidatedOrder =
    UnvalidatedCustomerInfo
    AND UnvalidatedShippingAddress
    AND UnvalidatedBillingAddress
    AND list of UnvalidatedOrderLine

data UnvalidatedOrderLine =
    UnvalidatedProductCode
    AND UnvalidatedOrderQuantity

// ----- validated state -----
data ValidatedOrder = ...
data ValidatedOrderLine = ...

// ----- priced state -----
data PricedOrder = ...
data PricedOrderLine = ...

// ----- output events -----
data OrderAcknowledgmentSent = ...
data OrderPlaced = ...
data BillableOrderPlaced = ...

// ----------------------
// Processes
// ----------------------
process "Place Order" =
    input: UnvalidatedOrder
    output (on success):
        OrderAcknowledgmentSent
        AND OrderPlaced (to send to shipping)
        AND BillableOrderPlaced (to send to billing)
    output (on error):
        InvalidOrder

// etc

我们的目标是将此转换为真实的代码.

Modeling Datas with Types

Seeing Patterns in a Domain Mode

组合的类型系统是实践领域驱动设计的绝佳帮助, 因为只需将类型混合在一起, 即可快速创建复杂的模型. 并且, 在函数式领域建模中, 也有一些常用的模式:

  • Simple values. 基础类型的包装. 因为不会直接使用像 int/string 之类的 “原始” 语言.
  • Combinations of values with AND. 也许是它语言中的结构体或类.
  • Choices with OR. 某种程序上类似枚举
  • Processes. 具有输入和输出的流程

Modeling Simple Values

领域专家们一般不会使用 int 之类的术语进行思考, 他们使用领域术语 –– OrderIdProductCode. 此外, 使用领域术语不容易混淆一些概念, 比如 OrderIdProductCode 都是 int, 但并不意味着它们可以互换. 所以, 为了明确这些类型是不同的, 我们将创建 “包装类型” –– 一种包装基础数据类型的类型.

F# 中创建 Simple values 的最简单方法是创建 “single-case” 联合类型 –– 只有一个选项的 OR 类型, 比如:

type CustomerId =
    | CustomerId of int

type CustomerId = CustomerId of int // 缩写成一行

let customerId = CustomerId 42  // 构造值

let (CustomerId innerValue) = customerId  // 解构, 模式匹配, innerValue is set to 42

通常这种 “single-case” 的类型名与构造子名相同.

现在我们可以审视一下领域模型, 并转换成部分代码:

type WidgetCode = WidgetCode of string
type UnitQuantity = UnitQuantity of int
type KilogramQuantity = KilogramQuantity of decimal

这里我们暂时忽视掉取值范围的约束, 后续会说明怎么建模有约束的 Simple values.

另外, 遍历 Simple values 的列表要比直接遍历基础数据类型的列表多花费一些开销, 这是因为内存不连续引起的.
当然, 这些开销通常不大需要关注, 除非我们的领域非常在意性能. 如果是这样的话, 可以使用下面这种方式代替直接建模 Simple values 的列表.

// type CustomerIds = CustomerIds of CustomerId[]
type CustomerIds = CustomerIds of int[]

Modeling Complex Data

复杂的类型就要借助到代数数据类型了.

在领域模型中, 我们看到许多数据结构都是 AND 型关系, 例如, 我们最初的简单订单模型定义为:

data Order =
    CustomerInfo
    AND ShippingAddress
    AND BillingAddress
    AND list of OrderLines
    AND AmountToBill

这可以很方便的直接转换成 F# 代码:

type Order = {
    CustomerInfo : CustomerInfo
    ShippingAddress : ShippingAddress
    BillingAddress : BillingAddress
    OrderLines : OrderLine list
    AmountToBill : ...
}

建模的时候, 我们会发现存在一些未解答的领域问题.
例如应该用什么类型来表示 AmountToBill? ShippingAddressBillingAddress 是相同的类型吗? 等等.

理想情况是继续请求领域专家的帮助. 例如, 如果他们将帐单地址和发货地址作为不同内容进行讨论, 那么即使它们具有相同的结构, 也最好将它们逻辑上分开. 当然我们不需要立即去寻求帮助, 因为我们可以对未知类型进行建模.

Modeling Unknown Types

在设计的早期阶段, 通常不会对某些建模问题给出明确答案. 例如我们知道待建模的类型的名字, 但并不清楚它的内部结构.

这不是问题 –– 我们可以将它们建模为显式的未定义的类型, 该类型充当占位符, 直到在设计过程后期有更好的理解.

如果要在 F# 中表示未定义的类型, 可以使用异常类型 exn 并将其别名为 Undefined; 然后, 就可以在设计模型时使用这个别名, 如下所示:

type Undefined = exn

type CustomerInfo = Undefined
type ShippingAddress = Undefined
type BillingAddress = Undefined
type OrderLine = Undefined
type BillingAmount = Undefined

type Order = {
    CustomerInfo : CustomerInfo
    ShippingAddress : ShippingAddress
    BillingAddress : BillingAddress
    OrderLines : OrderLine list
    AmountToBill : BillingAmount
}

此方法意味着可以继续使用类型对领域进行建模, 并且编译代码; 但当尝试编写处理这些类型的函数时, 会被强制用更好一点的 “东西” 去替换 Undefined.

Modeling with Choice Types

在我们的领域中, 我们也看到许多 OR 类型, 例如:

data ProductCode =
    WidgetCode
    OR GizmoCode

data OrderQuantity =
    UnitQuantity
    OR KilogramQuantity

我们可以使用 Choices with OR 对它们进行建模.

type ProductCode =
    | Widget of WidgetCode
    | Gizmo of GizmoCode

type OrderQuantity =
    | Unit of UnitQuantity
    | Kilogram of KilogramQuantity

在这种情况下, 区别于 “single-case”, 类型名与构造子名并不需要相同, 例如 WidgetWidgetCode.

Modeling Workflows with Functions

现在我们已经对数据结构 – “the nouns of the ubiquitous language” 进行了建模. 接下来, 我们将对工作流进行建模 – “the verbs of the ubiquitous language”.

例如, 如果我们有一个验证订单表单的工作流, 我们可能会将其记录为:

type ValidateOrder = UnvalidatedOrder-> ValidatedOrder

显而易见, 验证订单流程会将未验证的订单转换为已验证的订单.

Working with Complex Inputs and Outputs

每个函数只有一个输入和一个输出, 但某些工作流可能具有多个输入和输出 –– 我们如何建模?

我们将从输出开始. 如果工作流具有 outputAoutputB, 则可以创建 AND 类型来存储它们. 我们在 order-placing 工作流中看到了这一点: 输出需要三个不同的事件. 因此, 让我们创建一个复合类型来将它们建模为一条记录:

type PlaceOrderEvents = {
    AcknowledgmentSent : AcknowledgmentSent
    OrderPlaced : OrderPlaced
    BillableOrderPlaced : BillableOrderPlaced
}

然后, 可以将 order-placing 工作流建模为函数类型:

type PlaceOrder = UnvalidatedOrder -> PlaceOrderEvents

另一方面, 如果工作流具有 outputAoutputB, 则可以创建一个 OR 类型来存储它们. 例如, 我们简要讨论了将客户邮件分类为报价或订单. 这个过程对产出至少有两种不同的选择:

process "Categorize Inbound Mail" =
    input: Envelope contents
    output:
        QuoteForm (put on appropriate pile)
        OR OrderForm (put on appropriate pile)
        OR ...

很容易对此进行建模: 只需创建一个新的 OR 类型(例如 CategorizedMail)来表示结果, 然后让 CategorizeInboundMail 过程返回该类型. 最后, 我们的模型可能如下所示:

type CategorizedMail =
    | Quote of QuoteForm
    | Order of OrderForm

type CategorizeInboundMail = EnvelopeContents -> CategorizedMail

现在, 让我们来看看建模输入. 如果工作流具有不同的输入选择, 则可以创建 OR 类型. 但是, 如果流程有多个必需的输入, 例如下面的 “Calculate Prices, 我们可以在两种可能的方法之间进行选择.

"Calculate Prices" =
    input: OrderForm, ProductCatalog
    output: PricedOrder

第一个最简单的方法是将每个输入作为单独的参数传递, 如下所示:

type CalculatePrices = OrderForm -> ProductCatalog -> PricedOrder

或者, 我们可以创建新的 AND 类型来同时包含它们, 例如:

type CalculatePricesInput = {
    OrderForm : OrderForm
    ProductCatalog : ProductCatalog
}

现在函数如下所示:

type CalculatePrices = CalculatePricesInput -> PricedOrder

哪一种方式更好?

在上面的例子中, 如果 ProductCatalog 是依赖项而不是 “实际” 输入, 则我们希望使用第一种方法(单独的参数). 这使我们能够使用函数式编程中的依赖注入. 我们将在后面 <<依赖注入>> 章节中详细讨论这一点, 届时我们将实现订单处理管道.

另一方面, 如果两个输入始终是必需的, 并且彼此紧密相连, 则应使用 AND 类型.(在某些情况下, 可以使用 tuples 作为简单 AND 类型的替代方法, 但通常最好使用命名类型.)

Documenting Effects in the Function Signature

我们刚刚看到 ValidateOrder 可以这样编写:

type ValidateOrder = UnvalidatedOrder -> ValidatedOrder

但是, 这假定了验证过程始终有效, 并且始终返回已验证订单. 实际上, 这个过程可能会出错, 因此最好通过在函数签名中返回 Result 类型(Either in Haskell)来指示这一点:

type ValidateOrder =
    UnvalidatedOrder -> Result<ValidatedOrder, ValidationError list>

type ValidationError = {
    FieldName : string
    ErrorDescription : string
}

此签名显示输入是 UnvalidatedOrder, 如果成功, 则输出为 ValidatedOrder, 但如果验证失败, 则结果为 ValidationError 列表, 该列表又包含错误描述及其应用于哪个字段的说明.

函数编程人员使用术语 “effects” 来描述函数除了其主要输出之外另外执行的事情(函数副作用). 通过使用 Result 类型, 我们现在已经表明出 ValidateOrder 可能具有 “error effects” – 类型签名中明确说明, 我们不能保证函数始终成功, 并且我们应该准备好处理错误.

同样, 我们也可能会希望记录进程是异步的. 我们怎样才能做到这一点?

F# 中, 我们使用 Async 类型来表示函数将具有异步效果. 因此, 如果 ValidateOrder 具有异步效应和错误效果, 我们将编写如下函数类型:

type ValidateOrder =
    UnvalidatedOrder -> Async<Result<ValidatedOrder,ValidationError list>>

此类型签名现在明确表明:

  1. 当我们尝试获取返回值的内容时, 代码不会立即返回.
  2. 当它真的返回结果时, 结果可能是错误.

像这样显式地列出所有效果很有用, 但它确实使类型签名变得丑陋且复杂, 因此我们通常会为此创建一个类型别名, 使其看起来更美观.

type ValidationResponse<'a> = Async<Result<'a,ValidationError list>>

type ValidateOrder =
    UnvalidatedOrder -> ValidationResponse<ValidatedOrder>

A Question of Identity: Value Objects

我们已经了解了对领域数据和工作流建模的基本方法. 现在, 让我们继续研究一种对数据类型进行分类的重要方式 – 基于数据类型是否具有持久标识.

在 DDD 术语中, 具有持久身份的对象称为 Entities(实体), 而没有持久身份的对象称为 Value Objects(值对象). 让我们首先讨论值对象.

在许多情况下, 我们正在处理的数据对象没有身份 – 它们是可互换的. 例如, 出现在所有地方值为 W1234 的 WidgetCode 都彼此相等, 我们不需要区分它们.

let widgetCode1 = WidgetCode "W1234"
let widgetCode2 = WidgetCode "W1234"
printfn "%b" (widgetCode1 = widgetCode2) // prints "true"

“values without identity” 的概念在领域模型中经常出现, 无论是复杂类型还是简单类型. 例如, 一个 PersonalName 的 AND 类型可能具有 FirstNameLastName 两个字段, 因此它比简单的字符串复杂, 但它也是一个值对象, 因为具有相同字段的两个个人名称是可以互换的.

let name1 = {FirstName="Alex"; LastName="Adams"}
let name2 = {FirstName="Alex"; LastName="Adams"}
printfn "%b" (name1 = name2) // prints "true

例如 “address” 类型也是值对象, 如果两个值具有相同的街道地址以及城市和邮政编码, 则它们是相同的地址.

Implementing Equality for Value Objects

当我们使用 F# 代数类型系统对领域建模时, 默认情况下, 我们创建的类型基于字段的相等性判断 – 我们不需要自己编写任何判断相等性的代码.

准确地说, 在 F# 中, 如果两个 AND 类型值的所有字段都相等, 则两个值(相同类型)相等; 如果两个 OR 类型的选择情况相同, 则两个选择值相等; 这称为结构平等.

而在其它语言中, 我们可能需要重写 Equals 之类的方法.

A Question of Identity: Entities

但是, 我们也经常需要对在现实世界中具有独特标识的事物进行建模, 即使它们的组成发生变化, 但它们依然是同一个事物. 例如, 即使我更改了姓名或地址, 我仍然是同一个人.

DDD 术语中, 这些事物被称为 Entities(实体).

在我们实例的上下文中, 实体通常是某种类型的文档: 订单, 报价, 发票, 客户资料, 产品单等. 它们具有生命周期, 并通过各种业务流程从一种状态转换为另一种状态.

值对象与实体之间的区别取决于其所在的上下文. 例如, 考虑手机的生命周期.

  • 在制造过程中, 每部手机都会获得一个唯一的序列号, 因此在这种情况下, 它们将被建模为实体.
  • 在出售时, 序列号无关紧要-所有规格相同的手机都是可以互换的-可以将它们建模为值对象.
  • 一旦将特定手机出售给特定客户, 身份就会再次变得相关, 应该将其建模为一个实体: 即使更换屏幕或电池, 客户也将其视为同一部手机.

Identifiers for Entities

在对实体进行建模时, 我们需要为它们提供唯一的标识符或键, 例如 Order Id, or Customer Id.

例如下面的 Contact 类型, 不管 PhoneNumberEmailAddress 属性怎么更改, 它的 ContactId 属性保持不变.

type ContactId = ContactId of int

type Contact = {
    ContactId : ContactId
    PhoneNumber : ...
    EmailAddress: ...
}

这些标识符从何而来?

有时, 标识符是由真实世界本身提供的, 例如纸质订单和发票上总是写有某种单号; 但有时, 我们需要使用 UUID, 自动递增数据库表, ID 生成服务等技术自己创建一个人工标识符. 在我们的实例中, 仅假设客户已向我们提供了标识符.

Adding Identifiers to Data Definitions

向 AND 类型添加标识符很简单, 只需添加一个字段, 但是如何向 OR 类型添加标识符? 我们应该将标识符放在内部(与每个 case 关联)还是在外部(与任何 case 都不关联)?

例如, 假设我们有两个发票选项: UnpaidPaid.

如果我们使用外部方式对其进行建模, 我们将有一个包含 InvoiceId 的 AND 类型, 然后在该类型内有一个选择类型 InvoiceInfo, 其中包含每种发票类型的信息. 该代码将如下所示:

// Info for the unpaid case (without id)
type UnpaidInvoiceInfo = ...

// Info for the paid case (without id)
type PaidInvoiceInfo = ...

// Combined information (without id)
type InvoiceInfo =
    | Unpaid of UnpaidInvoiceInfo
    | Paid of PaidInvoiceInfo

// Id for invoice
type InvoiceId = ...

// Top level invoice type
type Invoice = {
    InvoiceId : InvoiceId // "outside" the two child cases
    InvoiceInfo : InvoiceInfo
}

如果使用内部方式, 我们将创建两个单独的类型(UnpaidInvoicePaidInvoice), 这两个类型都有自己的 InvoiceId, 然后是一个在它们之间进行选择的顶级 OR 类型 Invoice. 该代码将如下所示:

type UnpaidInvoice = {
    InvoiceId : InvoiceId // id stored "inside"
    // and other info for the unpaid case
}

type PaidInvoice = {
    InvoiceId : InvoiceId // id stored "inside"
    // and other info for the paid case
}

// top level invoice type
type Invoice =
    | Unpaid of UnpaidInvoice
    | Paid of PaidInvoice

相对于外部方式, 内部方式都易于使用模式匹配, 它将所有的数据都放在一起, 包括 id:

let invoice = Paid {InvoiceId = ...}

match invoice with
    | Unpaid unpaidInvoice ->
      printfn "The unpaid invoiceId is %A" unpaidInvoice.InvoiceId
    | Paid paidInvoice ->
      printfn "The paid invoiceId is %A" paidInvoice.InvoiceId

在实践中, 更常见的是使用内部方法.

Implementing Equality for Entities

前面我们看到, 默认情况下, F# 中的相等性判断使用类型的所有字段. 但是, 当我们比较实体时, 我们只想使用标识符字段. 这意味着, 为了在 F# 中正确建模实体, 我们必须更改默认行为.

一种方法是重写相等性判断, 以便仅使用标识符. 要更改默认判断逻辑, 我们必须:

  1. 重写 Equals 方法.
  2. 重写 GetHashCode 方法.
  3. CustomEqualityNoComparison 属性添加到类型中, 以告知编译器我们要更改默认行为.
[<CustomEquality; NoComparison>]
type Contact = {
    ContactId : ContactId
    PhoneNumber : PhoneNumber
    EmailAddress: EmailAddress
}
with
override this.Equals(obj) =
    match obj with
        | :? Contact as c -> this.ContactId = c.ContactId
        | _ -> false
override this.GetHashCode() =
    hash this.ContactId

Immutability and Identity

在函数式编程中, 值是不可变的, 这意味着到目前为止定义的对象在初始化后都无法更改.

对于值对象, 这非常好. 但对实体而言, 则是另一回事. 因为实体有生命周期, 我们希望与实体相关的数据会随着生命周期变化 – 这就是拥有恒定标识符的全部意义. 那么如何使不可变数据结构实现这一点?

答案是在保留身份的同时使用更改后的数据复制实体. 看起来这些复制操作似乎造成很多额外的工作, 但实际上并不是问题.

下面是一个如何在 F#中更新实体的示例.

其它语言中, 可以使用 Lens(透镜).

首先, 我们将从一个初始值开始:

let initialPerson = {PersonId=PersonId 42; Name="Joseph"}

要在仅更改某些字段的同时复制值, F# 具有 with 关键字, 其用法如下:

let updatedPerson = {initialPerson with Name="Joe"}

复制之后, updatedPerson 具有不同的名称, 但与 initialPerson 具有相同的 PersonId.

使用不可变数据结构的好处是进行任何更改都必须在类型签名中明确表示. 例如, 如果我们要编写一个函数来更改 Person 中的 Name 字段, 则不能使用以下签名的函数:

type UpdateName = Person -> Name -> unit

该函数没有输出, 这意味着没有任何改变(或者说 Person 没有副使用). 我们的函数必须有一个 Person 类型作为输出, 如下所示:

type UpdateName = Person -> Name -> Person

这清楚地表明, 给定一个人和一个名字, 将返回原始人的某种变体.

Aggregates

让我们仔细看看与我们的设计特别相关的两个数据类型: OrderOrderLine.

What is OrderLine?
下订单时, 订购的货物在订单中, 一种产品表现为一行, 也就是一个品项的产品.
也就是说, OrderLine 从属 Order, 一个 Order 包含多个 OrderLine.

首先, Order 是实体还是值对象? 显然, 这是一个实体 – Order 的详细信息可能会随着时间的流逝而变化(待验证->已验证…), 但是它是相同的一个 Order.

OrderLine 呢? 如果我们更改特定 OrderLine 的数量, 它仍然是同一 OrderLine 吗?
在大多数设计中, 是这样的, 即使数量或价格随时间发生了变化, 它仍然是相同的 OrderLine. 因此, OrderLine 也是一个具有其自身标识符的实体.

那么问题是, 如果我们更改了一个 OrderLine, 是否也要更改它所在的 Order?
我们的例子中, 答案很明显是更改了 OrderLine 也要更改整个 Order.

但事实上, 因为使用了不可变的数据结构, 如果有一个包含不可变 OrderLine 列表的不可变 Order, 那么仅仅创建一份 OrderLine 的副本并不会创建 Order 的副本.

所以, 为了更改 Order 中包含的 OrderLine, 需要在 Order 级别进行更改, 而不是 OrderLine 级别. 例如, 下面是一些用于更新 OrderLine 价格的伪代码(一个函数):

/// We pass in three parameters:
/// * the top-level order
/// * the id of the order line we want to change
/// * the new price
let changeOrderLinePrice order orderLineId newPrice =
    // 1. find the line to change using the orderLineId
    let orderLine = order.OrderLines |> findOrderLine orderLineId

    // 2. make a new version of the OrderLine with the new price
    let newOrderLine = {orderLine with Price = newPrice}

    // 3. create a new list of lines, replacing
    // the old line with the new line
    let newOrderLines =
        order.OrderLines |> replaceOrderLine orderLineId newOrderLine

    // 4. make a new version of the entire order, replacing
    // all the old lines with the new lines
    let newOrder = {order with OrderLines = newOrderLines}

    // 5. return the new order
    newOrder

最终结果(函数的输出)是包含新 OrderLine 列表的新 Order, 其中某一个 OrderLine 具有新价格.

可以看到, 数据的不变性会导致数据结构中的连锁反应, 因此更改一个低级组件也要强制更改更高级别的组件. 此例中, 即使我们只是需要更改其 “子实体” 之一(OrderLine), 也总是必须对 Order 本身进行操作.

这是一个非常常见的情况: 我们有一个实体的集合, 每个实体都有自己的 ID 以及一些包含它们的 “顶级” 实体. 在 DDD 术语中, 像这样的实体集合称为聚合, 顶层实体称为聚合根.

在我们的例子中, 聚合包括 OrderOrderLine 的列表, 聚合根是 Order 本身.

Aggregates Enforce Consistency and Invariants

在更新数据时, 聚合起着重要作用. 聚合充当一致性边界 – 当聚合的一部分更新时, 可能还需要更新其他部分以确保一致性.

例如, 我们可能会扩展此设计, 以便在顶级 Order 中存储额外的 TotalPrice. 那么, 如果其中某一个 OrderLine 更改了价格, 则还必须更新 TotalPrice 以保持数据一致. 上面的 changeOrderLinePrice 函数完成了这个操作. 显然, 知道如何保持一致性的唯一组件是顶级 Order(聚合根), 因此这是在 Order 级别而不是 OrderLine 级别执行更新的另一个原因.

聚合也是确保不变性(Invariants)的地方. 假设有一个规则, Order 中始终至少有一个 OrderLine. 然后, 如果尝试删除 OrderLine, 则聚合应可确保在仅剩一个 OrderLine 时出现错误. 后续会有章节讨论这个话题.

Invariants are generally business rules/enforcements/requirements that you impose to maintain the integrity of an object at any given time.

Aggregate References

此引用非其它语言中的引用类型

现在, 假设我们需要有关客户的信息与订单相关联. 可能会诱使你将客户添加为订单的字段, 如下所示:

type Order = {
    OrderId : OrderId
    Customer : Customer // info about associated customer
    OrderLines : OrderLine list
    // etc
}

但是, 想想不变性的连锁反应 —— 如果改变了客户的任何部分, 也必须改变订单. 那真的是我们想要的吗?

更好的设计是存储客户的引用, 而不是整个客户本身. 也就是说, 我们只将 CustomerId 存储在订单类型中, 如下所示:

type Order = {
    OrderId : OrderId
    CustomerId : CustomerId // reference to associated customer
    OrderLines : OrderLine list
    // etc
}

使用这种方式, 如果我们需要有关客户的完整信息, 先从订单中获取 CustomerId, 然后从数据库中单独加载相关的客户数据, 而不是将其作为订单的一部分加载. 也就是说, 客户和订单是不同且独立的聚合. 它们各自负责自己的内部一致性, 它们之间的唯一连接是通过聚合根的对象标识符.

这导致聚合的另一个重要方面: 它们是持久性的基本单位. 如果要从数据库中加载或保存对象, 则应加载或保存整个聚合. 每个数据库事务都应使用单个聚合, 并且不包括多个聚合或跨聚合边界. 后续章节会有案例参考.

同样, 如果要序列化对象以将其进行传递, 则始终发送整个聚合, 而不是发送其中的一部分.

明确一点, 并不是所有实体的集合都能成为聚合. 例如, 客户列表是实体的集合, 但它不是 DDD 所说的 “聚合”, 因为它没有顶级实体作为聚合根, 并且它一个也不是一致性边界.

Important Role Of Aggregates

以下是聚合在领域模型中的重要作用摘要:

  • 聚合是领域对象的集合, 可以被视为单个单元, 顶级实体充当聚合根.
  • 对聚合内对象的所有更改都必须通过聚合根进行, 并且聚合充当一致性边界, 以确保聚合内的所有数据同时正确更新.
  • 聚合是持久化、数据库事务和数据传输的原子单位.

定义聚合是设计过程中的一个重要部分. 有时, 相关的实体是同一聚合(OrderLineOrder)的一部分, 有时它们不是(CustomerOrder). 这是与领域专家协作至关重要的地方: 只有他们才能帮助我们了解实体之间的关系和一致性边界.

Putting It All Together

我们已经创建了许多类型, 让我们回顾一下它们如何作为一个完整的领域模型组合在一起.

首先, 我们将所有这些类型放在一个称为 OrderOrder.Domain 的命名空间中, 该空间用于将这些类型与其他命名空间分开. 换句话说, 我们使用 F# 中的命名空间来指示 DDD 界限上下文, 至少目前是这样.

首先是一些值对象, 它们不需要标识符.

然后是一些实体, 例如订单, 它是一个实体, 具有身份标识, 因此我们必须使用 ID 对其进行建模. 但我们现在不知道 IDstring, 还是 int 还是 guid, 但我们知道我们需要它, 因此, 现在让我们使用 Undefined. 我们将以同样的方式处理其他标识符.

最后, 让我们以工作流本身结束. 工作流的输入 UnvalidatedOrder 将从订单表单 “原样” 生成, 因此将仅包含 intstring 等基础类型. 工作流的输出需要两种类型: 工作流成功时的事件类型以及失败类型.

namespace OrderTaking.Domain

//
//
// 这些都是值对象, 不需要标识符.
//
//
// Product code related
type WidgetCode = WidgetCode of string
// constraint: starting with "W" then 4 digits
type GizmoCode = GizmoCode of string
// constraint: starting with "G" then 3 digits
type ProductCode =
    | Widget of WidgetCode
    | Gizmo of GizmoCode

// Order Quantity related
type UnitQuantity = UnitQuantity of int
type KilogramQuantity = KilogramQuantity of decimal
type OrderQuantity =
    | Unit of UnitQuantity
    | Kilos of KilogramQuantity

//
//
// 一些 Undefined 标识符, 以及一些实体.
//
//
type Undefined = exn
type OrderId = Undefined
type OrderLineId = Undefined
type CustomerId = Undefined
type CustomerInfo = Undefined
type ShippingAddress = Undefined
type BillingAddress = Undefined
type Price = Undefined
type BillingAmount = Undefined

type Order = {
    Id : OrderId // id for entity
    CustomerId : CustomerId // customer reference
    ShippingAddress : ShippingAddress
    BillingAddress : BillingAddress
    OrderLines : OrderLine list
    AmountToBill : BillingAmount
}

and OrderLine = {
    Id : OrderLineId // id for entity
    OrderId : OrderId
    ProductCode : ProductCode
    OrderQuantity : OrderQuantity
    Price : Price
}

//
//
// 定义工作流及其输入和输出.
//
//
type UnvalidatedOrder = {
    OrderId : string
    CustomerInfo : ...
    ShippingAddress : ...
    ...
}

type PlaceOrderEvents = {
    AcknowledgmentSent : ...
    OrderPlaced : ...
    BillableOrderPlaced : ...
}

type PlaceOrderError =
    | ValidationError of ValidationError list
    | ... // other errors

and ValidationError = {
    FieldName : string
    ErrorDescription : string
}

/// The "Place Order" process
type PlaceOrder =
    UnvalidatedOrder -> Result<PlaceOrderEvents, PlaceOrderError>

但我们的模型尚未完成. 例如, 该如何对订单的不同状态进行建模: 验证、定价等?