DDD领域建模的函数式实现案例 - kkreuning


领域原语构建的正确领域模型会迫使我们开发人员做正确的事情。
让我们想出一个简单的 Java 方法签名示例:
String foo(String str);
这段代码有什么作用?签名只告诉我们它是一个方法:它对输入字符串执行某些操作,或者它可能无法执行某些操作并返回null,或改变某些状态,或执行其他副作用,或抛出一个Throwable。
现在让我们看一下 Scala 中的等效签名:
现在让我们看一下 Scala 中的等效签名:

sealed trait PostalCode
case class Zipcode(i: Int) extends PostalCode 
case class Postleitzahl(i: Int) extends PostalCode 

def foo(str: String): Option[PostalCode]

更好的是,此代码将一个String转换为两种可能PostalCodes(美国或德国)之一,否则会返回None. 函数是尝试从字符串创建有效的邮政编码。我们只需要用几种类型指定我们的域,现在这段代码的意图很明确。
 
借用领域驱动设计中的一个术语,领域是专用于构建领域原语的那些业务
原始语言(String,boolean,int,float等方式)是一种编程代码的构建块;而领域原语是领域模型和业务逻辑的构建块(ShoppingBag,Order,Quantity等业务方式)。
领域原语的定义是精确的,不可能是非法的,它的存在意味着它的有效性。
当在实际业务逻辑中使用这些域原语时,以这种方式建模的真正力量就会显现出来:
def x(p: PostalCode, n: HouseNumber): Future[Option[Address]]
此代码只能做一件事:根据邮政编码和门牌号异步返回地址或不返回结果。此功能适用于任何人,PostalCode因此我们可以推断每个域都支持美国和德国地址。
而你能从以下方法明白地搞清楚这些吗?
CompletionStage<String> x(String s, int i);
 
语言原语和炸弹处理
让我们用另一个例子来研究在领域逻辑中使用语言原语的潜在问题,这次使用正确的命名,我们在其中建模一种从一个账户向另一个账户转账的方法:
boolean transfer(String fromAccountNumber, String toAccountNumber, BigDecimal amount);
现在让我们计算一下该方法可能被错误调用的方式:

  1. 任何论点都可以null。
  2. amount 可以为零,这在转账时没有意义。
  3. amount 可以是负面的,这也没有意义。
  4. fromAccountNumber或者toAccountNumber可能不是实际帐号。
  5. fromAccountNumber并且toAccountNumber可以是相同的帐号,这又没有意义。
  6. fromAccountNumber并且toAccountNumber可以混淆,向错误的方向转移资金。

这是这种方法的 9 个潜在错误,编译器不会帮助我们解决其中任何一个。
 
类型别名
相比之下,请考虑使用优秀的scala-newtype库的以下 Scala 代码:

@newtype case class FromAccount(a: Account)
@newtype case class ToAccount(a: Account)

def transfer(from: FromAccount, to: ToAccount, amount: Money): Boolean

我们像以前一样添加了额外的类型信息,现在如果调用者混淆了参数,我们的代码将无法编译。我们一直向上移动缺陷层次结构,很好!
上面的代码比较冗长,但谁在乎呢?代码是类型安全的,其意图更加明确。
许多现代高级语言支持一些类型别名和/或新类型的概念:

Java 什么都没有,所以我们必须自己创建包装器。
 
如何发现弱模型
这是一个不完整的需要注意的事项清单,可能需要进行一些炸弹处理:
  • 检查nulls,应使用Option类型对缺席进行建模。
  • 具有两个或多个相同类型参数的函数(除非您的函数是可交换的)。
  • 在使用Set或OrderedSet更合适地方使用List
  • 语言原语作为业务逻辑的方法输入。
  • UUIDs 作为方法参数,客户 uuid 与订单 uuid 不同。
  • 当需要至少一个元素时,使用 List而不是NonEmptyList。